From 6c5bdbf63aee54503233f2915b346d33dc4ef8b0 Mon Sep 17 00:00:00 2001 From: Sergey Shumakov Date: Wed, 19 Aug 2026 23:56:51 +0300 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20family=20surface=20=E2=80=94=20vers?= =?UTF-8?q?ion-agnostic=20dispatch=20members=20on=20welded=20family=20base?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two or more welded classes deriving one welded base (the shape a versioned class template welded per range makes) now gain a synthesized surface ON the base: the member intersection the concretes bind identically, each member a type-switch dispatch to the concrete class. Pure managed text over the concretes' own accessors — no new thunks, no new P/Invokes, the shim is byte-identical. Hoist rules: - identical C# spellings hoist with the exact type (scalars, strings, the shared scalar-sequence wrappers), settable when every concrete's is; - a welded-class member hoists as the member types' common welded base — the getter upcasts, the setter downcasts (InvalidCastException names a wrong-era assignment); - a sequence of welded elements hoists as a read-only FamilyVector live view (Count + indexer + duck-typed foreach over the concrete wrapper); - a method overload spelled identically on every concrete hoists as a forwarding dispatch; a bare base instance throws InvalidOperationException from the default arm. Mechanics: the field/method emitters record each class's member manifest (names + resolved C# spellings, placeholders included) beside their emissions; the class writer flushes it into document::family_records; the render pass groups records by resolved first-welded-base and appends one 'partial class ' block per family after its namespace's declarations. options::family_surface (default ON) restores the prior artifact when off. Existing surfaces are untouched byte-for-byte (goldens unchanged): the shared inheritance cases' sibling classes declare disjoint members, so no family there has a non-empty intersection. New family.hpp/gen_family.cpp pair + FamilyTests.cs lock the whole matrix (41 managed tests). Co-Authored-By: Claude Fable 5 --- src/welder/rods/csharp/document/artifacts.hpp | 369 +++++++++++++++++- .../rods/csharp/document/class_writer.hpp | 13 + src/welder/rods/csharp/emit/fields.hpp | 60 +++ src/welder/rods/csharp/emit/methods.hpp | 28 ++ tests/csharp/CMakeLists.txt | 13 + tests/csharp/app/FamilyTests.cs | 107 +++++ tests/csharp/cpp/family.hpp | 71 ++++ tests/csharp/cpp/gen_family.cpp | 8 + 8 files changed, 667 insertions(+), 2 deletions(-) create mode 100644 tests/csharp/app/FamilyTests.cs create mode 100644 tests/csharp/cpp/family.hpp create mode 100644 tests/csharp/cpp/gen_family.cpp diff --git a/src/welder/rods/csharp/document/artifacts.hpp b/src/welder/rods/csharp/document/artifacts.hpp index 55b2069..b108fae 100644 --- a/src/welder/rods/csharp/document/artifacts.hpp +++ b/src/welder/rods/csharp/document/artifacts.hpp @@ -5,6 +5,8 @@ #include #include +#include // emit_doc_comment (family surface) + /** @file The **two artifacts** the C# rod emits, and the placeholder machinery that lets them be written out of order. @@ -71,6 +73,17 @@ struct options { bespoke per-member emission for everything (the pre-erasure artifact, byte for byte). */ bool erased_fields{true}; + /** Whether every welded FAMILY — two or more welded classes deriving one + welded base (the shape a versioned class template welded per range + makes) — gets a synthesized version-agnostic surface on the base: the + member intersection the era classes bind identically, as dispatch + properties/methods that type-switch to the concrete class. Pure + managed-side text over the concretes' own accessors — no new thunks, + no new P/Invokes, the shim is untouched. A base-typed instance that is + not one of the family's concretes throws + `InvalidOperationException` from the dispatch default arm. Off = the + pre-family artifact, byte for byte. */ + bool family_surface{true}; /** How many files `Bindings.cs` is split into (default 1 — the single file). Unlike @ref shards this is not a compile-time measure: Roslyn is indifferent to file count (measured — one 11 MB file and 83 small ones @@ -100,6 +113,42 @@ struct ns_section { std::vector breaks{}; }; +/** One bound member of a welded class, as the family-surface synthesis needs + it (@ref options::family_surface): the C# spellings the emitters resolved, + recorded beside the emission so the render-time pass can intersect a + family's surfaces without re-deriving anything from reflection. Type + spellings may carry the render-time reference placeholders — they compare + exactly (same C++ type ⇒ same placeholder) and resolve at render like any + other emitted reference. */ +struct family_member { + std::string name{}; /**< The member's C# name. */ + bool method{false}; /**< Method (dispatchable overload) vs property. */ + std::string type_str{}; /**< Property: the public C# type spelling. */ + std::string elem_ref{}; /**< Property over a sequence of WELDED elements: + the element's type placeholder (else empty). */ + bool handle_like{false}; /**< Property typed as a welded class. */ + bool settable{false}; /**< Property: whether a `set` arm was emitted. */ + std::string ret_str{}; /**< Method: the public return type spelling. */ + std::string params_decl{}; /**< Method: the public parameter list. */ + std::string args{}; /**< Method: the forwarding argument names. */ + std::string doc{}; /**< The member's doc text (may be empty). */ +}; + +/** One welded class's identity + member manifest, flushed by the class writer + for the family-surface synthesis: the render pass groups these by resolved + first-welded-base and hoists each family's member intersection onto the + base as dispatch members. */ +struct family_record { + std::string cs_path{}; /**< The dotted C# path from the root namespace. */ + std::string cs_ns{}; /**< The enclosing namespace's dotted path. */ + std::string cs_name{}; /**< The C# class name (the leaf). */ + std::string base_ref{}; /**< First welded base's placeholder ref, or empty. */ + bool nested{false}; /**< Nested classes neither form nor head a family. */ + std::vector surface_names{}; /**< The class's own member names. */ + std::vector nested_names{}; /**< Its nested type names. */ + std::vector members{}; /**< The member manifest. */ +}; + /** The growing pair of documents shared by every writer handle: the native shim and the managed wrapper. Class/enum text lands in a per-namespace @ref ns_section::types; free functions and namespace variables land in that section's @ref ns_section::statics; @@ -142,6 +191,10 @@ struct document { half. `NativeMethods` is `partial`, so each part reopens it. */ std::vector pinvoke_breaks{}; std::vector sections{}; /**< Per-namespace types + Global bodies. */ + /** Every flushed class's family manifest (@ref options::family_surface): + the render pass groups them by resolved first-welded-base and + synthesizes each family's version-agnostic base surface. */ + std::vector family_records{}; std::string containers{}; /**< Generated container-wrapper classes (root ns). */ std::vector container_keys{}; /**< Dedup (one wrapper per type). */ @@ -385,13 +438,26 @@ struct document { items.push_back({item::kind::pinvoke, "", std::move(t)}); }); items.push_back({item::kind::pinvoke, "", _cs_pinvoke_epilogue()}); + // The synthesized family surfaces (options::family_surface): each + // block is one more `partial class ` declaration, appended + // after its namespace's welded declarations. + const family_surface_text fam{_family_surface()}; + const auto push_family = [&](const std::string& ns) { + for (const auto& [fns, text] : fam.blocks) + if (fns == ns) + items.push_back({item::kind::types, ns, text}); + }; for (const auto& s : sections) if (s.ns.empty()) slice_at(s.types, s.breaks, [&](std::string t) { items.push_back({item::kind::types, s.ns, std::move(t)}); }); - if (!containers.empty()) - items.push_back({item::kind::containers, "", containers}); + push_family(""); + std::string containers_text{containers}; + if (fam.uses_family_vector) + containers_text += _family_vector_support(); + if (!containers_text.empty()) + items.push_back({item::kind::containers, "", std::move(containers_text)}); for (const auto& s : sections) if (s.ns.empty() && !s.statics.empty()) items.push_back({item::kind::statics, s.ns, s.statics}); @@ -401,6 +467,7 @@ struct document { slice_at(s.types, s.breaks, [&](std::string t) { items.push_back({item::kind::types, s.ns, std::move(t)}); }); + push_family(s.ns); if (!s.statics.empty()) items.push_back({item::kind::statics, s.ns, s.statics}); } @@ -477,6 +544,304 @@ struct document { } private: + /** What @ref _family_surface hands the render: one synthesized + `partial class ` block per family, keyed by the base's + namespace, plus whether any block needs the `FamilyVector` + support type. */ + struct family_surface_text { + std::vector> blocks{}; + bool uses_family_vector{false}; + }; + + /** Resolve reference @a s — a `\x01raw\x02` placeholder, or already-plain + text — against @ref type_names. + @param s the reference. + @return the dotted C# path, or empty when the placeholder is unknown. */ + std::string _resolved_ref(const std::string& s) const { + if (s.size() < 2 || s.front() != '\x01' || s.back() != '\x02') + return s; + const std::string raw{s.substr(1, s.size() - 2)}; + for (const auto& [r, cs] : type_names) + if (r == raw) + return cs; + return {}; + } + + /** Synthesize the version-agnostic family surfaces + (@ref options::family_surface). A FAMILY is two or more top-level + welded classes sharing one welded base; each family's base gains a + `partial class` block holding the member INTERSECTION the concretes + bind identically, each member a dispatch on the concrete class: + + - a property whose C# type is the same on every concrete hoists with + that exact type (settable when every concrete's is); + - a property typed as a welded class hoists as the member types' + common welded base — the getter upcasts, the setter downcasts (an + `InvalidCastException` names a wrong-era assignment); + - a property over a sequence of welded elements hoists as a read-only + `FamilyVector` live view over the concrete's wrapper; + - a method overload whose parameter list and return type spell the + same on every concrete hoists as a forwarding dispatch. + + Everything else — era-gated members, shape-changing members — stays + on the concretes, reached by pattern matching. The synthesis is pure + managed text over the concretes' own accessors: no thunks, no + P/Invokes, and the shim never changes. + @return the per-namespace blocks. */ + family_surface_text _family_surface() const { + family_surface_text out{}; + if (!opts.family_surface || family_records.empty()) + return out; + const auto record_at = [&](const std::string& path) -> const family_record* { + if (path.empty()) + return nullptr; + for (const auto& r : family_records) + if (r.cs_path == path) + return &r; + return nullptr; + }; + // The common welded base of the member types referenced by @a refs + // (each a type placeholder): every referenced class must BE it or + // directly derive it. Empty when there is none. + const auto common_base = [&](const std::vector& refs) + -> std::string { + std::string candidate{}; + for (const std::string& ref : refs) { + const std::string t{_resolved_ref(ref)}; + const family_record* rec{record_at(t)}; + if (!rec) + return {}; + const std::string b{_resolved_ref(rec->base_ref)}; + if (candidate.empty()) + candidate = b.empty() ? t : b; + if (t != candidate && b != candidate) + return {}; + } + return candidate; + }; + // Group the top-level records by resolved base path, in weld order. + std::vector>> + families{}; + for (const auto& r : family_records) { + if (r.nested || r.base_ref.empty()) + continue; + const std::string base{_resolved_ref(r.base_ref)}; + if (base.empty()) + continue; + bool found{false}; + for (auto& [b, v] : families) + if (b == base) { + v.push_back(&r); + found = true; + break; + } + if (!found) + families.push_back({base, {&r}}); + } + static constexpr const char* reserved[]{ + "Dispose", "Clone", "ToString", "Equals", "GetHashCode"}; + const std::string default_arm{ + "default: throw new InvalidOperationException(\"no era dispatch " + "for \" + GetType().Name);"}; + for (const auto& [base_path, children] : families) { + if (children.size() < 2) + continue; + const family_record* base{record_at(base_path)}; + if (!base || base->nested) + continue; + std::string body{}; + std::vector emitted{}; + for (const family_member& fm0 : children.front()->members) { + // One hoist per property name / method signature. + const std::string key{fm0.method + ? fm0.name + "(" + fm0.params_decl + ")" + : fm0.name}; + bool skip{false}; + for (const auto& e : emitted) + if (e == key) + skip = true; + for (const char* r : reserved) + if (fm0.name == r) + skip = true; + for (const auto* names : {&base->surface_names, + &base->nested_names}) + for (const auto& n : *names) + if (n == fm0.name) + skip = true; + if (skip || fm0.name == base->cs_name) + continue; + emitted.push_back(key); + // The matching entry on EVERY concrete, or no hoist. + std::vector ms{}; + for (const family_record* c : children) { + const family_member* hit{nullptr}; + for (const family_member& m : c->members) + if (m.name == fm0.name && m.method == fm0.method && + (!m.method || m.params_decl == fm0.params_decl)) + hit = &m; + if (!hit) + break; + ms.push_back(hit); + } + if (ms.size() != children.size()) + continue; + if (fm0.method) { + bool same_ret{true}; + for (const family_member* m : ms) + if (m->ret_str != fm0.ret_str) + same_ret = false; + if (!same_ret) + continue; + emit_doc_comment(body, " ", + fm0.doc.empty() ? nullptr : fm0.doc.c_str()); + const bool is_void{fm0.ret_str == "void"}; + body += " public " + fm0.ret_str + " " + fm0.name + + "(" + fm0.params_decl + ")\n {\n" + " switch (this)\n {\n"; + for (const family_record* c : children) + body += " case " + c->cs_path + " _c: " + + (is_void ? "_c." + fm0.name + "(" + fm0.args + + "); return;" + : "return _c." + fm0.name + "(" + + fm0.args + ");") + + "\n"; + body += " " + default_arm + + "\n }\n }\n\n"; + continue; + } + // Properties: exact-type, welded-base, or sequence-of-welded. + bool same_type{true}, all_handle{true}, all_seq{true}, + settable{fm0.settable}; + for (const family_member* m : ms) { + if (m->type_str != fm0.type_str) + same_type = false; + if (!m->handle_like) + all_handle = false; + if (m->elem_ref.empty()) + all_seq = false; + if (!m->settable) + settable = false; + } + const auto refs_of = [&](bool elem) { + std::vector refs{}; + for (const family_member* m : ms) + refs.push_back(elem ? m->elem_ref : m->type_str); + return refs; + }; + std::string hoist_type{}, elem_base{}; + if (same_type) { + hoist_type = fm0.type_str; + } else if (all_handle) { + hoist_type = common_base(refs_of(false)); + if (hoist_type.empty()) + continue; + } else if (all_seq) { + elem_base = common_base(refs_of(true)); + if (elem_base.empty()) + continue; + hoist_type = "FamilyVector<" + elem_base + ">"; + out.uses_family_vector = true; + settable = false; + } else { + continue; + } + emit_doc_comment(body, " ", + fm0.doc.empty() ? nullptr : fm0.doc.c_str()); + body += " public " + hoist_type + " " + fm0.name + + "\n {\n get\n {\n" + " switch (this)\n {\n"; + for (std::size_t i{0}; i < children.size(); ++i) { + const family_record* c{children[i]}; + if (elem_base.empty()) { + body += " case " + c->cs_path + + " _c: return _c." + fm0.name + ";\n"; + } else { + body += " case " + c->cs_path + + " _c:\n {\n" + " var _s = _c." + + fm0.name + + ";\n return new " + "FamilyVector<" + + elem_base + + ">(() => _s.Count, _k => _s[_k]);\n" + " }\n"; + } + } + body += " " + default_arm + + "\n }\n }\n"; + if (settable) { + body += " set\n {\n" + " switch (this)\n {\n"; + for (std::size_t i{0}; i < children.size(); ++i) { + const family_record* c{children[i]}; + const std::string cast{ + same_type ? std::string{} + : "(" + ms[i]->type_str + ")"}; + body += " case " + c->cs_path + + " _c: _c." + fm0.name + " = " + cast + + "value; break;\n"; + } + body += " " + default_arm + + "\n }\n }\n"; + } + body += " }\n\n"; + } + if (body.empty()) + continue; + std::string block{ + " // Version-agnostic family surface (welder " + "family_surface): the members every\n" + " // concrete class deriving " + + base->cs_name + + " binds identically, dispatched on the\n" + " // concrete class. Era-gated members stay on the " + "concretes - pattern match to\n" + " // reach them.\n" + " public partial class " + + base->cs_name + "\n {\n"}; + block += body; + block += " }\n\n"; + out.blocks.push_back({base->cs_ns, std::move(block)}); + } + return out; + } + + /** The `FamilyVector` support type — the read-only, base-typed live + view the synthesized sequence properties return. Rendered once, with + the generated container wrappers, when any family hoisted a sequence + member. @return the class text, at namespace depth. */ + static std::string _family_vector_support() { + return + " /// A read-only live view over a per-era sequence " + "member, element-typed as the\n" + " /// family base: the version-agnostic spelling of a welded " + "family's vector and\n" + " /// fixed-array members. Count and the indexer read through " + "to the underlying\n" + " /// native container; foreach is duck-typed.\n" + " public sealed class FamilyVector where T : class\n" + " {\n" + " private readonly Func _count;\n" + " private readonly Func _get;\n" + " public FamilyVector(Func count, Func get) " + "{ _count = count; _get = get; }\n" + " public int Count => _count();\n" + " public T this[int i] => _get(i);\n" + " public Enumerator GetEnumerator() => new " + "Enumerator(this);\n" + " /// Duck-typed foreach support.\n" + " public struct Enumerator\n" + " {\n" + " private readonly FamilyVector _c;\n" + " private int _i;\n" + " internal Enumerator(FamilyVector c) { _c = c; " + "_i = -1; }\n" + " public bool MoveNext() => ++_i < _c.Count;\n" + " public T Current => _c[_i];\n" + " }\n" + " }\n\n"; + } + /** The file header every part opens with (comment banner, `#nullable`, `using`s). @return the shared preamble text. */ std::string _cs_header() const { diff --git a/src/welder/rods/csharp/document/class_writer.hpp b/src/welder/rods/csharp/document/class_writer.hpp index 4a5936e..93c5401 100644 --- a/src/welder/rods/csharp/document/class_writer.hpp +++ b/src/welder/rods/csharp/document/class_writer.hpp @@ -87,6 +87,11 @@ struct class_writer { std::vector surface_names{}; std::vector nested_names{}; std::string members{}; /**< Accumulated property/method/ctor text. */ + /** The member manifest the family-surface synthesis intersects + (@ref options::family_surface) — recorded by the field/method + emitters beside their emissions, flushed into + @ref document::family_records with the class's identities. */ + std::vector family_members{}; /** One recorded comparison-operator emission, held back until flush: C# requires `==`/`!=`, `<`/`>` and `<=`/`>=` in PAIRS, so pairing is @@ -130,6 +135,7 @@ struct class_writer { surface_names = std::move(o.surface_names); nested_names = std::move(o.nested_names); members = std::move(o.members); + family_members = std::move(o.family_members); comparisons = std::move(o.comparisons); indexer_sigs = std::move(o.indexer_sigs); o.doc = nullptr; @@ -355,6 +361,13 @@ struct class_writer { // into its owner's buffer and is never a boundary of its own. if (!sink) doc->section(cs_ns).breaks.push_back(out.size()); + // The class's family manifest, for the render-time family-surface + // synthesis (options::family_surface). + if (doc->opts.family_surface) + doc->family_records.push_back( + {cs_path, cs_ns, cs_name, base_ref, sink != nullptr, + std::move(surface_names), std::move(nested_names), + std::move(family_members)}); } }; diff --git a/src/welder/rods/csharp/emit/fields.hpp b/src/welder/rods/csharp/emit/fields.hpp index f8fe516..2f02e3d 100644 --- a/src/welder/rods/csharp/emit/fields.hpp +++ b/src/welder/rods/csharp/emit/fields.hpp @@ -55,6 +55,8 @@ class field_emitter { template void emit_field() { constexpr std::meta::info MT{std::meta::type_of(Mem)}; + if (_writer.doc->opts.family_surface) + record_family_field(); // A non-const SCALAR/ENUM sequence member is a LIVE object, so it // binds by reference like its welded-element siblings — a generated // wrapper with live element access and a zero-copy AsSpan() — never a @@ -167,6 +169,18 @@ class field_emitter { std::meta::remove_cvref(std::meta::return_type_of(Getter))}; constexpr bool checked{(require_marshallable(RT, true), true)}; static_assert(checked); + if (_writer.doc->opts.family_surface) { + constexpr bool has_setter{Setter != std::meta::info{}}; + family_member fm{}; + fm.name = name; + fm.type_str = public_type(); + fm.settable = has_setter; + fm.handle_like = classify(RT) == marshal_kind::handle; + fm.elem_ref = welded_seq_elem_ref(); + if (const char* d{::welder::doc_of()}; d && *d) + fm.doc = d; + _writer.family_members.push_back(std::move(fm)); + } const std::string gid{std::meta::identifier_of(Getter)}; const std::string glookup{ "wcs::named_member(" + @@ -214,6 +228,52 @@ class field_emitter { } private: + /** The element's type placeholder when @a MT is a sequence of WELDED + elements (the shape the family surface hoists as a + `FamilyVector` view), else empty. + @tparam MT the member/return type reflection. + @return the element's `\x01raw\x02` reference, or empty. */ + template + static std::string welded_seq_elem_ref() { + if constexpr (classify(MT) == marshal_kind::seq_ref) { + constexpr std::meta::info El{ + std::meta::remove_cvref(sequence_element(bare(MT)))}; + if constexpr (classify(El) == marshal_kind::handle) + return type_ref(); + } + return {}; + } + + /** Record data member @a Mem into the class's family manifest + (@ref options::family_surface): its resolved C# name, public type + spelling, settability and doc — exactly what the render-time family + synthesis intersects across a family's concretes. + @tparam Mem a reflection of the data member. + @tparam Style the name style. */ + template + void record_family_field() { + constexpr std::meta::info MT{std::meta::type_of(Mem)}; + family_member fm{}; + fm.name = ::welder::name_of(); + if constexpr (classify(MT) == marshal_kind::seq_value && + !std::meta::is_const_type(MT)) { + // The live-wrapper form emit_scalar_seq binds (its type is the + // generated container wrapper, not the by-value T[]). + fm.type_str = container_ref(); + fm.settable = !::welder::member_no_reassign(Mem, cs); + } else { + fm.type_str = public_type(); + fm.settable = !(std::meta::is_const_type(MT) || + ::welder::member_no_reassign(Mem, cs)); + fm.handle_like = classify(MT) == marshal_kind::handle; + fm.elem_ref = welded_seq_elem_ref(); + } + if (const char* d{::welder::doc_of()}; d && *d) + fm.doc = d; + _writer.family_members.push_back(std::move(fm)); + } + /** The bespoke per-member setter (thunk + P/Invoke): the member's own `operator=` spliced through `field_set`. Shared by the bespoke path and by the erased path's handle-like members, whose GETTER erases to diff --git a/src/welder/rods/csharp/emit/methods.hpp b/src/welder/rods/csharp/emit/methods.hpp index 5b81105..d4ca946 100644 --- a/src/welder/rods/csharp/emit/methods.hpp +++ b/src/welder/rods/csharp/emit/methods.hpp @@ -103,6 +103,8 @@ class method_emitter { _writer.handle_cs, _writer.handle_field} .emit(); } + if (_writer.doc->opts.family_surface) + record_family_method(); } } @@ -166,6 +168,32 @@ class method_emitter { } private: + /** Record overload @a Fn into the class's family manifest + (@ref options::family_surface): the group's resolved C# name, the + public parameter list and return spelling, and the forwarding + argument names — what the render-time family synthesis needs to + hoist an identically-spelled overload onto the family base as a + dispatch method. + @tparam Fn a reflection of the member function. + @tparam Style the name style. */ + template + void record_family_method() { + constexpr std::size_t n{std::meta::parameters_of(Fn).size()}; + const call_pieces cp{ + build_params(std::make_index_sequence{})}; + family_member fm{}; + fm.method = true; + fm.name = _group_name; + fm.ret_str = + public_return_type(); + fm.params_decl = cp.wrapper_params; + for (const std::string& p : split_param_names(cp.param_names)) + fm.args += (fm.args.empty() ? "" : ", ") + p; + if (const char* d{::welder::doc_of()}; d && *d) + fm.doc = d; + _writer.family_members.push_back(std::move(fm)); + } + /** Emit virtual slot @a Fn: the ordinary (virtual-dispatch) thunk, a qualified base-call thunk, P/Invokes for both, and one `public virtual` wrapper branching on `_isDirector`. The slot's C# name was already diff --git a/tests/csharp/CMakeLists.txt b/tests/csharp/CMakeLists.txt index ed5cd49..1a9e15f 100644 --- a/tests/csharp/CMakeLists.txt +++ b/tests/csharp/CMakeLists.txt @@ -125,6 +125,15 @@ welder_csharp_generate_bindings(welder_test_cs_namespace OUTPUT_DIR ${_ws}/namespace DEPENDS ${WELDER_TEST_COMMON_CPP}/namespace.hpp) +# The version-family synthesis (options::family_surface): per-era template +# instantiations sharing a welded base gain dispatch members ON the base. +welder_csharp_generate_bindings(welder_test_cs_family + SOURCES cpp/gen_family.cpp + LIBRARY welder_test_cs_family + INCLUDE_DIRS ${CMAKE_CURRENT_SOURCE_DIR}/cpp + OUTPUT_DIR ${_ws}/family + DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/cpp/family.hpp) + # Building the targets both regenerates the artifacts and compiles the shims — # the compile IS a test (the generated splices must re-derive every member). add_test(NAME csharp.build @@ -136,6 +145,7 @@ add_test(NAME csharp.build welder_test_cs_enums welder_test_cs_overloads welder_test_cs_properties welder_test_cs_naming welder_test_cs_nested welder_test_cs_namespace + welder_test_cs_family --config $) set_tests_properties(csharp.build PROPERTIES FIXTURES_SETUP csharp_native) @@ -222,6 +232,8 @@ file(GENERATE OUTPUT ${_app}/app.csproj CONTENT + + \" CopyToOutputDirectory=\"PreserveNewest\" /> \" CopyToOutputDirectory=\"PreserveNewest\" /> \" CopyToOutputDirectory=\"PreserveNewest\" /> @@ -237,6 +249,7 @@ file(GENERATE OUTPUT ${_app}/app.csproj CONTENT \" CopyToOutputDirectory=\"PreserveNewest\" /> \" CopyToOutputDirectory=\"PreserveNewest\" /> \" CopyToOutputDirectory=\"PreserveNewest\" /> + \" CopyToOutputDirectory=\"PreserveNewest\" /> ") diff --git a/tests/csharp/app/FamilyTests.cs b/tests/csharp/app/FamilyTests.cs new file mode 100644 index 0000000..b69966c --- /dev/null +++ b/tests/csharp/app/FamilyTests.cs @@ -0,0 +1,107 @@ +// Family-surface round-trip (options::family_surface): base-typed data access +// over welded per-era instantiations sharing a welded base — the surface +// tests/csharp/cpp/family.hpp welds. What the synthesized dispatch members +// must prove: exact-type hoists read AND write, welded members hoist as the +// family base (live views included), welded sequences hoist as a +// FamilyVector live view, methods forward, era-gated members stay on +// the concretes, and a bare base instance throws from the default arm. +using System; +using Xunit; +using family_ns; + +public class FamilyTests +{ + [Fact] + public void ExactTypeMembersHoistReadWrite() + { + using var w = new WidgetV2(); + WidgetBase b = w; + Assert.Equal(2, b.SharedScalar); + b.SharedScalar = 7; + Assert.Equal(7, w.SharedScalar); + b.Label = "azeroth"; + Assert.Equal("azeroth", w.Label); + } + + [Fact] + public void SharedSequenceWrapperHoists() + { + using var w = new WidgetV1(); + WidgetBase b = w; + b.Nums.Add(5); + b.Nums.Add(6); + Assert.Equal(2, w.Nums.Count); + Assert.Equal(6, b.Nums[1]); + } + + [Fact] + public void WeldedMemberHoistsAsFamilyBase() + { + using var w = new WidgetV2(); + WidgetBase b = w; + Assert.Equal(2, b.Gadget.Power); // GadgetBase-typed, itself dispatched + b.Gadget.Power = 9; // the live view writes through + Assert.Equal(9, w.Gadget.Power); + using var right = new GadgetV2(); + right.Power = 3; + b.Gadget = right; // same era assigns + Assert.Equal(3, w.Gadget.Power); + using var wrong = new GadgetV1(); + Assert.Throws(() => b.Gadget = wrong); + } + + [Fact] + public void WeldedSequenceHoistsAsFamilyVector() + { + using var w = new WidgetV1(); + w.Gadgets.Add(new GadgetV1()); + w.Gadgets.Add(new GadgetV1()); + WidgetBase b = w; + var view = b.Gadgets; + Assert.Equal(2, view.Count); + view[0].Power = 4; // live element view writes through + Assert.Equal(4, w.Gadgets[0].Power); + int n = 0, sum = 0; + foreach (GadgetBase g in view) + { + n++; + sum += g.Power; + } + Assert.Equal(2, n); + Assert.Equal(5, sum); // 4 + the default 1 + } + + [Fact] + public void MethodsDispatch() + { + using var w1 = new WidgetV1(); + using var w2 = new WidgetV2(); + WidgetBase b1 = w1, b2 = w2; + Assert.Equal(1, b1.Era()); + Assert.Equal(2, b2.Era()); + Assert.Equal(6, b2.Scaled(3)); + } + + [Fact] + public void EraGatedMembersStayOnTheConcretes() + { + Assert.Null(typeof(WidgetBase).GetProperty("EraGated")); + Assert.NotNull(typeof(WidgetV1).GetProperty("EraGated")); + using var w = new WidgetV1(); + WidgetBase b = w; + if (b is WidgetV1 v1) // the pattern-matching story + v1.EraGated = 42; + Assert.Equal(42, w.EraGated); + } + + [Fact] + public void BareBaseThrowsFromTheDefaultArm() + { + using var bare = new WidgetBase(); + Assert.Throws(() => bare.Era()); + Assert.Throws(() => + { + _ = bare.SharedScalar; + }); + } +} diff --git a/tests/csharp/cpp/family.hpp b/tests/csharp/cpp/family.hpp new file mode 100644 index 0000000..ac3dc73 --- /dev/null +++ b/tests/csharp/cpp/family.hpp @@ -0,0 +1,71 @@ +#pragma once +// Version-FAMILY synthesis (options::family_surface): a class template welded +// per "era" through namespace-scope aliases, every instantiation deriving one +// welded base — the versioned-format shape wowlib welds. The render pass +// hoists the member INTERSECTION onto the base as dispatch members, so +// base-typed code reads and writes data without a downcast: +// - identical spellings hoist with the exact type (scalar, string, the +// shared scalar-sequence wrapper); +// - a welded member hoists as the member types' common welded base; +// - a sequence of welded elements hoists as a FamilyVector +// read view; +// - identically-spelled methods hoist as forwarding dispatch; +// - a member whose type varies per era stays on the concretes. +// +// #included by gen_family.cpp (the WELDER_CSHARP_MAIN generator) after the +// welder vocabulary, and by the generated shim.cpp. +#include +#include +#include +#include +#include +#include + +namespace family_ns { + +// The nested entity's own family: a welded base + per-era instantiations, so +// the outer family's `gadget` / `gadgets` members have a base to hoist to. +struct +[[=welder::weld]] +GadgetBase {}; + +template +struct +[[=welder::weld]] +Gadget : GadgetBase { + [[=welder::doc("The gadget's power rating.")]] + int power{V}; +}; +using GadgetV1 = Gadget<1>; +using GadgetV2 = Gadget<2>; + +struct +[[=welder::weld]] +WidgetBase {}; + +template +struct +[[=welder::weld]] +Widget : WidgetBase { + [[=welder::doc("A scalar every era binds identically.")]] + int shared_scalar{V}; + std::string label{}; + // A non-const scalar sequence binds as the live wrapper — one generated + // type for every era, so it hoists with that exact type. + std::vector nums{}; + // A welded member: hoists as GadgetBase (getter upcast, setter downcast). + Gadget gadget{}; + // A sequence of welded elements: hoists as FamilyVector. + std::vector> gadgets{}; + // The era-gated shape: the C# type differs per era, so it stays on the + // concretes (reached by pattern matching). + std::conditional_t era_gated{}; + + [[=welder::doc("The era this instantiation is.")]] + int era() const { return V; } + int scaled(int k) const { return V * k; } +}; +using WidgetV1 = Widget<1>; +using WidgetV2 = Widget<2>; + +} // namespace family_ns diff --git a/tests/csharp/cpp/gen_family.cpp b/tests/csharp/cpp/gen_family.cpp new file mode 100644 index 0000000..b897e9d --- /dev/null +++ b/tests/csharp/cpp/gen_family.cpp @@ -0,0 +1,8 @@ +// The C#/.NET bindings generator over the version-family cases (family.hpp): +// the options::family_surface synthesis — welded per-era instantiations +// sharing a welded base gain a version-agnostic surface ON the base. +#include +#include "family.hpp" +#include + +WELDER_CSHARP_MAIN(family_ns, "family.hpp", "welder_test_cs_family") From a423eb8cd457fcc77bb3561556312fea63097bea Mon Sep 17 00:00:00 2001 From: Sergey Shumakov Date: Fri, 21 Aug 2026 15:46:32 +0300 Subject: [PATCH 2/3] family surface is opt-in: gate on welder::mark::family_surface, drop the option MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Synthesizing members onto a base is too intrusive to infer from structure alone — any two welded classes sharing a welded base would qualify. The surface is now synthesized ONLY for a base carrying [[=welder::mark::family_surface]] (skarndev/welder#3) covering this rod's language; an unmarked base is never touched, however hoistable its family's intersection is. The mark lives in welder CORE, not this rod, because base definitions sit in consumers' core headers, which must parse without any rod fetched (and gcc-16 ignores annotations on redeclarations, so the bindings layer cannot attach one after the fact). options::family_surface is gone — the mark is the one switch. Manifest recording is unconditional now: an unmarked class's manifest still resolves member types when it appears inside a marked family's members. welder pin moves from main to the mark commit (restore main once welder#3 lands). family.hpp marks its two bases and adds an Unmarked family whose perfectly hoistable intersection must synthesize nothing; FamilyTests locks that negative (42 managed tests). Co-Authored-By: Claude Fable 5 --- CMakeLists.txt | 4 +- src/welder/rods/csharp/document/artifacts.hpp | 56 +++++++++---------- .../rods/csharp/document/class_writer.hpp | 25 +++++---- src/welder/rods/csharp/emit/classes.hpp | 11 ++++ src/welder/rods/csharp/emit/fields.hpp | 14 ++--- src/welder/rods/csharp/emit/methods.hpp | 14 ++--- tests/csharp/CMakeLists.txt | 5 +- tests/csharp/app/FamilyTests.cs | 29 +++++++--- tests/csharp/cpp/family.hpp | 34 +++++++++-- tests/csharp/cpp/gen_family.cpp | 5 +- 10 files changed, 124 insertions(+), 73 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index e05601f..09de400 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -29,7 +29,9 @@ if(NOT TARGET welder::headers) include(FetchContent) FetchContent_Declare(welder GIT_REPOSITORY https://github.com/skarndev/welder.git - GIT_TAG main) + # The family_surface mark (skarndev/welder#3) - restore `main` once it + # lands there. + GIT_TAG 0d13458d2a808f62ce39f63e1275f10afa56218d) FetchContent_MakeAvailable(welder) endif() diff --git a/src/welder/rods/csharp/document/artifacts.hpp b/src/welder/rods/csharp/document/artifacts.hpp index b108fae..8b8d18c 100644 --- a/src/welder/rods/csharp/document/artifacts.hpp +++ b/src/welder/rods/csharp/document/artifacts.hpp @@ -73,17 +73,6 @@ struct options { bespoke per-member emission for everything (the pre-erasure artifact, byte for byte). */ bool erased_fields{true}; - /** Whether every welded FAMILY — two or more welded classes deriving one - welded base (the shape a versioned class template welded per range - makes) — gets a synthesized version-agnostic surface on the base: the - member intersection the era classes bind identically, as dispatch - properties/methods that type-switch to the concrete class. Pure - managed-side text over the concretes' own accessors — no new thunks, - no new P/Invokes, the shim is untouched. A base-typed instance that is - not one of the family's concretes throws - `InvalidOperationException` from the dispatch default arm. Off = the - pre-family artifact, byte for byte. */ - bool family_surface{true}; /** How many files `Bindings.cs` is split into (default 1 — the single file). Unlike @ref shards this is not a compile-time measure: Roslyn is indifferent to file count (measured — one 11 MB file and 83 small ones @@ -114,12 +103,12 @@ struct ns_section { }; /** One bound member of a welded class, as the family-surface synthesis needs - it (@ref options::family_surface): the C# spellings the emitters resolved, - recorded beside the emission so the render-time pass can intersect a - family's surfaces without re-deriving anything from reflection. Type - spellings may carry the render-time reference placeholders — they compare - exactly (same C++ type ⇒ same placeholder) and resolve at render like any - other emitted reference. */ + it (the `[[=welder::mark::family_surface]]` opt-in): the C# spellings the + emitters resolved, recorded beside the emission so the render-time pass + can intersect a family's surfaces without re-deriving anything from + reflection. Type spellings may carry the render-time reference + placeholders — they compare exactly (same C++ type ⇒ same placeholder) + and resolve at render like any other emitted reference. */ struct family_member { std::string name{}; /**< The member's C# name. */ bool method{false}; /**< Method (dispatchable overload) vs property. */ @@ -144,6 +133,9 @@ struct family_record { std::string cs_name{}; /**< The C# class name (the leaf). */ std::string base_ref{}; /**< First welded base's placeholder ref, or empty. */ bool nested{false}; /**< Nested classes neither form nor head a family. */ + bool marked{false}; /**< Carries the `family_surface` mark for this rod's + language — the OPT-IN a base must have for the + synthesis to touch it. */ std::vector surface_names{}; /**< The class's own member names. */ std::vector nested_names{}; /**< Its nested type names. */ std::vector members{}; /**< The member manifest. */ @@ -191,9 +183,10 @@ struct document { half. `NativeMethods` is `partial`, so each part reopens it. */ std::vector pinvoke_breaks{}; std::vector sections{}; /**< Per-namespace types + Global bodies. */ - /** Every flushed class's family manifest (@ref options::family_surface): - the render pass groups them by resolved first-welded-base and - synthesizes each family's version-agnostic base surface. */ + /** Every flushed class's family manifest: the render pass groups them by + resolved first-welded-base and synthesizes a version-agnostic base + surface for each family whose base carries the + `[[=welder::mark::family_surface]]` opt-in. */ std::vector family_records{}; std::string containers{}; /**< Generated container-wrapper classes (root ns). */ std::vector container_keys{}; /**< Dedup (one wrapper per type). */ @@ -438,9 +431,9 @@ struct document { items.push_back({item::kind::pinvoke, "", std::move(t)}); }); items.push_back({item::kind::pinvoke, "", _cs_pinvoke_epilogue()}); - // The synthesized family surfaces (options::family_surface): each - // block is one more `partial class ` declaration, appended - // after its namespace's welded declarations. + // The synthesized family surfaces (the marked bases): each block is + // one more `partial class ` declaration, appended after its + // namespace's welded declarations. const family_surface_text fam{_family_surface()}; const auto push_family = [&](const std::string& ns) { for (const auto& [fns, text] : fam.blocks) @@ -567,11 +560,14 @@ struct document { return {}; } - /** Synthesize the version-agnostic family surfaces - (@ref options::family_surface). A FAMILY is two or more top-level - welded classes sharing one welded base; each family's base gains a - `partial class` block holding the member INTERSECTION the concretes - bind identically, each member a dispatch on the concrete class: + /** Synthesize the version-agnostic family surfaces. A FAMILY is two or + more top-level welded classes sharing one welded base; a family whose + base carries the `[[=welder::mark::family_surface]]` opt-in (covering + this rod's language) gains a `partial class` block ON the base holding + the member INTERSECTION the concretes bind identically, each member a + dispatch on the concrete class. The mark is strictly required — + synthesizing members onto a base is too intrusive to infer from + structure alone, so an unmarked base is never touched: - a property whose C# type is the same on every concrete hoists with that exact type (settable when every concrete's is); @@ -590,7 +586,7 @@ struct document { @return the per-namespace blocks. */ family_surface_text _family_surface() const { family_surface_text out{}; - if (!opts.family_surface || family_records.empty()) + if (family_records.empty()) return out; const auto record_at = [&](const std::string& path) -> const family_record* { if (path.empty()) @@ -647,7 +643,7 @@ struct document { if (children.size() < 2) continue; const family_record* base{record_at(base_path)}; - if (!base || base->nested) + if (!base || base->nested || !base->marked) continue; std::string body{}; std::vector emitted{}; diff --git a/src/welder/rods/csharp/document/class_writer.hpp b/src/welder/rods/csharp/document/class_writer.hpp index 93c5401..b515316 100644 --- a/src/welder/rods/csharp/document/class_writer.hpp +++ b/src/welder/rods/csharp/document/class_writer.hpp @@ -87,10 +87,13 @@ struct class_writer { std::vector surface_names{}; std::vector nested_names{}; std::string members{}; /**< Accumulated property/method/ctor text. */ - /** The member manifest the family-surface synthesis intersects - (@ref options::family_surface) — recorded by the field/method - emitters beside their emissions, flushed into - @ref document::family_records with the class's identities. */ + /** Whether this class carries the `[[=welder::mark::family_surface]]` + opt-in (covering this rod's language) — the render-time family + synthesis hoists onto a base ONLY when the base is marked. */ + bool family_marked{false}; + /** The member manifest the family-surface synthesis intersects — + recorded by the field/method emitters beside their emissions, flushed + into @ref document::family_records with the class's identities. */ std::vector family_members{}; /** One recorded comparison-operator emission, held back until flush: C# @@ -135,6 +138,7 @@ struct class_writer { surface_names = std::move(o.surface_names); nested_names = std::move(o.nested_names); members = std::move(o.members); + family_marked = o.family_marked; family_members = std::move(o.family_members); comparisons = std::move(o.comparisons); indexer_sigs = std::move(o.indexer_sigs); @@ -362,12 +366,13 @@ struct class_writer { if (!sink) doc->section(cs_ns).breaks.push_back(out.size()); // The class's family manifest, for the render-time family-surface - // synthesis (options::family_surface). - if (doc->opts.family_surface) - doc->family_records.push_back( - {cs_path, cs_ns, cs_name, base_ref, sink != nullptr, - std::move(surface_names), std::move(nested_names), - std::move(family_members)}); + // synthesis (the [[=welder::mark::family_surface]] opt-in). Every + // class records one — an unmarked class's manifest still resolves + // member types when it appears INSIDE a marked family's members. + doc->family_records.push_back( + {cs_path, cs_ns, cs_name, base_ref, sink != nullptr, + family_marked, std::move(surface_names), std::move(nested_names), + std::move(family_members)}); } }; diff --git a/src/welder/rods/csharp/emit/classes.hpp b/src/welder/rods/csharp/emit/classes.hpp index ab7728f..29c27d2 100644 --- a/src/welder/rods/csharp/emit/classes.hpp +++ b/src/welder/rods/csharp/emit/classes.hpp @@ -3,8 +3,10 @@ #include #include +#include // family_surface_for #include #include +#include #include #include #include @@ -122,6 +124,15 @@ class class_opener { w.handle_field = "_h_" + sanitized(w.cs_path); w.handle_cs = w.cs_path + "Handle"; w.destroy_symbol = w.sym_prefix + "_destroy"; + // The family-surface OPT-IN: only a base carrying + // [[=welder::mark::family_surface]] (covering this rod's language) + // has a version-agnostic surface synthesized onto it — synthesizing + // members onto a base is too intrusive to infer from structure alone. + { + constexpr bool fm{::welder::family_surface_for( + std::meta::dealias(^^T), cs)}; + w.family_marked = fm; + } finish(w); return w; } diff --git a/src/welder/rods/csharp/emit/fields.hpp b/src/welder/rods/csharp/emit/fields.hpp index 2f02e3d..ef6d9a6 100644 --- a/src/welder/rods/csharp/emit/fields.hpp +++ b/src/welder/rods/csharp/emit/fields.hpp @@ -55,8 +55,7 @@ class field_emitter { template void emit_field() { constexpr std::meta::info MT{std::meta::type_of(Mem)}; - if (_writer.doc->opts.family_surface) - record_family_field(); + record_family_field(); // A non-const SCALAR/ENUM sequence member is a LIVE object, so it // binds by reference like its welded-element siblings — a generated // wrapper with live element access and a zero-copy AsSpan() — never a @@ -169,7 +168,7 @@ class field_emitter { std::meta::remove_cvref(std::meta::return_type_of(Getter))}; constexpr bool checked{(require_marshallable(RT, true), true)}; static_assert(checked); - if (_writer.doc->opts.family_surface) { + { constexpr bool has_setter{Setter != std::meta::info{}}; family_member fm{}; fm.name = name; @@ -244,10 +243,11 @@ class field_emitter { return {}; } - /** Record data member @a Mem into the class's family manifest - (@ref options::family_surface): its resolved C# name, public type - spelling, settability and doc — exactly what the render-time family - synthesis intersects across a family's concretes. + /** Record data member @a Mem into the class's family manifest: its + resolved C# name, public type spelling, settability and doc — exactly + what the render-time family synthesis intersects across a MARKED + family's concretes (an unrelated class's manifest still resolves + member types when it appears inside a marked family's members). @tparam Mem a reflection of the data member. @tparam Style the name style. */ template diff --git a/src/welder/rods/csharp/emit/methods.hpp b/src/welder/rods/csharp/emit/methods.hpp index d4ca946..6db81d0 100644 --- a/src/welder/rods/csharp/emit/methods.hpp +++ b/src/welder/rods/csharp/emit/methods.hpp @@ -103,8 +103,7 @@ class method_emitter { _writer.handle_cs, _writer.handle_field} .emit(); } - if (_writer.doc->opts.family_surface) - record_family_method(); + record_family_method(); } } @@ -168,12 +167,11 @@ class method_emitter { } private: - /** Record overload @a Fn into the class's family manifest - (@ref options::family_surface): the group's resolved C# name, the - public parameter list and return spelling, and the forwarding - argument names — what the render-time family synthesis needs to - hoist an identically-spelled overload onto the family base as a - dispatch method. + /** Record overload @a Fn into the class's family manifest: the group's + resolved C# name, the public parameter list and return spelling, and + the forwarding argument names — what the render-time family synthesis + needs to hoist an identically-spelled overload onto a MARKED family + base as a dispatch method. @tparam Fn a reflection of the member function. @tparam Style the name style. */ template diff --git a/tests/csharp/CMakeLists.txt b/tests/csharp/CMakeLists.txt index 1a9e15f..c2630eb 100644 --- a/tests/csharp/CMakeLists.txt +++ b/tests/csharp/CMakeLists.txt @@ -125,8 +125,9 @@ welder_csharp_generate_bindings(welder_test_cs_namespace OUTPUT_DIR ${_ws}/namespace DEPENDS ${WELDER_TEST_COMMON_CPP}/namespace.hpp) -# The version-family synthesis (options::family_surface): per-era template -# instantiations sharing a welded base gain dispatch members ON the base. +# The version-family synthesis: per-era template instantiations sharing a +# welded base marked [[=welder::mark::family_surface]] gain dispatch members +# ON the base (an unmarked base gains nothing — the opt-in default). welder_csharp_generate_bindings(welder_test_cs_family SOURCES cpp/gen_family.cpp LIBRARY welder_test_cs_family diff --git a/tests/csharp/app/FamilyTests.cs b/tests/csharp/app/FamilyTests.cs index b69966c..e5531f9 100644 --- a/tests/csharp/app/FamilyTests.cs +++ b/tests/csharp/app/FamilyTests.cs @@ -1,10 +1,12 @@ -// Family-surface round-trip (options::family_surface): base-typed data access -// over welded per-era instantiations sharing a welded base — the surface -// tests/csharp/cpp/family.hpp welds. What the synthesized dispatch members -// must prove: exact-type hoists read AND write, welded members hoist as the -// family base (live views included), welded sequences hoist as a -// FamilyVector live view, methods forward, era-gated members stay on -// the concretes, and a bare base instance throws from the default arm. +// Family-surface round-trip: base-typed data access over welded per-era +// instantiations sharing a welded base MARKED with +// [[=welder::mark::family_surface]] — the surface tests/csharp/cpp/family.hpp +// welds. What the synthesized dispatch members must prove: exact-type hoists +// read AND write, welded members hoist as the family base (live views +// included), welded sequences hoist as a FamilyVector live view, +// methods forward, era-gated members stay on the concretes, a bare base +// instance throws from the default arm — and an UNMARKED base synthesizes +// nothing, however hoistable its family's intersection is. using System; using Xunit; using family_ns; @@ -94,6 +96,19 @@ public void EraGatedMembersStayOnTheConcretes() Assert.Equal(42, w.EraGated); } + [Fact] + public void UnmarkedBaseSynthesizesNothing() + { + // The Unmarked family's intersection (SharedScalar, Era) is perfectly + // hoistable — but its base carries no family_surface mark, so the rod + // must leave it the bare handle-only base it always was. + Assert.Null(typeof(UnmarkedBase).GetProperty("SharedScalar")); + Assert.Null(typeof(UnmarkedBase).GetMethod("Era")); + Assert.NotNull(typeof(UnmarkedV1).GetProperty("SharedScalar")); + // The marked bases DID synthesize (the positive control). + Assert.NotNull(typeof(WidgetBase).GetProperty("SharedScalar")); + } + [Fact] public void BareBaseThrowsFromTheDefaultArm() { diff --git a/tests/csharp/cpp/family.hpp b/tests/csharp/cpp/family.hpp index ac3dc73..9fe8516 100644 --- a/tests/csharp/cpp/family.hpp +++ b/tests/csharp/cpp/family.hpp @@ -1,16 +1,19 @@ #pragma once -// Version-FAMILY synthesis (options::family_surface): a class template welded -// per "era" through namespace-scope aliases, every instantiation deriving one -// welded base — the versioned-format shape wowlib welds. The render pass -// hoists the member INTERSECTION onto the base as dispatch members, so -// base-typed code reads and writes data without a downcast: +// Version-FAMILY synthesis: a class template welded per "era" through +// namespace-scope aliases, every instantiation deriving one welded base — +// the versioned-format shape wowlib welds. For a base carrying the +// [[=welder::mark::family_surface]] OPT-IN, the render pass hoists the +// member INTERSECTION onto the base as dispatch members, so base-typed code +// reads and writes data without a downcast: // - identical spellings hoist with the exact type (scalar, string, the // shared scalar-sequence wrapper); // - a welded member hoists as the member types' common welded base; // - a sequence of welded elements hoists as a FamilyVector // read view; // - identically-spelled methods hoist as forwarding dispatch; -// - a member whose type varies per era stays on the concretes. +// - a member whose type varies per era stays on the concretes; +// - an UNMARKED base synthesizes NOTHING, however hoistable its family's +// intersection is — the opt-in default. // // #included by gen_family.cpp (the WELDER_CSHARP_MAIN generator) after the // welder vocabulary, and by the generated shim.cpp. @@ -27,6 +30,7 @@ namespace family_ns { // the outer family's `gadget` / `gadgets` members have a base to hoist to. struct [[=welder::weld]] +[[=welder::mark::family_surface]] GadgetBase {}; template @@ -41,6 +45,7 @@ using GadgetV2 = Gadget<2>; struct [[=welder::weld]] +[[=welder::mark::family_surface]] WidgetBase {}; template @@ -68,4 +73,21 @@ Widget : WidgetBase { using WidgetV1 = Widget<1>; using WidgetV2 = Widget<2>; +// The opt-in default: NO family_surface mark, so although this family's +// intersection is perfectly hoistable, the rod must synthesize nothing — +// UnmarkedBase stays the bare handle-only base it always was. +struct +[[=welder::weld]] +UnmarkedBase {}; + +template +struct +[[=welder::weld]] +Unmarked : UnmarkedBase { + int shared_scalar{V}; + int era() const { return V; } +}; +using UnmarkedV1 = Unmarked<1>; +using UnmarkedV2 = Unmarked<2>; + } // namespace family_ns diff --git a/tests/csharp/cpp/gen_family.cpp b/tests/csharp/cpp/gen_family.cpp index b897e9d..70df9a0 100644 --- a/tests/csharp/cpp/gen_family.cpp +++ b/tests/csharp/cpp/gen_family.cpp @@ -1,6 +1,7 @@ // The C#/.NET bindings generator over the version-family cases (family.hpp): -// the options::family_surface synthesis — welded per-era instantiations -// sharing a welded base gain a version-agnostic surface ON the base. +// the family-surface synthesis — welded per-era instantiations sharing a +// welded base MARKED [[=welder::mark::family_surface]] gain a +// version-agnostic surface ON the base (an unmarked base gains nothing). #include #include "family.hpp" #include From ffa71b25d62b808fb26e36db7dbeda8f8cdcbd2e Mon Sep 17 00:00:00 2001 From: Sergey Shumakov Date: Fri, 21 Aug 2026 17:27:23 +0300 Subject: [PATCH 3/3] the family-surface opt-in is the rod's own mark, not core vocabulary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design reversal of the previous commit's welder-core dependency: the family_surface mark did not belong in welder core while only this rod honors it (skarndev/welder#3 closed unmerged). The marker now lives here — defines [[=welder::rods::csharp::family_surface]] (a bare tag; no language mask — it IS this rod's mark) plus the family_surface_marked(type) query, and the class opener reads it directly. The welder pin returns to main. A consumer whose welded headers must also parse WITHOUT this rod (a Python-only build where welder-csharp is not fetched) spells the mark behind a build-driven macro that expands to nothing when the rod is absent — documented in marks.hpp; gcc-16 collects annotations only from the DEFINING declaration, so the macro must be consistent across every TU of one build tree. Same tests, same results: 42 managed (marked bases synthesize, the unmarked family's hoistable intersection synthesizes nothing), goldens untouched, 7/7 ctest. Co-Authored-By: Claude Fable 5 --- CMakeLists.txt | 4 +- src/welder/rods/csharp/document/artifacts.hpp | 11 ++-- .../rods/csharp/document/class_writer.hpp | 8 +-- src/welder/rods/csharp/emit/classes.hpp | 12 ++-- src/welder/rods/csharp/marks.hpp | 60 +++++++++++++++++++ tests/csharp/CMakeLists.txt | 4 +- tests/csharp/app/FamilyTests.cs | 5 +- tests/csharp/cpp/family.hpp | 8 ++- tests/csharp/cpp/gen_family.cpp | 2 +- 9 files changed, 87 insertions(+), 27 deletions(-) create mode 100644 src/welder/rods/csharp/marks.hpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 09de400..e05601f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -29,9 +29,7 @@ if(NOT TARGET welder::headers) include(FetchContent) FetchContent_Declare(welder GIT_REPOSITORY https://github.com/skarndev/welder.git - # The family_surface mark (skarndev/welder#3) - restore `main` once it - # lands there. - GIT_TAG 0d13458d2a808f62ce39f63e1275f10afa56218d) + GIT_TAG main) FetchContent_MakeAvailable(welder) endif() diff --git a/src/welder/rods/csharp/document/artifacts.hpp b/src/welder/rods/csharp/document/artifacts.hpp index 8b8d18c..2a7c86b 100644 --- a/src/welder/rods/csharp/document/artifacts.hpp +++ b/src/welder/rods/csharp/document/artifacts.hpp @@ -103,8 +103,9 @@ struct ns_section { }; /** One bound member of a welded class, as the family-surface synthesis needs - it (the `[[=welder::mark::family_surface]]` opt-in): the C# spellings the - emitters resolved, recorded beside the emission so the render-time pass + it (the `[[=welder::rods::csharp::family_surface]]` opt-in): the C# + spellings the emitters resolved, recorded beside the emission so the + render-time pass can intersect a family's surfaces without re-deriving anything from reflection. Type spellings may carry the render-time reference placeholders — they compare exactly (same C++ type ⇒ same placeholder) @@ -186,7 +187,7 @@ struct document { /** Every flushed class's family manifest: the render pass groups them by resolved first-welded-base and synthesizes a version-agnostic base surface for each family whose base carries the - `[[=welder::mark::family_surface]]` opt-in. */ + `[[=welder::rods::csharp::family_surface]]` opt-in. */ std::vector family_records{}; std::string containers{}; /**< Generated container-wrapper classes (root ns). */ std::vector container_keys{}; /**< Dedup (one wrapper per type). */ @@ -562,8 +563,8 @@ struct document { /** Synthesize the version-agnostic family surfaces. A FAMILY is two or more top-level welded classes sharing one welded base; a family whose - base carries the `[[=welder::mark::family_surface]]` opt-in (covering - this rod's language) gains a `partial class` block ON the base holding + base carries the rod's `[[=welder::rods::csharp::family_surface]]` + opt-in gains a `partial class` block ON the base holding the member INTERSECTION the concretes bind identically, each member a dispatch on the concrete class. The mark is strictly required — synthesizing members onto a base is too intrusive to infer from diff --git a/src/welder/rods/csharp/document/class_writer.hpp b/src/welder/rods/csharp/document/class_writer.hpp index b515316..673d66b 100644 --- a/src/welder/rods/csharp/document/class_writer.hpp +++ b/src/welder/rods/csharp/document/class_writer.hpp @@ -87,9 +87,9 @@ struct class_writer { std::vector surface_names{}; std::vector nested_names{}; std::string members{}; /**< Accumulated property/method/ctor text. */ - /** Whether this class carries the `[[=welder::mark::family_surface]]` - opt-in (covering this rod's language) — the render-time family - synthesis hoists onto a base ONLY when the base is marked. */ + /** Whether this class carries the rod's + `[[=welder::rods::csharp::family_surface]]` opt-in — the render-time + family synthesis hoists onto a base ONLY when the base is marked. */ bool family_marked{false}; /** The member manifest the family-surface synthesis intersects — recorded by the field/method emitters beside their emissions, flushed @@ -366,7 +366,7 @@ struct class_writer { if (!sink) doc->section(cs_ns).breaks.push_back(out.size()); // The class's family manifest, for the render-time family-surface - // synthesis (the [[=welder::mark::family_surface]] opt-in). Every + // synthesis ([[=welder::rods::csharp::family_surface]]). Every // class records one — an unmarked class's manifest still resolves // member types when it appears INSIDE a marked family's members. doc->family_records.push_back( diff --git a/src/welder/rods/csharp/emit/classes.hpp b/src/welder/rods/csharp/emit/classes.hpp index 29c27d2..2a45936 100644 --- a/src/welder/rods/csharp/emit/classes.hpp +++ b/src/welder/rods/csharp/emit/classes.hpp @@ -3,10 +3,9 @@ #include #include -#include // family_surface_for #include #include -#include +#include #include #include #include @@ -124,13 +123,12 @@ class class_opener { w.handle_field = "_h_" + sanitized(w.cs_path); w.handle_cs = w.cs_path + "Handle"; w.destroy_symbol = w.sym_prefix + "_destroy"; - // The family-surface OPT-IN: only a base carrying - // [[=welder::mark::family_surface]] (covering this rod's language) - // has a version-agnostic surface synthesized onto it — synthesizing + // The family-surface OPT-IN: only a base carrying the rod's own + // [[=welder::rods::csharp::family_surface]] mark has a + // version-agnostic surface synthesized onto it — synthesizing // members onto a base is too intrusive to infer from structure alone. { - constexpr bool fm{::welder::family_surface_for( - std::meta::dealias(^^T), cs)}; + constexpr bool fm{family_surface_marked(std::meta::dealias(^^T))}; w.family_marked = fm; } finish(w); diff --git a/src/welder/rods/csharp/marks.hpp b/src/welder/rods/csharp/marks.hpp new file mode 100644 index 0000000..cdb8f15 --- /dev/null +++ b/src/welder/rods/csharp/marks.hpp @@ -0,0 +1,60 @@ +#pragma once +#include + +/** @file + The C# rod's OWN annotation vocabulary — opt-ins that are meta to the C# + binding rather than to the welded C++ surface, so they live in the rod and + never touch welder core. One mark today: @ref + welder::rods::csharp::family_surface, the family-surface opt-in. + + A consumer whose welded headers must also parse WITHOUT this rod (a + Python-only build of the same project, where welder-csharp is not even + fetched) spells the mark behind a macro that expands to nothing when the + rod is absent — the annotation then simply never exists in those builds: + @code + #if defined(MYPROJ_WITH_CSHARP_ROD) + #include + #define MYPROJ_CS_FAMILY_SURFACE =welder::rods::csharp::family_surface, + #else + #define MYPROJ_CS_FAMILY_SURFACE + #endif + + struct [[ + =welder::weld, + MYPROJ_CS_FAMILY_SURFACE + =welder::doc("...") + ]] EntityBase {}; + @endcode + The macro (with its trailing comma) must expand consistently across every + TU of one build tree — drive it from the build system, not from + `__has_include`, so the generator, the shim and the library agree on the + class's annotation list. +*/ + +namespace welder::inline v0::rods::csharp { + +/** The stored form of the @ref family_surface mark. */ +struct family_surface_spec {}; + +/** The family-surface OPT-IN: placed on a welded BASE class, it opts the + base's family — two or more welded classes deriving it, the shape a + versioned class template welded per instantiation makes — into the + rod-synthesized version-agnostic surface ON the base (the member + intersection the derived classes bind identically, as dispatch members; + see the document assembler's family synthesis). Synthesizing members onto + a base is too intrusive to infer from structure alone, so the mark is + strictly required: an unmarked base is never touched. + @code + struct [[=welder::weld, =welder::rods::csharp::family_surface]] Base {}; + @endcode */ +inline constexpr family_surface_spec family_surface{}; + +/** Does @a type carry the @ref family_surface opt-in? + @param type a reflection of the welded base class to test. + @return `true` iff the mark is present. */ +consteval bool family_surface_marked(std::meta::info type) { + return !std::meta::annotations_of_with_type(type, ^^family_surface_spec) + .empty(); +} + +} // namespace welder::inline v0::rods::csharp diff --git a/tests/csharp/CMakeLists.txt b/tests/csharp/CMakeLists.txt index c2630eb..34252f3 100644 --- a/tests/csharp/CMakeLists.txt +++ b/tests/csharp/CMakeLists.txt @@ -126,8 +126,8 @@ welder_csharp_generate_bindings(welder_test_cs_namespace DEPENDS ${WELDER_TEST_COMMON_CPP}/namespace.hpp) # The version-family synthesis: per-era template instantiations sharing a -# welded base marked [[=welder::mark::family_surface]] gain dispatch members -# ON the base (an unmarked base gains nothing — the opt-in default). +# welded base marked [[=welder::rods::csharp::family_surface]] gain dispatch +# members ON the base (an unmarked base gains nothing — the opt-in default). welder_csharp_generate_bindings(welder_test_cs_family SOURCES cpp/gen_family.cpp LIBRARY welder_test_cs_family diff --git a/tests/csharp/app/FamilyTests.cs b/tests/csharp/app/FamilyTests.cs index e5531f9..75ba6c2 100644 --- a/tests/csharp/app/FamilyTests.cs +++ b/tests/csharp/app/FamilyTests.cs @@ -1,7 +1,8 @@ // Family-surface round-trip: base-typed data access over welded per-era // instantiations sharing a welded base MARKED with -// [[=welder::mark::family_surface]] — the surface tests/csharp/cpp/family.hpp -// welds. What the synthesized dispatch members must prove: exact-type hoists +// [[=welder::rods::csharp::family_surface]] (the rod's own mark) — the +// surface tests/csharp/cpp/family.hpp welds. What the synthesized dispatch +// members must prove: exact-type hoists // read AND write, welded members hoist as the family base (live views // included), welded sequences hoist as a FamilyVector live view, // methods forward, era-gated members stay on the concretes, a bare base diff --git a/tests/csharp/cpp/family.hpp b/tests/csharp/cpp/family.hpp index 9fe8516..c4c4a64 100644 --- a/tests/csharp/cpp/family.hpp +++ b/tests/csharp/cpp/family.hpp @@ -2,7 +2,8 @@ // Version-FAMILY synthesis: a class template welded per "era" through // namespace-scope aliases, every instantiation deriving one welded base — // the versioned-format shape wowlib welds. For a base carrying the -// [[=welder::mark::family_surface]] OPT-IN, the render pass hoists the +// [[=welder::rods::csharp::family_surface]] OPT-IN (the rod's own mark, +// ), the render pass hoists the // member INTERSECTION onto the base as dispatch members, so base-typed code // reads and writes data without a downcast: // - identical spellings hoist with the exact type (scalar, string, the @@ -18,6 +19,7 @@ // #included by gen_family.cpp (the WELDER_CSHARP_MAIN generator) after the // welder vocabulary, and by the generated shim.cpp. #include +#include #include #include #include @@ -30,7 +32,7 @@ namespace family_ns { // the outer family's `gadget` / `gadgets` members have a base to hoist to. struct [[=welder::weld]] -[[=welder::mark::family_surface]] +[[=welder::rods::csharp::family_surface]] GadgetBase {}; template @@ -45,7 +47,7 @@ using GadgetV2 = Gadget<2>; struct [[=welder::weld]] -[[=welder::mark::family_surface]] +[[=welder::rods::csharp::family_surface]] WidgetBase {}; template diff --git a/tests/csharp/cpp/gen_family.cpp b/tests/csharp/cpp/gen_family.cpp index 70df9a0..04c6a0f 100644 --- a/tests/csharp/cpp/gen_family.cpp +++ b/tests/csharp/cpp/gen_family.cpp @@ -1,6 +1,6 @@ // The C#/.NET bindings generator over the version-family cases (family.hpp): // the family-surface synthesis — welded per-era instantiations sharing a -// welded base MARKED [[=welder::mark::family_surface]] gain a +// welded base MARKED [[=welder::rods::csharp::family_surface]] gain a // version-agnostic surface ON the base (an unmarked base gains nothing). #include #include "family.hpp"