diff --git a/src/welder/rods/csharp/document/artifacts.hpp b/src/welder/rods/csharp/document/artifacts.hpp index 55b2069..2a7c86b 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. @@ -100,6 +102,46 @@ struct ns_section { std::vector breaks{}; }; +/** One bound member of a welded class, as the family-surface synthesis needs + 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) + 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. */ + 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. */ +}; + /** 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 +184,11 @@ 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: 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::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). */ @@ -385,13 +432,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 (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) + 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 +461,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 +538,307 @@ 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. A FAMILY is two or + more top-level welded classes sharing one welded base; a family whose + 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 + 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); + - 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 (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 || !base->marked) + 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..673d66b 100644 --- a/src/welder/rods/csharp/document/class_writer.hpp +++ b/src/welder/rods/csharp/document/class_writer.hpp @@ -87,6 +87,14 @@ struct class_writer { std::vector surface_names{}; std::vector nested_names{}; std::string members{}; /**< Accumulated property/method/ctor text. */ + /** 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 + 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 +138,8 @@ 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); o.doc = nullptr; @@ -355,6 +365,14 @@ 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 ([[=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( + {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..2a45936 100644 --- a/src/welder/rods/csharp/emit/classes.hpp +++ b/src/welder/rods/csharp/emit/classes.hpp @@ -5,6 +5,7 @@ #include #include +#include #include #include #include @@ -122,6 +123,14 @@ 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 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{family_surface_marked(std::meta::dealias(^^T))}; + 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 f8fe516..ef6d9a6 100644 --- a/src/welder/rods/csharp/emit/fields.hpp +++ b/src/welder/rods/csharp/emit/fields.hpp @@ -55,6 +55,7 @@ class field_emitter { template void emit_field() { constexpr std::meta::info MT{std::meta::type_of(Mem)}; + 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 +168,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); + { + 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 +227,53 @@ 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: 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 + 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..6db81d0 100644 --- a/src/welder/rods/csharp/emit/methods.hpp +++ b/src/welder/rods/csharp/emit/methods.hpp @@ -103,6 +103,7 @@ class method_emitter { _writer.handle_cs, _writer.handle_field} .emit(); } + record_family_method(); } } @@ -166,6 +167,31 @@ class method_emitter { } private: + /** 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 + 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/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 ed5cd49..34252f3 100644 --- a/tests/csharp/CMakeLists.txt +++ b/tests/csharp/CMakeLists.txt @@ -125,6 +125,16 @@ welder_csharp_generate_bindings(welder_test_cs_namespace OUTPUT_DIR ${_ws}/namespace DEPENDS ${WELDER_TEST_COMMON_CPP}/namespace.hpp) +# The version-family synthesis: per-era template instantiations sharing a +# 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 + 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 +146,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 +233,8 @@ file(GENERATE OUTPUT ${_app}/app.csproj CONTENT + + \" CopyToOutputDirectory=\"PreserveNewest\" /> \" CopyToOutputDirectory=\"PreserveNewest\" /> \" CopyToOutputDirectory=\"PreserveNewest\" /> @@ -237,6 +250,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..75ba6c2 --- /dev/null +++ b/tests/csharp/app/FamilyTests.cs @@ -0,0 +1,123 @@ +// Family-surface round-trip: base-typed data access over welded per-era +// instantiations sharing a welded base MARKED with +// [[=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 +// 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; + +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 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() + { + 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..c4c4a64 --- /dev/null +++ b/tests/csharp/cpp/family.hpp @@ -0,0 +1,95 @@ +#pragma once +// 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::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 +// 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; +// - 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. +#include +#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]] +[[=welder::rods::csharp::family_surface]] +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]] +[[=welder::rods::csharp::family_surface]] +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>; + +// 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 new file mode 100644 index 0000000..04c6a0f --- /dev/null +++ b/tests/csharp/cpp/gen_family.cpp @@ -0,0 +1,9 @@ +// 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::rods::csharp::family_surface]] gain a +// version-agnostic surface ON the base (an unmarked base gains nothing). +#include +#include "family.hpp" +#include + +WELDER_CSHARP_MAIN(family_ns, "family.hpp", "welder_test_cs_family")