xo-umbrella2/xo-expression2/utest/printable_render.test.cpp

2101 lines
100 KiB
C++

/* @file printable_render.test.cpp
*
* author: Roland Conybeare, Aug 2026
*
* Phase C verification for xo-expression2's printers, bottom-up. TypeRef
* first: it is the subsystem's only leaf, depending on nothing else here.
* Then DVariable, whose :typeref field nests it, then DVarRef, then
* DGlobalSymtab, then DConstant, then DIfElseExpr, then DSequenceExpr.
*
* Follows the template in xo-object2/utest/printable_render.test.cpp -- see
* .xo-backlog/xo-printable2/issues/01-aprintable-pretty-ppsink.md for why both
* renderings are pinned rather than only asserted equal.
*
* TypeRef is the first converted printer that is NOT a facet D-type, so it
* needs its own Prettifier<TypeRef> (TypeRef.hpp) exactly as it already needed
* a print::ppdetail<TypeRef>; without one it would fall through Prettifier's
* empty primary template to an operator<< it does not have.
*
* DVariable, by contrast, IS a facet D-type: it is rendered here through
* with_facet<APrintable>::mkobj(), needs a collector to exist at all, and picks
* up the nested TypeRef through that Prettifier<TypeRef>.
*
* Expectations are OBSERVED, never predicted.
*/
#include "init_expression2.hpp"
#include <xo/expression2/ApplyExpr.hpp> /* likewise DApplyExpr */
#include <xo/expression2/Constant.hpp> /* likewise DConstant */
#include <xo/expression2/DefineExpr.hpp> /* likewise DDefineExpr */
#include <xo/expression2/GlobalSymtab.hpp> /* likewise DGlobalSymtab */
#include <xo/expression2/IfElseExpr.hpp> /* likewise DIfElseExpr */
#include <xo/expression2/LambdaExpr.hpp> /* likewise DLambdaExpr */
#include <xo/expression2/Typename.hpp> /* likewise DTypename */
#include <xo/expression2/LocalSymtab.hpp> /* likewise DLocalSymtab */
#include <xo/expression2/SequenceExpr.hpp> /* likewise DSequenceExpr */
#include <xo/expression2/TypeRef.hpp>
#include <xo/expression2/VarRef.hpp> /* likewise DVarRef */
#include <xo/expression2/Variable.hpp> /* convenience header: DVariable + its facet impls */
#include <xo/type/AtomicType.hpp> /* DAtomicType, for DLocalSymtab's :types */
#include <xo/object2/Float.hpp>
#include <xo/object2/Integer.hpp>
#include <xo/gc/X1Collector.hpp>
#include <xo/stringtable2/StringTable.hpp>
#include <xo/alloc2/arena/IAllocator_DArena.hpp>
#include <xo/alloc2/CollectorTypeRegistry.hpp>
#include <xo/printable2/Printable.hpp>
#include <xo/indentlog2/print/toppstr.hpp>
#include <xo/reflect/Reflect.hpp>
#include <xo/facet/FacetRegistry.hpp>
#include <xo/testutil/UtestRehearser.hpp>
#include <xo/arena/ArenaHashMapConfig.hpp>
#include <xo/ppsink/PpStyle.hpp>
#include <xo/ppsink/scope.hpp>
#include <xo/ppsink/scope_macros.hpp>
#include <catch2/catch.hpp>
#include <iostream>
#include <cstdint>
#include <cstdlib>
#include <cctype>
#include <string>
#include <type_traits>
#include <utility>
#include <vector>
namespace xo {
using xo::scm::TypeRef;
using xo::scm::AType;
using xo::scm::DVariable;
using xo::scm::DVarRef;
using xo::scm::DGlobalSymtab;
using xo::scm::DConstant;
using xo::scm::DIfElseExpr;
using xo::scm::DSequenceExpr;
using xo::scm::DDefineExpr;
using xo::scm::DApplyExpr;
using xo::scm::DLocalSymtab;
using xo::scm::DLambdaExpr;
using xo::scm::DTypename;
using xo::scm::DAtomicType;
using xo::scm::Metatype;
using xo::scm::AExpression;
using xo::scm::DFloat;
using xo::scm::DInteger;
using xo::scm::Binding;
using xo::scm::DUniqueString;
using xo::scm::StringTable;
using xo::mm::CollectorTypeRegistry;
using xo::mm::AAllocator;
using xo::mm::ACollector;
using xo::mm::DX1Collector;
using xo::mm::DArena;
using xo::mm::AGCObject;
using xo::mm::X1CollectorConfig;
using xo::mm::ArenaConfig;
using xo::print::APrintable;
using xo::facet::obj;
using xo::facet::with_facet;
using xo::map::ArenaHashMapConfig;
using xo::reflect::Reflect;
using xo::flatstring;
using xo::pp::PpConfig;
using xo::pp::toppstr;
using xo::pp::scope;
using xo::pp::xtag;
namespace ut {
/** DVariable's APrintable facet is registered by SetupExpression2; the
* TypeRef cases do not need this, but obj<APrintable,DVariable> does.
**/
static InitEvidence s_init = InitSubsys<S_expression2_tag>::require();
namespace {
/** render @p x through pretty(PpSink&) **/
template <typename T>
std::string
render_pretty(const T & x, std::uint32_t margin) {
return toppstr(PpConfig::scratch_plain(margin), x);
}
/** replace the digits of ":id <n>" with "N". See the identical
* helper in xo-procedure2/utest/printable_render.test.cpp: TypeId
* is a process-wide counter handed out in reflection order, so it
* moves when an unrelated test reflects a new type first.
*
* This scrubs the NESTED TypeDescr's :id. TypeRef's own :id is a
* type-variable NAME, is quoted, and stays pinned exactly.
**/
std::string scrub_type_id(std::string s) {
const std::string key = ":id ";
for (std::size_t i = s.find(key); i != std::string::npos; i = s.find(key, i+1)) {
std::size_t b = i + key.size();
std::size_t e = b;
while (e < s.size() && ::isdigit((unsigned char)s[e]))
++e;
if (e > b)
s.replace(b, e - b, "N");
}
return s;
}
/** index of the digits belonging to the ".tseq" at/after @p from,
* or npos. @p n_digit receives their count.
*
* NB the separator is whitespace, not necessarily a SPACE: at a
* narrow margin the value breaks onto its own line, so ".tseq" is
* followed by "\n" plus indent. Keying on ".tseq " silently
* scrubbed nothing in exactly those cases -- caught because the
* broken-layout expectations then failed.
**/
std::size_t find_tseq_digits(const std::string & s, std::size_t from,
std::size_t * n_digit) {
const std::string key = ".tseq";
for (std::size_t i = s.find(key, from); i != std::string::npos;
i = s.find(key, i+1))
{
std::size_t b = i + key.size();
while (b < s.size() && ::isspace((unsigned char)s[b]))
++b;
std::size_t e = b;
while (e < s.size() && ::isdigit((unsigned char)s[e]))
++e;
if (e > b) {
*n_digit = e - b;
return b;
}
}
return std::string::npos;
}
/** replace the digits in a quoted GENERATED type-variable name:
* :id "if:12" -> :id "if:N".
*
* A third counter, distinct from TypeDescr's :id (scrub_type_id)
* and from typeseq (scrub_tseq). DIfElseExpr::_make_empty() builds
* its TypeRef through TypeRef::generate_unique(), which draws on a
* process-wide counter -- so these move with how many TypeRefs the
* run happened to make before this one, including from unrelated
* tests. The PREFIX is kept: "if:" is a property of the printer
* under test, the number is not.
**/
std::string scrub_typevar(std::string s) {
const std::string key = ":id \"";
for (std::size_t i = s.find(key); i != std::string::npos; i = s.find(key, i+1)) {
std::size_t b = i + key.size();
/* skip the prefix, up to and including its ':' */
while (b < s.size() && s[b] != ':' && s[b] != '"')
++b;
if (b >= s.size() || s[b] != ':')
continue; /* no prefix separator -- not a generated name */
++b;
std::size_t e = b;
while (e < s.size() && ::isdigit((unsigned char)s[e]))
++e;
if (e > b)
s.replace(b, e - b, "N");
}
return s;
}
/** replace each ".tseq" value with "N".
*
* Same reasoning as scrub_type_id, different counter: a typeseq is
* handed out by xo::reflect::typeseq::id<T>() on first use, and in
* practice that is subsystem registration order
* (SetupObject2::register_facets etc). Stable today -- DInteger
* is 9 and DFloat 10 on every run -- but it is registration order,
* not a property of DConstant, so pinning the digits would make an
* unrelated object2 registration break this test.
*
* What the digits actually SAY is checked separately, in
* DConstant-tseq-fields below.
**/
std::string scrub_tseq(std::string s) {
std::size_t n_digit = 0;
for (std::size_t i = find_tseq_digits(s, 0, &n_digit);
i != std::string::npos;
i = find_tseq_digits(s, i+1, &n_digit))
{
s.replace(i, n_digit, "N");
}
return s;
}
/** the first ".tseq" value in @p s, as a number; -1 if there is
* none. Used only by DConstant-tseq-fields.
**/
long first_tseq(const std::string & s) {
std::size_t n_digit = 0;
std::size_t i = find_tseq_digits(s, 0, &n_digit);
if (i == std::string::npos)
return -1;
return ::strtol(s.c_str() + i, nullptr, 10);
}
/** the SECOND ".tseq" value in @p s; -1 if there is none **/
long second_tseq(const std::string & s) {
std::size_t n_digit = 0;
std::size_t i = find_tseq_digits(s, 0, &n_digit);
if (i == std::string::npos)
return -1;
std::size_t j = find_tseq_digits(s, i+1, &n_digit);
if (j == std::string::npos)
return -1;
return ::strtol(s.c_str() + j, nullptr, 10);
}
/** which of a TypeRef's two states to build.
*
* The type variable name is supplied rather than generated:
* TypeRef::generate_unique() draws from a process-wide counter, so
* a generated name would move whenever an unrelated test made a
* TypeRef first.
**/
enum class Kind {
/** id, no type description -- the normal pre-typecheck state **/
unresolved,
/** resolved via TypeRef::resolved(), so the id is EMPTY **/
resolved,
/** both: an id that later got resolved **/
both,
};
TypeRef make_typeref(Kind kind) {
switch (kind) {
case Kind::unresolved:
return TypeRef(TypeRef::type_var::from_chars("t:1"), obj<AType>());
case Kind::resolved:
return TypeRef::resolved(Reflect::require<double>());
case Kind::both:
{
TypeRef retval(TypeRef::type_var::from_chars("t:2"),
obj<AType>());
retval.resolve(Reflect::require<double>());
return retval;
}
}
return TypeRef();
}
/** MARGIN is the case variable, as elsewhere in phase C **/
struct Testcase_TypeRef {
Testcase_TypeRef(Kind kind,
std::uint32_t margin,
const char * expect_pretty)
: kind_{kind}, margin_{margin},
expect_pretty_{expect_pretty} {}
Kind kind_;
std::uint32_t margin_;
/** OBSERVED via pretty; outlives phase E **/
std::string expect_pretty_;
};
static std::vector<Testcase_TypeRef> s_typeref_v = {
/* An unresolved TypeRef prints ":td null". ppsink has no
* equivalent of legacy cond(): Prettifier<TypeDescr> renders
* NOTHING for a null descriptor (TypeDescr_pp.hpp, deliberately
* -- changing it is an output-visible change to xo-reflect), so
* TypeRef::pretty() branches and supplies the word itself.
* Identical to legacy, which is the point.
*/
Testcase_TypeRef(Kind::unresolved, 200,
"<TypeRef :id \"t:1\" :td null>"),
/* the struct breaks; fields still fit their own lines, and the
* struct-level indent agrees at 2.
*/
Testcase_TypeRef(Kind::unresolved, 20,
"<TypeRef\n"
" :id \"t:1\"\n"
" :td null>"),
/* TypeRef::resolved() leaves the type variable name empty, and
* an empty id renders as "" -- NOT as nothing. quot(), not
* unq(): legacy used xo::print::quot, which always quoted, and
* xo::pp::quot is its exact counterpart. unq() would have
* rendered t:1 bare above and dropped these quotes here.
*/
Testcase_TypeRef(Kind::resolved, 200,
"<TypeRef :id \"\" :td <TypeDescr :id N"
" :canonical_name double :complete true"
" :metatype atomic>>"),
/* resolved: :td's value fits on its own line. identical. */
Testcase_TypeRef(Kind::both, 80,
"<TypeRef\n"
" :id \"t:2\"\n"
" :td <TypeDescr :id N :canonical_name double"
" :complete true :metatype atomic>>"),
/* REVIEWED DIVERGENCE, both halves already settled by
* DPrimitive (xo-procedure2) and reappearing here because :td
* is again a TypeDescr:
*
* 1. the value of a broken field lands in column 4 under legacy
* (indent 2 + indent_width 2) and column 3 under ppsink
* (indent 2 + tag_value_offset 1).
* 2. legacy's :td stays on ONE line at any margin -- its legacy
* path is already a FlatSink and has no break points to
* offer -- while ppsink breaks the nested struct.
*/
Testcase_TypeRef(Kind::both, 40,
"<TypeRef\n"
" :id \"t:2\"\n"
" :td\n"
" <TypeDescr\n"
" :id N\n"
" :canonical_name double\n"
" :complete true\n"
" :metatype atomic>>"),
/* margin 20: legacy is UNCHANGED from margin 40, having nothing
* left to give. ppsink degrades one step further, folding the
* nested fields' values onto their own lines.
*/
Testcase_TypeRef(Kind::both, 20,
"<TypeRef\n"
" :id \"t:2\"\n"
" :td\n"
" <TypeDescr\n"
" :id N\n"
" :canonical_name\n"
" double\n"
" :complete true\n"
" :metatype\n"
" atomic>>"),
};
/** collector + string table for one DVariable case.
*
* DVariable, unlike TypeRef, is a GC-allocated D-type: it needs an
* allocator to exist at all, and its APrintable facet needs
* SetupExpression2's registrations (see s_init below). One
* collector per case, so a case cannot see another's arena.
**/
struct VarFixture {
explicit VarFixture(const std::string & name)
: gc_{X1CollectorConfig{
.name_ = "printable_render." + name,
.arena_config_ = ArenaConfig{
.size_ = 8192,
.store_header_flag_ = true},
.object_types_z_ = 16384,
.gc_trigger_v_{{4096, 4096}},
.debug_flag_ = false}},
table_{1024}
{
CollectorTypeRegistry::instance()
.install_types(with_facet<ACollector>::mkobj(&gc_));
}
obj<AAllocator> allocator() { return with_facet<AAllocator>::mkobj(&gc_); }
/** @p name nullptr means an ANONYMOUS variable -- name_ is a
* bare pointer with no non-null invariant, and legacy printed
* the empty string for it.
**/
DVariable * make_var(const char * name, Kind kind) {
const DUniqueString * sym = (name ? table_.intern(name) : nullptr);
return DVariable::make(this->allocator(), sym, make_typeref(kind));
}
/** @p path is the DEFINING variable's binding; DVarRef::make
* derives its own via Binding::relative(link, path).
*
* A sentinel (default-constructed) Binding is not reachable
* this way -- Binding::relative asserts on it (Binding.cpp) --
* so "{path}" is not among the cases below.
**/
DVarRef * make_varref(const char * name, Binding path, std::int32_t link) {
const DUniqueString * sym = (name ? table_.intern(name) : nullptr);
DVariable * var = DVariable::make(this->allocator(), sym,
make_typeref(Kind::resolved),
path);
return DVarRef::make(this->allocator(), var, link);
}
/** a DGlobalSymtab holding @p name_v as global variables.
*
* @p hint_capacity feeds ArenaHashMapConfig; the rendered
* :var_capacity / :type_capacity come from it (rounded up to a
* power of 2 by DArenaHashMap), so it is a case variable
* rather than a detail.
*
* The hash-map superstructure lives outside GC space, hence
* the separate aux arena -- that is DGlobalSymtab::make's own
* mm / aux_mm split, not a testing convenience.
**/
obj<AGCObject,DGlobalSymtab> make_symtab(std::size_t hint_capacity,
const std::vector<const char *> & name_v) {
auto cfg = ArenaHashMapConfig()
.with_name("printable_render.symtab")
.with_hint_max_capacity(hint_capacity);
auto symtab = DGlobalSymtab::make(this->allocator(),
this->aux_allocator(),
cfg, cfg);
for (const char * name : name_v) {
DVariable * var
= DVariable::make(this->allocator(),
table_.intern(name),
make_typeref(Kind::resolved));
symtab.data()->upsert_variable(this->allocator(), var);
}
return symtab;
}
obj<AAllocator> aux_allocator() { return with_facet<AAllocator>::mkobj(&aux_); }
/** a DConstant boxing @p x. Integer and float take different
* paths through DConstant::_lookup_td, and render different
* D-type typeseqs, so both are cases.
**/
/** a DSequenceExpr holding @p n DConstant elements.
*
* n == 0 is a case in its own right: an empty DArray is what
* the sequence starts as, and how the nested sequence printer
* renders nothing is worth pinning.
**/
DSequenceExpr * make_sequence(int n) {
DSequenceExpr * retval = DSequenceExpr::_make_empty(this->allocator());
for (int i = 1; i <= n; ++i) {
retval->push_back(this->allocator(),
obj<AExpression>(DConstant::make(this->allocator(),
DInteger::box<AGCObject>(this->allocator(), i))));
}
return retval;
}
/** a DIfElseExpr with each branch present or absent.
*
* Children are DConstants -- converted printers, so nothing
* here can pin "STUB:" text that would move later.
**/
DIfElseExpr * make_ifelse(bool with_test, bool with_true, bool with_false) {
DIfElseExpr * retval = DIfElseExpr::_make_empty(this->allocator());
if (with_test)
retval->assign_test(obj<AExpression>(DConstant::make(this->allocator(),
DInteger::box<AGCObject>(this->allocator(), 1))));
if (with_true)
retval->assign_when_true(obj<AExpression>(DConstant::make(this->allocator(),
DInteger::box<AGCObject>(this->allocator(), 2))));
if (with_false)
retval->assign_when_false(obj<AExpression>(DConstant::make(this->allocator(),
DInteger::box<AGCObject>(this->allocator(), 3))));
return retval;
}
/** a DDefineExpr for @p name, with an initializer iff @p with_rhs.
*
* Two independent branches meet here: the OPTIONAL :rhs field,
* and the nested DVariable's own branch on a null name (@p name
* nullptr). Both are converted printers, so nothing here pins
* "STUB:" text that would move later.
*
* make_empty() rather than make(): make() would need an
* obj<AExpression> up front, and the parser's own path is to
* build the skeleton and fill it in.
**/
DDefineExpr * make_define(const char * name, bool with_rhs) {
DDefineExpr * retval = DDefineExpr::make_empty(this->allocator());
if (name)
retval->assign_lhs_name(table_.intern(name));
if (with_rhs)
retval->assign_rhs(obj<AExpression>(DConstant::make(this->allocator(),
DInteger::box<AGCObject>(this->allocator(), 7))));
return retval;
}
/** a DApplyExpr calling variable "f" with @p n_arg constants.
*
* scaffold() + assign_arg() rather than make2(), so the arity
* is a case variable -- the whole point of this printer, whose
* field count is a runtime value.
*
* Every argument IS assigned: the printer reaches its children
* through FacetRegistry::variant<APrintable> (not try_variant),
* so an unassigned slot would be an empty obj<> handed to a
* facet lookup that does not tolerate one. A DApplyExpr with
* holes is a parser-intermediate state; whether it should be
* printable is a separate question from this conversion.
*
* fn is a DVariable and the args are DConstants -- both
* converted, so nothing here pins "STUB:" text.
**/
DApplyExpr * make_applyexpr(int n_arg) {
/* Kind::unresolved: its TypeRef renders `:id "t:1" :td null`.
* Kind::resolved would drag in xo-reflect's TypeDescr
* printer -- `<TypeDescr :id 8 :canonical_name double ...>`,
* long, and carrying a process-wide counter -- for no gain
* here. This printer's subject is field ARITY.
*/
DVariable * fn_var = this->make_var("f", Kind::unresolved);
DApplyExpr * retval
= DApplyExpr::scaffold(this->allocator(),
make_typeref(Kind::resolved),
with_facet<AExpression>::mkobj(fn_var),
n_arg);
for (int i = 0; i < n_arg; ++i) {
retval->assign_arg(i,
obj<AExpression>(DConstant::make(this->allocator(),
DInteger::box<AGCObject>(this->allocator(),
10 + i))));
}
return retval;
}
/** a DLocalSymtab holding @p n_var variables and @p n_type
* type definitions.
*
* Two independent dynamic-arity loops, which is what makes
* this printer different from DApplyExpr's one. Capacity is
* exactly the requested count: append_var/append_type assert
* and no-op past capacity, so an over-tight array would
* silently produce fewer fields than the case names.
**/
DLocalSymtab * make_localsymtab(int n_var, int n_type) {
DLocalSymtab * retval
= DLocalSymtab::_make_empty(this->allocator(),
nullptr /*parent*/,
n_var, n_type);
for (int i = 0; i < n_var; ++i) {
std::string name = "v" + std::to_string(1 + i);
retval->append_var(this->allocator(),
table_.intern(name.c_str()),
make_typeref(Kind::unresolved));
}
for (int i = 0; i < n_type; ++i) {
std::string name = "t" + std::to_string(1 + i);
retval->append_type(this->allocator(),
table_.intern(name.c_str()),
DAtomicType::make(this->allocator(),
Metatype::t_i64()));
}
return retval;
}
/** a DLambdaExpr named @p name, over a symtab of @p n_var
* arguments, with a body iff @p with_body.
*
* Legacy's branch is ALL-OR-NOTHING: `name_ && body` gates the
* whole struct, so @p name nullptr and @p with_body false are
* each a case that renders a bare <LambdaExpr> -- not a struct
* with fields dropped. Both are exercised.
*
* The symtab is always real, including for the bare cases: a
* null one would be a different defect from the one under
* test, and visit_gco_children dereferences it.
**/
DLambdaExpr * make_lambda(const char * name, bool with_body, int n_var) {
DLocalSymtab * symtab = this->make_localsymtab(n_var, 0 /*n_type*/);
obj<AExpression> body;
if (with_body) {
body = obj<AExpression>(DConstant::make(this->allocator(),
DInteger::box<AGCObject>(this->allocator(), 5)));
}
return DLambdaExpr::_make(this->allocator(),
make_typeref(Kind::unresolved),
(name ? table_.intern(name) : nullptr),
symtab,
body);
}
/** a DTypename binding @p name to a type, or to NOTHING when
* @p with_type is false.
*
* The null-type case is the only one that can render: a
* populated DTypename throws, deliberately. See
* DTypename-render below.
**/
obj<AGCObject,DTypename> make_typename(const char * name, bool with_type) {
obj<AType> type;
if (with_type)
type = DAtomicType::make(this->allocator(), Metatype::t_i64());
return DTypename::make(this->allocator(),
(name ? table_.intern(name) : nullptr),
type);
}
template <typename T>
DConstant * make_constant(T x) {
/* if constexpr, not a ternary: the two box<> calls return
* different obj<> specializations with no common type.
*/
if constexpr (std::is_integral_v<T>) {
obj<AGCObject> value
= DInteger::box<AGCObject>(this->allocator(),
static_cast<long>(x));
return DConstant::_make(this->allocator(), value);
} else {
obj<AGCObject> value
= DFloat::box<AGCObject>(this->allocator(),
static_cast<double>(x));
return DConstant::_make(this->allocator(), value);
}
}
DX1Collector gc_;
/** non-GC memory for the symbol table's hash maps **/
DArena aux_ = DArena::map(ArenaConfig{ .name_ = flatstring("printable_render.aux"),
.size_ = 256*1024 });
StringTable table_;
};
/** MARGIN is the case variable, as for TypeRef above; NAME and the
* nested TypeRef's state vary too, since DVariable's printer has a
* branch on name_ and inherits TypeRef's on td_.
**/
struct Testcase_DVariable {
Testcase_DVariable(const char * name,
Kind kind,
std::uint32_t margin,
const char * label,
const char * expect_pretty)
: name_{name}, kind_{kind}, margin_{margin}, label_{label},
expect_pretty_{expect_pretty} {}
/** nullptr -> anonymous variable **/
const char * name_;
Kind kind_;
std::uint32_t margin_;
/** distinguishes this case's arena **/
const char * label_;
/** OBSERVED via pretty; outlives phase E **/
std::string expect_pretty_;
};
static std::vector<Testcase_DVariable> s_dvariable_v = {
/* flat: identical, and the nested TypeRef arrives through
* Prettifier<TypeRef> rather than through a facet -- DVariable
* holds a TypeRef by value, not an obj<APrintable>.
*/
Testcase_DVariable("myvar", Kind::resolved, 200, "res200",
"<DVariable :name \"myvar\" :typeref"
" <TypeRef :id \"\" :td <TypeDescr :id N"
" :canonical_name double :complete true"
" :metatype atomic>>>"),
/* REVIEWED DIVERGENCE, the same one TypeRef pinned, now visible
* at TWO levels: the value of a broken field lands at
* indent+indent_width (legacy, 2) vs indent+tag_value_offset
* (ppsink, 1). So the nested <TypeRef begins at column 4 vs 3,
* and its own fields at 6 vs 4. Layout only; same tokens.
*/
/* NB :td breaks here, where it used to fit. Spelling bools
* out (2026-09-08) added 3 columns to ":complete true", which
* pushed this line to 82 against a margin of 80. Layout only.
*/
Testcase_DVariable("myvar", Kind::resolved, 80, "res80",
"<DVariable\n"
" :name \"myvar\"\n"
" :typeref\n"
" <TypeRef\n"
" :id \"\"\n"
" :td\n"
" <TypeDescr :id N :canonical_name"
" double :complete true :metatype atomic>>>"),
/* the second half of that divergence: legacy's TypeDescr is a
* FlatSink render with no break points to offer, so it stays on
* one line however narrow the margin; ppsink breaks it.
*/
Testcase_DVariable("myvar", Kind::resolved, 40, "res40",
"<DVariable\n"
" :name \"myvar\"\n"
" :typeref\n"
" <TypeRef\n"
" :id \"\"\n"
" :td\n"
" <TypeDescr\n"
" :id N\n"
" :canonical_name double\n"
" :complete true\n"
" :metatype atomic>>>"),
/* unresolved typeref: short enough to stay flat at 200 ... */
Testcase_DVariable("myvar", Kind::unresolved, 200, "unres200",
"<DVariable :name \"myvar\" :typeref"
" <TypeRef :id \"t:1\" :td null>>"),
/* ... at 40 only the OUTER struct breaks, and the nested
* TypeRef still fits its line. Identical, which is the useful
* part: the two stacks agree wherever nothing is forced.
*/
Testcase_DVariable("myvar", Kind::unresolved, 40, "unres40",
"<DVariable\n"
" :name \"myvar\"\n"
" :typeref <TypeRef :id \"t:1\" :td null>>"),
/* at 20 the nested TypeRef breaks too -- the indent divergence
* again, with no TypeDescr involved.
*/
Testcase_DVariable("myvar", Kind::unresolved, 20, "unres20",
"<DVariable\n"
" :name \"myvar\"\n"
" :typeref\n"
" <TypeRef\n"
" :id \"t:1\"\n"
" :td null>>"),
/* a null name_ renders as "" -- NOT as nothing, and NOT as
* "null". Both printers reach it through the same
* (name_ ? string_view(*name_) : string_view("")) branch, so
* this pins the branch rather than a formatting rule.
*/
Testcase_DVariable(nullptr, Kind::unresolved, 200, "anon200",
"<DVariable :name \"\" :typeref"
" <TypeRef :id \"t:1\" :td null>>"),
};
/** DVarRef's own case variables: the DEFINING variable's binding,
* the link count, and the margin. Its typeref is not printed, so
* it is held at Kind::resolved throughout and is not a variable.
**/
struct Testcase_DVarRef {
Testcase_DVarRef(const char * name,
Binding path,
std::int32_t link,
std::uint32_t margin,
const char * label,
const char * expect_pretty)
: name_{name}, path_{path}, link_{link}, margin_{margin},
label_{label},
expect_pretty_{expect_pretty} {}
const char * name_;
Binding path_;
std::int32_t link_;
std::uint32_t margin_;
const char * label_;
/** OBSERVED via pretty; outlives phase E **/
std::string expect_pretty_;
};
static std::vector<Testcase_DVarRef> s_dvarref_v = {
/* flat, identical. Note :name is NOT quoted -- legacy did not
* quote it here, though DVariable quotes its own :name. That
* inconsistency is legacy's and is preserved deliberately;
* unifying the two would be an output-visible change wanting
* its own commit.
*
* :path is the point of this printer. Binding has no
* Prettifier<> and no ppdetail<>, only an operator<<
* (Binding.hpp:58), so it takes ppsink's leaf FALLBACK -- empty
* primary template -> not string-like -> operator<<. That the
* two renderings agree is the evidence the fallback fires, and
* it is worth pinning because a MISSING Prettifier<> is silent
* for any type that has an operator<< (TypeRef, having none,
* failed loudly instead).
*/
Testcase_DVarRef("myvar", Binding::local(3), 0, 200, "local0.200",
"<DVarRef :name myvar :path {path:0:3}>"),
/* outer struct breaks; both values still fit their lines */
Testcase_DVarRef("myvar", Binding::local(3), 0, 30, "local0.30",
"<DVarRef\n"
" :name myvar\n"
" :path {path:0:3}>"),
/* margin 12: BOTH values break, so the known field-value column
* divergence (legacy 4, ppsink 3) shows up twice in one render.
*/
Testcase_DVarRef("myvar", Binding::local(3), 0, 12, "local0.12",
"<DVarRef\n"
" :name\n"
" myvar\n"
" :path\n"
" {path:0:3}>"),
/* link 2: DVarRef::make composes its binding via
* Binding::relative(link, vardef->path()), so the rendered
* i_link is 2 rather than the defining variable's 0. Pins the
* composition, not just the formatting.
*/
Testcase_DVarRef("myvar", Binding::local(3), 2, 200, "local2.200",
"<DVarRef :name myvar :path {path:2:3}>"),
/* a global binding prints its own way ("{path:global:7}",
* Binding::print) and ignores the link -- Binding::relative
* returns the definition unchanged for globals.
*/
Testcase_DVarRef("g", Binding::global(7), 0, 200, "global.200",
"<DVarRef :name g :path {path:global:7}>"),
/* margin 20: :name fits, :path does not -- the divergence on
* one field with the other left alone.
*/
Testcase_DVarRef("g", Binding::global(7), 0, 20, "global.20",
"<DVarRef\n"
" :name g\n"
" :path\n"
" {path:global:7}>"),
};
/** DGlobalSymtab's four fields are all std::uint32_t
* (DGlobalSymtab.hpp), so this is the first printer whose every
* value goes through the WIDENED integer Prettifier rather than
* the operator<< fallback -- see
* .xo-backlog/xo-ppsink/issues/09-scalar-prettifiers.md, which was
* done first precisely so these expectations pin a Prettifier.
**/
struct Testcase_DGlobalSymtab {
Testcase_DGlobalSymtab(std::size_t hint_capacity,
std::vector<const char *> name_v,
std::uint32_t margin,
const char * label,
const char * expect_pretty)
: hint_capacity_{hint_capacity}, name_v_{std::move(name_v)},
margin_{margin}, label_{label},
expect_pretty_{expect_pretty} {}
std::size_t hint_capacity_;
std::vector<const char *> name_v_;
std::uint32_t margin_;
const char * label_;
/** OBSERVED via pretty; outlives phase E **/
std::string expect_pretty_;
};
static std::vector<Testcase_DGlobalSymtab> s_symtab_v = {
/* an empty symtab still reports a capacity: the hash map is
* sized at construction. 16, not the hint of 8 --
* DArenaHashMap rounds up.
*/
Testcase_DGlobalSymtab(8, {}, 200, "empty.200",
"<DGlobalSymtab :nvar 0 :var_capacity 16"
" :ntype 0 :type_capacity 16>"),
/* struct breaks, every field fits its own line. Identical --
* four scalar fields have no nested structure to disagree over,
* which is what makes this printer the safe one to do first.
*/
Testcase_DGlobalSymtab(8, {}, 40, "empty.40",
"<DGlobalSymtab\n"
" :nvar 0\n"
" :var_capacity 16\n"
" :ntype 0\n"
" :type_capacity 16>"),
/* margin 14: only the two LONG field names force their values
* onto the next line, so the known column divergence (legacy 4,
* ppsink 3) appears while :nvar and :ntype stay put.
*/
Testcase_DGlobalSymtab(8, {}, 14, "empty.14",
"<DGlobalSymtab\n"
" :nvar 0\n"
" :var_capacity\n"
" 16\n"
" :ntype 0\n"
" :type_capacity\n"
" 16>"),
/* three upserted variables: :nvar tracks them, :ntype does not.
* Pins that the printer reads the two arrays separately rather
* than one count twice.
*/
Testcase_DGlobalSymtab(8, {"a", "b", "c"}, 200, "three.200",
"<DGlobalSymtab :nvar 3 :var_capacity 16"
" :ntype 0 :type_capacity 16>"),
Testcase_DGlobalSymtab(8, {"a", "b", "c"}, 14, "three.14",
"<DGlobalSymtab\n"
" :nvar 3\n"
" :var_capacity\n"
" 16\n"
" :ntype 0\n"
" :type_capacity\n"
" 16>"),
/* a wider hint moves BOTH capacities, so they are read from the
* maps rather than being a constant that happened to match.
*/
Testcase_DGlobalSymtab(64, {"a"}, 200, "wide.200",
"<DGlobalSymtab :nvar 1 :var_capacity 64"
" :ntype 0 :type_capacity 64>"),
};
/** DSequenceExpr is one field wrapping a DArray -- so it is the
* first expression2 printer nesting a SEQUENCE rather than a
* struct, and the DArray framing divergence settled in
* xo-object2's phase C reappears here one level down.
**/
struct Testcase_DSequenceExpr {
Testcase_DSequenceExpr(int n_elt,
std::uint32_t margin,
const char * label,
const char * expect_pretty)
: n_elt_{n_elt}, margin_{margin}, label_{label},
expect_pretty_{expect_pretty} {}
int n_elt_;
std::uint32_t margin_;
const char * label_;
/** OBSERVED via pretty; outlives phase E **/
std::string expect_pretty_;
};
static std::vector<Testcase_DSequenceExpr> s_sequence_v = {
/* empty sequence: "[]", and identical at EVERY margin -- an
* empty DArray has no break points to offer, so this case never
* diverges however narrow.
*/
Testcase_DSequenceExpr(0, 200, "seq0.200",
"<DSequenceExpr :expr_v []>"),
Testcase_DSequenceExpr(0, 30, "seq0.30",
"<DSequenceExpr :expr_v []>"),
/* one element, flat: identical */
Testcase_DSequenceExpr(1, 200, "seq1.200",
"<DSequenceExpr :expr_v [<DConstant :value_.tseq N :value.tseq N :value 1>]>"),
/* REVIEWED DIVERGENCE, and it is DArray's, not this printer's
* -- already settled in xo-object2's phase C and reappearing
* because :expr_v nests a DArray:
*
* legacy "[ <DConstant" -- a space after '[', elements
* aligned at column 6
* ppsink "[<DConstant" -- no space, elements at column 3
*
* Stacked on top of the field-value column divergence, so this
* case shows both at once.
*/
Testcase_DSequenceExpr(1, 30, "seq1.30",
"<DSequenceExpr\n"
" :expr_v\n"
" [<DConstant\n"
" :value_.tseq N\n"
" :value.tseq N\n"
" :value 1>]>"),
/* three elements at margin 60: the elements each fit a line, so
* this isolates the ELEMENT ALIGNMENT half of the divergence --
* legacy continues at column 6, ppsink at column 3 -- without
* the elements themselves breaking.
*/
Testcase_DSequenceExpr(3, 60, "seq3.60",
"<DSequenceExpr\n"
" :expr_v\n"
" [<DConstant :value_.tseq N :value.tseq N :value 1>\n"
" <DConstant :value_.tseq N :value.tseq N :value 2>\n"
" <DConstant :value_.tseq N :value.tseq N :value 3>]>"),
/* three elements flat: identical, so the divergence is purely
* about where breaks land, not about which tokens are emitted.
*/
Testcase_DSequenceExpr(3, 200, "seq3.200",
"<DSequenceExpr :expr_v [<DConstant :value_.tseq N :value.tseq N :value 1> <DConstant :value_.tseq N :value.tseq N :value 2> <DConstant :value_.tseq N :value.tseq N :value 3>]>"),
};
/** DIfElseExpr is the first printer with OPTIONAL fields: legacy
* uses refrtag's three-argument form and ppsink field()'s third
* argument, and an absent branch drops the field AND its separator
* rather than rendering an empty value.
*
* Children are DConstants (converted), so nothing here pins
* "STUB:" text that would move when a sibling printer lands.
**/
struct Testcase_DIfElseExpr {
Testcase_DIfElseExpr(bool with_test, bool with_true, bool with_false,
std::uint32_t margin,
const char * label,
const char * expect_pretty)
: with_test_{with_test}, with_true_{with_true},
with_false_{with_false}, margin_{margin}, label_{label},
expect_pretty_{expect_pretty} {}
bool with_test_;
bool with_true_;
bool with_false_;
std::uint32_t margin_;
const char * label_;
/** OBSERVED via pretty; outlives phase E **/
std::string expect_pretty_;
};
static std::vector<Testcase_DIfElseExpr> s_ifelse_v = {
/* THE case this printer was worth doing for: all three branches
* absent, so three of four fields vanish and only :typeref is
* rendered. Both stacks agree -- no ":test" with an empty
* value, no stray separator.
*/
Testcase_DIfElseExpr(false, false, false, 200, "none.200",
"<DIfElseExpr :typeref <TypeRef :id \"if:N\" :td null>>"),
/* the same, broken: the sole field still breaks normally, and
* the nested TypeRef shows the usual column divergence.
*/
Testcase_DIfElseExpr(false, false, false, 30, "none.30",
"<DIfElseExpr\n"
" :typeref\n"
" <TypeRef\n"
" :id \"if:N\"\n"
" :td null>>"),
/* one branch present: :test appears, :when_true / :when_false
* stay absent. Pins that presence is PER FIELD, not all-or-
* nothing.
*/
Testcase_DIfElseExpr(true, false, false, 200, "test-only.200",
"<DIfElseExpr :typeref <TypeRef :id \"if:N\" :td null> :test <DConstant :value_.tseq N :value.tseq N :value 1>>"),
/* two present, and at margin 60 the second child's value is
* pushed to its own line -- the column divergence again, with
* an absent field still cleanly missing.
*/
Testcase_DIfElseExpr(true, true, false, 60, "no-else.60",
"<DIfElseExpr\n"
" :typeref <TypeRef :id \"if:N\" :td null>\n"
" :test <DConstant :value_.tseq N :value.tseq N :value 1>\n"
" :when_true\n"
" <DConstant :value_.tseq N :value.tseq N :value 2>>"),
/* all four fields. NB this breaks even at margin 200 -- the
* flat form is 190+ characters -- so there is no all-on-one-line
* case for this printer, and the two stacks agree exactly here.
*/
Testcase_DIfElseExpr(true, true, true, 200, "all.200",
"<DIfElseExpr\n"
" :typeref <TypeRef :id \"if:N\" :td null>\n"
" :test <DConstant :value_.tseq N :value.tseq N :value 1>\n"
" :when_true <DConstant :value_.tseq N :value.tseq N :value 2>\n"
" :when_false <DConstant :value_.tseq N :value.tseq N :value 3>>"),
/* margin 30: every child breaks, so the field-value column
* divergence (legacy 4, ppsink 3) appears at both levels for
* all four fields at once -- the widest instance so far.
*/
Testcase_DIfElseExpr(true, true, true, 30, "all.30",
"<DIfElseExpr\n"
" :typeref\n"
" <TypeRef\n"
" :id \"if:N\"\n"
" :td null>\n"
" :test\n"
" <DConstant\n"
" :value_.tseq N\n"
" :value.tseq N\n"
" :value 1>\n"
" :when_true\n"
" <DConstant\n"
" :value_.tseq N\n"
" :value.tseq N\n"
" :value 2>\n"
" :when_false\n"
" <DConstant\n"
" :value_.tseq N\n"
" :value.tseq N\n"
" :value 3>>"),
};
/** DConstant's :value nests an object2 leaf (DInteger / DFloat),
* both already converted -- so this is the first expression2
* printer nesting a printer from ANOTHER subsystem.
*
* is_int_ picks the boxed type; the two .tseq fields are scrubbed
* (see scrub_tseq), so what varies visibly is the :value leaf and
* the layout.
**/
struct Testcase_DConstant {
Testcase_DConstant(bool is_int,
std::uint32_t margin,
const char * label,
const char * expect_pretty)
: is_int_{is_int}, margin_{margin}, label_{label},
expect_pretty_{expect_pretty} {}
bool is_int_;
std::uint32_t margin_;
const char * label_;
/** OBSERVED via pretty; outlives phase E **/
std::string expect_pretty_;
};
static std::vector<Testcase_DConstant> s_constant_v = {
/* flat. :value 42 comes from DInteger's own converted printer,
* reached through the APrintable facet variant -- so this pins
* cross-subsystem nesting, not just DConstant's frame.
*/
Testcase_DConstant(true, 200, "int.200",
"<DConstant :value_.tseq N :value.tseq N :value 42>"),
/* struct breaks, all three values still fit their lines */
Testcase_DConstant(true, 44, "int.44",
"<DConstant\n"
" :value_.tseq N\n"
" :value.tseq N\n"
" :value 42>"),
/* margin 14: the two long field names push their values down --
* the known column divergence (legacy 4, ppsink 3) -- while
* :value 42 stays put. Same shape DGlobalSymtab showed.
*/
Testcase_DConstant(true, 14, "int.14",
"<DConstant\n"
" :value_.tseq\n"
" N\n"
" :value.tseq\n"
" N\n"
" :value 42>"),
/* a float constant: DFloat's Prettifier renders 2.5, and the
* boxed type is different (which scrub_tseq hides here and
* DConstant-tseq-fields checks instead).
*/
Testcase_DConstant(false, 200, "flt.200",
"<DConstant :value_.tseq N :value.tseq N :value 2.5>"),
Testcase_DConstant(false, 14, "flt.14",
"<DConstant\n"
" :value_.tseq\n"
" N\n"
" :value.tseq\n"
" N\n"
" :value 2.5>"),
};
/** DDefineExpr has one optional field (:rhs) and one that is always
* present (:lhs). Where DIfElseExpr's legacy body used refrtag's
* three-argument form, this one DUPLICATES the whole
* pretty_struct() call in an if/else -- see the "cond() doesn't
* resolve the way we want here" comment in DDefineExpr.cpp. The
* ppsink body is a single call with field()'s third argument, so
* these cases are the evidence that the two spellings agree.
*
* @p name nullptr exercises the nested DVariable's own null-name
* branch at the same time.
**/
struct Testcase_DDefineExpr {
Testcase_DDefineExpr(const char * name, bool with_rhs,
std::uint32_t margin,
const char * label,
const char * expect_pretty)
: name_{name}, with_rhs_{with_rhs}, margin_{margin},
label_{label},
expect_pretty_{expect_pretty} {}
/** nullptr -> lhs variable is anonymous **/
const char * name_;
bool with_rhs_;
std::uint32_t margin_;
const char * label_;
/** OBSERVED via pretty; outlives phase E **/
std::string expect_pretty_;
};
static std::vector<Testcase_DDefineExpr> s_define_v = {
/* no initializer: :rhs and its separator are gone entirely.
* Both stacks agree -- no ":rhs" with an empty value.
*/
Testcase_DDefineExpr("x", false, 200, "noinit.200",
"<DDefineExpr :lhs <DVariable :name \"x\" :typeref <TypeRef :id \"\" :td null>>>"),
/* the same, fully broken. Three levels of nesting, so the
* field-value column divergence (legacy indent+2, ppsink
* indent+1) compounds: 4 vs 3 at :lhs, 8 vs 5 at :typeref.
*/
Testcase_DDefineExpr("x", false, 30, "noinit.30",
"<DDefineExpr\n"
" :lhs\n"
" <DVariable\n"
" :name \"x\"\n"
" :typeref\n"
" <TypeRef\n"
" :id \"\"\n"
" :td null>>>"),
/* with an initializer: :rhs appears, flat, identical */
Testcase_DDefineExpr("x", true, 200, "init.200",
"<DDefineExpr :lhs <DVariable :name \"x\" :typeref <TypeRef :id \"\" :td null>> :rhs <DConstant :value_.tseq N :value.tseq N :value 7>>"),
/* margin 60 -- THE case worth having. Here the column
* divergence changes WHAT IS EMITTED, not just where: ppsink's
* :lhs value starts one column earlier, which leaves the nested
* DVariable enough room to stay on one line, where legacy has
* to break it into four. Elsewhere the two stacks differ only
* in leading whitespace; this pins that the difference can
* cascade into a different line structure.
*/
Testcase_DDefineExpr("x", true, 60, "init.60",
"<DDefineExpr\n"
" :lhs\n"
" <DVariable :name \"x\" :typeref <TypeRef :id \"\" :td null>>\n"
" :rhs <DConstant :value_.tseq N :value.tseq N :value 7>>"),
/* margin 30, both fields present: every level breaks */
Testcase_DDefineExpr("x", true, 30, "init.30",
"<DDefineExpr\n"
" :lhs\n"
" <DVariable\n"
" :name \"x\"\n"
" :typeref\n"
" <TypeRef\n"
" :id \"\"\n"
" :td null>>\n"
" :rhs\n"
" <DConstant\n"
" :value_.tseq N\n"
" :value.tseq N\n"
" :value 7>>"),
/* anonymous lhs: DVariable renders :name "" rather than
* dropping the field -- an empty VALUE, not an absent field.
* The contrast with :rhs above is the point.
*/
Testcase_DDefineExpr(nullptr, true, 200, "anon.200",
"<DDefineExpr :lhs <DVariable :name \"\" :typeref <TypeRef :id \"\" :td null>> :rhs <DConstant :value_.tseq N :value.tseq N :value 7>>"),
};
/** DApplyExpr's field count is n_args_+1, a RUNTIME value, so this
* is the first printer in the cluster built with
* PpSink::struct_open() rather than pretty_struct(). Field names
* after :fn are generated -- concat("arg", 1+i), 1-based.
*
* fn is a DVariable and the args are DConstants, both converted.
**/
struct Testcase_DApplyExpr {
Testcase_DApplyExpr(int n_arg, std::uint32_t margin,
const char * label,
const char * expect_pretty)
: n_arg_{n_arg}, margin_{margin}, label_{label},
expect_pretty_{expect_pretty} {}
int n_arg_;
std::uint32_t margin_;
const char * label_;
/** OBSERVED via pretty; outlives phase E **/
std::string expect_pretty_;
};
static std::vector<Testcase_DApplyExpr> s_apply_v = {
/* zero args -- only :fn. THE <DVariable :name \"f\" :typeref <TypeRef :id \"t:N\" :td null>>-FORM DIVERGENCE: legacy
* renders "<ApplyExpr:fn ..." with no space, because its
* hand-rolled print_upto() path writes the struct name and then
* each refrtag with no separator between them. ppsink emits
* one, as every pretty_struct-based printer already did.
* Legacy is wrong here and this is not a regression; see the
* ticket.
*/
Testcase_DApplyExpr(0, 200, "a0.200",
"<ApplyExpr :fn <DVariable :name \"f\" :typeref <TypeRef :id \"t:N\" :td null>>>"),
/* broken: the separator question disappears -- a field on its
* own line needs no leading space -- so the two stacks differ
* only by the usual field-value column (legacy +2, ppsink +1),
* compounding over three levels.
*/
Testcase_DApplyExpr(0, 30, "a0.30",
"<ApplyExpr\n"
" :fn\n"
" <DVariable\n"
" :name \"f\"\n"
" :typeref\n"
" <TypeRef\n"
" :id \"t:N\"\n"
" :td null>>>"),
/* one arg, flat: TWO missing separators in legacy, before :fn
* and before :arg1. Pins that the defect is per field.
*/
Testcase_DApplyExpr(1, 200, "a1.200",
"<ApplyExpr :fn <DVariable :name \"f\" :typeref <TypeRef :id \"t:N\" :td null>> :arg1 <DConstant :value_.tseq N :value.tseq N :value 10>>"),
Testcase_DApplyExpr(1, 40, "a1.40",
"<ApplyExpr\n"
" :fn\n"
" <DVariable\n"
" :name \"f\"\n"
" :typeref\n"
" <TypeRef :id \"t:N\" :td null>>\n"
" :arg1\n"
" <DConstant\n"
" :value_.tseq N\n"
" :value.tseq N\n"
" :value 10>>"),
/* three args at margin 200: too wide to fit, so it breaks and
* every field fits its own line -- the two stacks agree
* EXACTLY. The generated names arg1/arg2/arg3 are the
* assertion that matters here.
*/
Testcase_DApplyExpr(3, 200, "a3.200",
"<ApplyExpr\n"
" :fn <DVariable :name \"f\" :typeref <TypeRef :id \"t:N\" :td null>>\n"
" :arg1 <DConstant :value_.tseq N :value.tseq N :value 10>\n"
" :arg2 <DConstant :value_.tseq N :value.tseq N :value 11>\n"
" :arg3 <DConstant :value_.tseq N :value.tseq N :value 12>>"),
/* margin 60: :fn breaks, the args still fit -- so one field
* diverges and three do not, in one rendering.
*/
Testcase_DApplyExpr(3, 60, "a3.60",
"<ApplyExpr\n"
" :fn\n"
" <DVariable\n"
" :name \"f\"\n"
" :typeref <TypeRef :id \"t:N\" :td null>>\n"
" :arg1 <DConstant :value_.tseq N :value.tseq N :value 10>\n"
" :arg2 <DConstant :value_.tseq N :value.tseq N :value 11>\n"
" :arg3 <DConstant :value_.tseq N :value.tseq N :value 12>>"),
/* margin 30: everything breaks, at every level */
Testcase_DApplyExpr(3, 30, "a3.30",
"<ApplyExpr\n"
" :fn\n"
" <DVariable\n"
" :name \"f\"\n"
" :typeref\n"
" <TypeRef\n"
" :id \"t:N\"\n"
" :td null>>\n"
" :arg1\n"
" <DConstant\n"
" :value_.tseq N\n"
" :value.tseq N\n"
" :value 10>\n"
" :arg2\n"
" <DConstant\n"
" :value_.tseq N\n"
" :value.tseq N\n"
" :value 11>\n"
" :arg3\n"
" <DConstant\n"
" :value_.tseq N\n"
" :value.tseq N\n"
" :value 12>>"),
};
/** DLocalSymtab has TWO dynamic-arity loops -- vars_ then types_ --
* bracketing two scalar fields, :nvars and :ntypes. Index names
* are generated as "[i]" and COLLIDE between the loops: a symtab
* with both renders :[0] twice. Legacy did that too (snprintf
* "[%u]" in each loop) and the conversion reproduces it.
*
* Only VAR-ONLY cases are here. A non-empty types_ cannot be
* pinned on either side: legacy THROWS (see
* DLocalSymtab-types-throws below), and ppsink renders
* "STUB:DTypename", which moves when DTypename converts.
*
* Unlike DApplyExpr, legacy loses no separator here -- it builds
* fields with xrefrtag/newline_pretty_tag rather than writing the
* struct name and refrtags back to back. So the ONLY divergence
* is the usual field-value column: legacy indent+2, ppsink
* indent+1, compounding per level.
**/
struct Testcase_DLocalSymtab {
Testcase_DLocalSymtab(int n_var, std::uint32_t margin,
const char * label,
const char * expect_pretty)
: n_var_{n_var}, margin_{margin}, label_{label},
expect_pretty_{expect_pretty} {}
int n_var_;
std::uint32_t margin_;
const char * label_;
/** OBSERVED via pretty; outlives phase E **/
std::string expect_pretty_;
};
static std::vector<Testcase_DLocalSymtab> s_localsymtab_v = {
/* empty: both scalar fields and neither loop. The two stacks
* agree exactly -- nothing nests, so no field-value column
* question arises.
*/
Testcase_DLocalSymtab(0, 200, "s0.200",
"<LocalSymtab :nvars 0 :ntypes 0>"),
Testcase_DLocalSymtab(0, 30, "s0.30",
"<LocalSymtab\n"
" :nvars 0\n"
" :ntypes 0>"),
/* one var, flat. :ntypes 0 still appears AFTER the loop --
* an empty loop drops its fields but not the count.
*/
Testcase_DLocalSymtab(1, 200, "s1.200",
"<LocalSymtab :nvars 1 :[0] <DVariable :name \"v1\" :typeref <TypeRef :id \"t:N\" :td null>> :ntypes 0>"),
/* margin 60: the symtab and the DVariable break, the TypeRef
* does not -- one level of divergence, not three.
*/
Testcase_DLocalSymtab(1, 60, "s1.60",
"<LocalSymtab\n"
" :nvars 1\n"
" :[0]\n"
" <DVariable\n"
" :name \"v1\"\n"
" :typeref <TypeRef :id \"t:N\" :td null>>\n"
" :ntypes 0>"),
/* margin 30: everything breaks, so the +2 / +1 column gap
* compounds over three levels -- 4 vs 3, 6 vs 4, 8 vs 5.
*/
Testcase_DLocalSymtab(1, 30, "s1.30",
"<LocalSymtab\n"
" :nvars 1\n"
" :[0]\n"
" <DVariable\n"
" :name \"v1\"\n"
" :typeref\n"
" <TypeRef\n"
" :id \"t:N\"\n"
" :td null>>\n"
" :ntypes 0>"),
/* two vars: the generated names ARE the assertion -- [0] then
* [1], in append order, with v1/v2 following them.
*/
Testcase_DLocalSymtab(2, 200, "s2.200",
"<LocalSymtab :nvars 2 :[0] <DVariable :name \"v1\" :typeref <TypeRef :id \"t:N\" :td null>> :[1] <DVariable :name \"v2\" :typeref <TypeRef :id \"t:N\" :td null>> :ntypes 0>"),
Testcase_DLocalSymtab(2, 30, "s2.30",
"<LocalSymtab\n"
" :nvars 2\n"
" :[0]\n"
" <DVariable\n"
" :name \"v1\"\n"
" :typeref\n"
" <TypeRef\n"
" :id \"t:N\"\n"
" :td null>>\n"
" :[1]\n"
" <DVariable\n"
" :name \"v2\"\n"
" :typeref\n"
" <TypeRef\n"
" :id \"t:N\"\n"
" :td null>>\n"
" :ntypes 0>"),
};
/** DLambdaExpr's branch is ALL-OR-NOTHING, unlike DDefineExpr's
* per-field one: `name_ && body` gates the entire struct, so an
* incomplete lambda renders `<LambdaExpr>` rather than a struct
* with fields omitted. Both halves of that condition are cases.
*
* Its :local_symtab field nests DLocalSymtab, converted just
* before it -- which is why this printer was taken after that one
* rather than before: nothing here pins "STUB:" text.
**/
struct Testcase_DLambdaExpr {
Testcase_DLambdaExpr(const char * name, bool with_body, int n_var,
std::uint32_t margin, const char * label,
const char * expect_pretty)
: name_{name}, with_body_{with_body}, n_var_{n_var},
margin_{margin}, label_{label},
expect_pretty_{expect_pretty} {}
const char * name_;
bool with_body_;
int n_var_;
std::uint32_t margin_;
const char * label_;
/** OBSERVED via pretty; outlives phase E **/
std::string expect_pretty_;
};
static std::vector<Testcase_DLambdaExpr> s_lambda_v = {
/* fits on one line: the two stacks agree, and nothing nests
* deeply enough for the field-value column to matter.
*/
Testcase_DLambdaExpr("f", true, 0, 200, "L0.200",
"<LambdaExpr :tref <TypeRef :id \"t:N\" :td null> :name \"f\" :local_symtab <LocalSymtab :nvars 0 :ntypes 0> :body <DConstant :value_.tseq N :value.tseq N :value 5>>"),
Testcase_DLambdaExpr("f", true, 0, 60, "L0.60",
"<LambdaExpr\n"
" :tref <TypeRef :id \"t:N\" :td null>\n"
" :name \"f\"\n"
" :local_symtab <LocalSymtab :nvars 0 :ntypes 0>\n"
" :body <DConstant :value_.tseq N :value.tseq N :value 5>>"),
Testcase_DLambdaExpr("f", true, 0, 30, "L0.30",
"<LambdaExpr\n"
" :tref\n"
" <TypeRef\n"
" :id \"t:N\"\n"
" :td null>\n"
" :name \"f\"\n"
" :local_symtab\n"
" <LocalSymtab\n"
" :nvars 0\n"
" :ntypes 0>\n"
" :body\n"
" <DConstant\n"
" :value_.tseq N\n"
" :value.tseq N\n"
" :value 5>>"),
/* one argument, margin 200. The :local_symtab value is now wide
* enough that the LAMBDA breaks while the symtab inside it does
* not -- so every field gets its own line and the two stacks
* agree EXACTLY, isolating field order and names from layout.
*/
Testcase_DLambdaExpr("f", true, 1, 200, "L1.200",
"<LambdaExpr\n"
" :tref <TypeRef :id \"t:N\" :td null>\n"
" :name \"f\"\n"
" :local_symtab <LocalSymtab :nvars 1 :[0] <DVariable :name \"v1\" :typeref <TypeRef :id \"t:N\" :td null>> :ntypes 0>\n"
" :body <DConstant :value_.tseq N :value.tseq N :value 5>>"),
/* the mixed case: the symtab breaks, its one variable does not.
* Exactly one level of the +2/+1 column gap is visible.
*/
Testcase_DLambdaExpr("f", true, 1, 80, "L1.80",
"<LambdaExpr\n"
" :tref <TypeRef :id \"t:N\" :td null>\n"
" :name \"f\"\n"
" :local_symtab\n"
" <LocalSymtab\n"
" :nvars 1\n"
" :[0] <DVariable :name \"v1\" :typeref <TypeRef :id \"t:N\" :td null>>\n"
" :ntypes 0>\n"
" :body <DConstant :value_.tseq N :value.tseq N :value 5>>"),
/* everything breaks, four levels deep -- the deepest nesting in
* this fixture, and where the column gap compounds most.
*/
Testcase_DLambdaExpr("f", true, 1, 30, "L1.30",
"<LambdaExpr\n"
" :tref\n"
" <TypeRef\n"
" :id \"t:N\"\n"
" :td null>\n"
" :name \"f\"\n"
" :local_symtab\n"
" <LocalSymtab\n"
" :nvars 1\n"
" :[0]\n"
" <DVariable\n"
" :name \"v1\"\n"
" :typeref\n"
" <TypeRef\n"
" :id \"t:N\"\n"
" :td null>>\n"
" :ntypes 0>\n"
" :body\n"
" <DConstant\n"
" :value_.tseq N\n"
" :value.tseq N\n"
" :value 5>>"),
Testcase_DLambdaExpr("f", true, 2, 200, "L2.200",
"<LambdaExpr\n"
" :tref <TypeRef :id \"t:N\" :td null>\n"
" :name \"f\"\n"
" :local_symtab <LocalSymtab :nvars 2 :[0] <DVariable :name \"v1\" :typeref <TypeRef :id \"t:N\" :td null>> :[1] <DVariable :name \"v2\" :typeref <TypeRef :id \"t:N\" :td null>> :ntypes 0>\n"
" :body <DConstant :value_.tseq N :value.tseq N :value 5>>"),
/* name_ null: the WHOLE struct collapses to a bare <LambdaExpr>.
* Not "fields dropped" -- the body and symtab are present and
* still render nothing. That is legacy's branch, reproduced.
*/
Testcase_DLambdaExpr(nullptr, true, 1, 200, "Lanon.200",
"<LambdaExpr>"),
/* and it is margin-invariant, having no break points at all */
Testcase_DLambdaExpr(nullptr, true, 1, 30, "Lanon.30",
"<LambdaExpr>"),
/* body absent, name present: the same collapse from the other
* half of the condition.
*/
Testcase_DLambdaExpr("f", false, 1, 200, "Lnobody.200",
"<LambdaExpr>"),
/* margin 8 against 13 characters: the degenerate form does not
* break even when it cannot fit, because it has nowhere to.
*/
Testcase_DLambdaExpr("f", false, 1, 8, "Lnobody.8",
"<LambdaExpr>"),
};
} /*namespace*/
TEST_CASE("TypeRef-render", "[printable][TypeRef]")
{
UtestRehearser rh;
for (auto _ : rh) {
scope log(XO_DEBUG2_(rh.enable_debug(), "TypeRef-render"));
for (std::size_t i_tc = 0, n_tc = s_typeref_v.size(); i_tc < n_tc; ++i_tc) {
const auto & tc = s_typeref_v[i_tc];
TypeRef tr = make_typeref(tc.kind_);
std::string pretty = scrub_type_id(render_pretty(tr, tc.margin_));
log && log(xtag("i_tc", i_tc), xtag("margin", tc.margin_), xtag("pretty", pretty));
REHEARSE(rh, pretty == tc.expect_pretty_);
}
}
}
TEST_CASE("DVariable-render", "[printable][DVariable]")
{
REQUIRE(s_init.evidence());
UtestRehearser rh;
for (auto _ : rh) {
scope log(XO_DEBUG2_(rh.enable_debug(), "DVariable-render"));
for (std::size_t i_tc = 0, n_tc = s_dvariable_v.size(); i_tc < n_tc; ++i_tc) {
const auto & tc = s_dvariable_v[i_tc];
VarFixture fx(tc.label_);
DVariable * var = fx.make_var(tc.name_, tc.kind_);
REQUIRE(var != nullptr);
/* the facet, not the raw pointer: this is how a DVariable
* is printed in anger, and it is the path phase D removes
* IPrintable::pretty(ppindentinfo) from.
*/
auto p = with_facet<APrintable>::mkobj(var);
std::string pretty = scrub_type_id(render_pretty(p, tc.margin_));
log && log(xtag("i_tc", i_tc), xtag("margin", tc.margin_), xtag("pretty", pretty));
REHEARSE(rh, pretty == tc.expect_pretty_);
}
}
}
TEST_CASE("DVarRef-render", "[printable][DVarRef]")
{
REQUIRE(s_init.evidence());
UtestRehearser rh;
for (auto _ : rh) {
scope log(XO_DEBUG2_(rh.enable_debug(), "DVarRef-render"));
for (std::size_t i_tc = 0, n_tc = s_dvarref_v.size(); i_tc < n_tc; ++i_tc) {
const auto & tc = s_dvarref_v[i_tc];
VarFixture fx(tc.label_);
DVarRef * vr = fx.make_varref(tc.name_, tc.path_, tc.link_);
REQUIRE(vr != nullptr);
auto p = with_facet<APrintable>::mkobj(vr);
std::string pretty = render_pretty(p, tc.margin_);
log && log(xtag("i_tc", i_tc), xtag("margin", tc.margin_), xtag("pretty", pretty));
REHEARSE(rh, pretty == tc.expect_pretty_);
}
}
}
/** the one case that CANNOT be pinned against both protocols.
*
* DVarRef::pretty (and pretty_deprecated before it) does std::string_view(*(this->name()))
* with no null check, and a DVariable's name_ has no non-null
* invariant -- DVariable's own printer guards for exactly this. So
* legacy is undefined here rather than merely different, and there is
* no legacy rendering to compare against. DVarRef::pretty() guards,
* matching the sibling printer; this pins that guard.
**/
TEST_CASE("DVarRef-anon-render", "[printable][DVarRef]")
{
REQUIRE(s_init.evidence());
VarFixture fx("anon");
DVarRef * vr = fx.make_varref(nullptr, Binding::local(1), 0);
REQUIRE(vr != nullptr);
auto p = with_facet<APrintable>::mkobj(vr);
CHECK(render_pretty(p, 200) == "<DVarRef :name :path {path:0:1}>");
}
TEST_CASE("DGlobalSymtab-render", "[printable][DGlobalSymtab]")
{
REQUIRE(s_init.evidence());
UtestRehearser rh;
for (auto _ : rh) {
scope log(XO_DEBUG2_(rh.enable_debug(), "DGlobalSymtab-render"));
for (std::size_t i_tc = 0, n_tc = s_symtab_v.size(); i_tc < n_tc; ++i_tc) {
const auto & tc = s_symtab_v[i_tc];
VarFixture fx(tc.label_);
auto symtab = fx.make_symtab(tc.hint_capacity_, tc.name_v_);
auto p = with_facet<APrintable>::mkobj(symtab.data());
std::string pretty = render_pretty(p, tc.margin_);
log && log(xtag("i_tc", i_tc), xtag("margin", tc.margin_), xtag("pretty", pretty));
REHEARSE(rh, pretty == tc.expect_pretty_);
}
}
}
TEST_CASE("DConstant-render", "[printable][DConstant]")
{
REQUIRE(s_init.evidence());
UtestRehearser rh;
for (auto _ : rh) {
scope log(XO_DEBUG2_(rh.enable_debug(), "DConstant-render"));
for (std::size_t i_tc = 0, n_tc = s_constant_v.size(); i_tc < n_tc; ++i_tc) {
const auto & tc = s_constant_v[i_tc];
VarFixture fx(tc.label_);
DConstant * k = (tc.is_int_
? fx.make_constant(42L)
: fx.make_constant(2.5));
REQUIRE(k != nullptr);
auto p = with_facet<APrintable>::mkobj(k);
std::string pretty = scrub_tseq(render_pretty(p, tc.margin_));
log && log(xtag("i_tc", i_tc), xtag("margin", tc.margin_), xtag("pretty", pretty));
REHEARSE(rh, pretty == tc.expect_pretty_);
}
}
}
/** what scrub_tseq hides, checked without depending on the numbers.
*
* DConstant prints TWO typeseqs -- the boxed D-type's
* (value_._typeseq()) and the APrintable facet obj's
* (value_pr._typeseq()). Two fields only earn their place if they can
* disagree. **Observed 2026-08-10: they always agree**, because an
* obj<> carries the D-type's typeseq whichever facet it is viewed
* through, and FacetRegistry::variant() does not change the D-type.
*
* So `:value.tseq` is REDUNDANT in the rendering. Recorded here as a
* pinned property rather than left implicit: a mutation replacing
* value_pr._typeseq() with value_._typeseq() passes every other test
* in this file, and that is a fact about the printer, not a gap in the
* suite. Dropping the field would be an output-visible change and
* wants its own commit -- see
* .xo-backlog/xo-printable2/issues/01-aprintable-pretty-ppsink.md
**/
TEST_CASE("DConstant-tseq-fields", "[printable][DConstant]")
{
REQUIRE(s_init.evidence());
VarFixture fx_int("tseq.int");
VarFixture fx_flt("tseq.flt");
std::string s_int
= render_pretty(with_facet<APrintable>::mkobj(fx_int.make_constant(42L)), 200);
std::string s_flt
= render_pretty(with_facet<APrintable>::mkobj(fx_flt.make_constant(2.5)), 200);
CHECK(scrub_tseq(s_int) == "<DConstant :value_.tseq N :value.tseq N :value 42>");
CHECK(first_tseq(s_int) > 0);
/* the two fields agree -- the redundancy described above */
CHECK(first_tseq(s_int) == second_tseq(s_int));
CHECK(first_tseq(s_flt) == second_tseq(s_flt));
/* ... and the boxed type IS genuinely discriminated between cases */
CHECK(first_tseq(s_int) != first_tseq(s_flt));
}
TEST_CASE("DIfElseExpr-render", "[printable][DIfElseExpr]")
{
REQUIRE(s_init.evidence());
UtestRehearser rh;
for (auto _ : rh) {
scope log(XO_DEBUG2_(rh.enable_debug(), "DIfElseExpr-render"));
for (std::size_t i_tc = 0, n_tc = s_ifelse_v.size(); i_tc < n_tc; ++i_tc) {
const auto & tc = s_ifelse_v[i_tc];
VarFixture fx(tc.label_);
DIfElseExpr * e = fx.make_ifelse(tc.with_test_, tc.with_true_,
tc.with_false_);
REQUIRE(e != nullptr);
auto p = with_facet<APrintable>::mkobj(e);
std::string pretty
= scrub_typevar(scrub_tseq(render_pretty(p, tc.margin_)));
log && log(xtag("i_tc", i_tc), xtag("margin", tc.margin_), xtag("pretty", pretty));
REHEARSE(rh, pretty == tc.expect_pretty_);
}
}
}
TEST_CASE("DSequenceExpr-render", "[printable][DSequenceExpr]")
{
REQUIRE(s_init.evidence());
UtestRehearser rh;
for (auto _ : rh) {
scope log(XO_DEBUG2_(rh.enable_debug(), "DSequenceExpr-render"));
for (std::size_t i_tc = 0, n_tc = s_sequence_v.size(); i_tc < n_tc; ++i_tc) {
const auto & tc = s_sequence_v[i_tc];
VarFixture fx(tc.label_);
DSequenceExpr * e = fx.make_sequence(tc.n_elt_);
REQUIRE(e != nullptr);
auto p = with_facet<APrintable>::mkobj(e);
std::string pretty = scrub_tseq(render_pretty(p, tc.margin_));
log && log(xtag("i_tc", i_tc), xtag("margin", tc.margin_), xtag("pretty", pretty));
REHEARSE(rh, pretty == tc.expect_pretty_);
}
}
}
TEST_CASE("DDefineExpr-render", "[printable][DDefineExpr]")
{
REQUIRE(s_init.evidence());
UtestRehearser rh;
for (auto _ : rh) {
scope log(XO_DEBUG2_(rh.enable_debug(), "DDefineExpr-render"));
for (std::size_t i_tc = 0, n_tc = s_define_v.size(); i_tc < n_tc; ++i_tc) {
const auto & tc = s_define_v[i_tc];
VarFixture fx(tc.label_);
DDefineExpr * e = fx.make_define(tc.name_, tc.with_rhs_);
REQUIRE(e != nullptr);
auto p = with_facet<APrintable>::mkobj(e);
std::string pretty
= scrub_typevar(scrub_tseq(render_pretty(p, tc.margin_)));
log && log(xtag("i_tc", i_tc), xtag("margin", tc.margin_), xtag("pretty", pretty));
REHEARSE(rh, pretty == tc.expect_pretty_);
}
}
}
TEST_CASE("DApplyExpr-render", "[printable][DApplyExpr]")
{
REQUIRE(s_init.evidence());
UtestRehearser rh;
for (auto _ : rh) {
scope log(XO_DEBUG2_(rh.enable_debug(), "DApplyExpr-render"));
for (std::size_t i_tc = 0, n_tc = s_apply_v.size(); i_tc < n_tc; ++i_tc) {
const auto & tc = s_apply_v[i_tc];
VarFixture fx(tc.label_);
DApplyExpr * e = fx.make_applyexpr(tc.n_arg_);
REQUIRE(e != nullptr);
auto p = with_facet<APrintable>::mkobj(e);
std::string pretty
= scrub_type_id(scrub_typevar(scrub_tseq(render_pretty(p, tc.margin_))));
log && log(xtag("i_tc", i_tc), xtag("margin", tc.margin_), xtag("pretty", pretty));
REHEARSE(rh, pretty == tc.expect_pretty_);
}
}
}
TEST_CASE("DLocalSymtab-render", "[printable][DLocalSymtab]")
{
REQUIRE(s_init.evidence());
UtestRehearser rh;
for (auto _ : rh) {
scope log(XO_DEBUG2_(rh.enable_debug(), "DLocalSymtab-render"));
for (std::size_t i_tc = 0, n_tc = s_localsymtab_v.size(); i_tc < n_tc; ++i_tc) {
const auto & tc = s_localsymtab_v[i_tc];
VarFixture fx(tc.label_);
DLocalSymtab * e = fx.make_localsymtab(tc.n_var_, 0 /*n_type*/);
REQUIRE(e != nullptr);
auto p = with_facet<APrintable>::mkobj(e);
std::string pretty
= scrub_type_id(scrub_typevar(scrub_tseq(render_pretty(p, tc.margin_))));
log && log(xtag("i_tc", i_tc), xtag("margin", tc.margin_), xtag("pretty", pretty));
REHEARSE(rh, pretty == tc.expect_pretty_);
}
}
}
/** the types_ path, which s_localsymtab_v cannot cover.
*
* `.xo-backlog/xo-type/issues/01-no-aprintable-facet.md` recorded the
* throw as UNVERIFIED -- a code-read, since nothing constructed a
* symtab with a non-empty types_. This is the observation, and it
* corrects where the ticket placed the fault: DLocalSymtab's own
* `(*types_)[i].to_facet<APrintable>()` SUCCEEDS, because DTypename
* has an IPrintable facet. What throws is one level down, in
* DTypename::pretty's `type_.to_facet<APrintable>()`,
* since xo-type's D-types have none.
*
* The two sides AGREED TO DISAGREE only briefly. While DTypename was
* a phase-B stub, ppsink rendered here and legacy threw; once
* DTypename converted (same day, keeping to_facet<APrintable>) both
* throw, and the symmetry is back. See DTypename-render for why the
* throw was kept rather than tolerated.
*
* Both halves survive phase E, for the same reason as DTypename's.
**/
TEST_CASE("DLocalSymtab-types-throws", "[printable][DLocalSymtab]")
{
REQUIRE(s_init.evidence());
VarFixture fx("types.throws");
DLocalSymtab * e = fx.make_localsymtab(0 /*n_var*/, 1 /*n_type*/);
REQUIRE(e != nullptr);
auto p = with_facet<APrintable>::mkobj(e);
/* the whole render dies rather than the one field, on both
* protocols: DLocalSymtab's own facet lookup succeeds (types_
* holds DTypenames, which HAVE the facet) and DTypename's does
* not. One level deeper than this ticket first claimed.
*/
REQUIRE_THROWS_AS(render_pretty(p, 200), std::runtime_error);
}
TEST_CASE("DLambdaExpr-render", "[printable][DLambdaExpr]")
{
REQUIRE(s_init.evidence());
UtestRehearser rh;
for (auto _ : rh) {
scope log(XO_DEBUG2_(rh.enable_debug(), "DLambdaExpr-render"));
for (std::size_t i_tc = 0, n_tc = s_lambda_v.size(); i_tc < n_tc; ++i_tc) {
const auto & tc = s_lambda_v[i_tc];
VarFixture fx(tc.label_);
DLambdaExpr * e = fx.make_lambda(tc.name_, tc.with_body_, tc.n_var_);
REQUIRE(e != nullptr);
auto p = with_facet<APrintable>::mkobj(e);
std::string pretty
= scrub_type_id(scrub_typevar(scrub_tseq(render_pretty(p, tc.margin_))));
log && log(xtag("i_tc", i_tc), xtag("margin", tc.margin_), xtag("pretty", pretty));
REHEARSE(rh, pretty == tc.expect_pretty_);
}
}
}
/** DTypename has NO renderable case, and that is the point.
*
* RC's call, 2026-08-11: the printer keeps `to_facet<APrintable>()`
* and throws, rather than tolerating the missing facet with a
* placeholder. The throw is a standing failing test for
* `.xo-backlog/xo-type/issues/01` -- a "yolo" red test, living in the
* tree rather than in a test file. Retiring it with a placeholder
* would retire the only thing asserting the gap exists.
*
* So this test pins the THROW, on both protocols. It is expected to
* start failing the day xo-type gains an APrintable facet; that
* failure is the signal, and the fix then is to replace this with a
* rendering test.
*
* Phase E deleted the matching deprecated assertion, as everywhere
* else in this file. Here that assertion was NOT scaffolding for a
* pinned rendering -- there is no rendering -- it recorded that both
* protocols failed identically, which was the evidence that the
* conversion changed nothing.
**/
TEST_CASE("DTypename-render", "[printable][DTypename]")
{
REQUIRE(s_init.evidence());
VarFixture fx("typename.throws");
auto tn = fx.make_typename("t1", true /*with_type*/);
auto pr = tn.to_facet<APrintable>();
REQUIRE_THROWS_AS(render_pretty(pr, 200), std::runtime_error);
}
} /*namespace ut*/
} /*namespace xo*/
/* end printable_render.test.cpp */