diff --git a/libraries/sysiolib/core/sysio/basic_name.hpp b/libraries/sysiolib/core/sysio/basic_name.hpp index d2985de0b..a575f223e 100644 --- a/libraries/sysiolib/core/sysio/basic_name.hpp +++ b/libraries/sysiolib/core/sysio/basic_name.hpp @@ -32,6 +32,7 @@ */ #include "check.hpp" +#include "reflect.hpp" #include "serialize.hpp" #include @@ -63,10 +64,23 @@ concept basic_name_traits = { Traits::bad_char_message } -> std::convertible_to; { Traits::too_long_message } -> std::convertible_to; { Traits::bad_final_symbol_message } -> std::convertible_to; + { Traits::not_normalized_message } -> std::convertible_to; } && Traits::max_len > 0 && std::string_view{ Traits::alphabet }.size() > 0; +/// OPTIONAL traits members: the symbols a spelling may START with, and the +/// message to report when it does not. Traits that omit them accept any +/// alphabet character in the leading position, which is what `sysio::name` +/// wants. `slug_name` supplies them so that no legal code can be confused with +/// a decimal number - see `slug_name_traits::leading_alphabet`. Byte-identical +/// with the host-side fc::basic_name. +template +concept basic_name_has_leading_alphabet = requires { + { Traits::leading_alphabet } -> std::convertible_to; + { Traits::bad_leading_char_message } -> std::convertible_to; +}; + template struct basic_name { uint64_t value = 0; @@ -74,33 +88,83 @@ struct basic_name { constexpr basic_name() = default; constexpr explicit basic_name( uint64_t v ) : value(v) {} - /// Per-character validated string constructor. sysio::check-throws on an - /// over-long string, an out-of-alphabet character, a final symbol too - /// wide for its (possibly narrowed) slot, or - for zero_terminates traits - /// only - the pad symbol embedded anywhere in the string (such a literal - /// would silently decode to just its prefix; rejecting it keeps the - /// literal and the canonical decoding in agreement). constexpr - so an - /// invalid `_n` / `_s` literal is a compile error. + /// Construct from a string. sysio::check-throws unless the input is the + /// canonical spelling of its own encoding - see validity_error(), the + /// SINGLE validation algorithm this type has, shared with + /// is_valid_literal() and byte-for-byte the same rules, in the same order, + /// as the host-side fc::basic_name. The two are meant to be diffable. + /// + /// constexpr - sysio::check is not, so it is reached only on the failure + /// path: a valid `_n` / `_s` literal constant-evaluates, and an invalid one + /// is a compile error rather than a silent mis-encoding. constexpr explicit basic_name( std::string_view str ) : value(0) { - // sysio::check is not constexpr - invoke it only on the failure path so - // a valid `_n` / `_s` literal still constant-evaluates (a bad one - // reaches check and is therefore a compile error). - if ( str.size() > static_cast(Traits::max_len) ) - sysio::check( false, Traits::too_long_message ); + if ( const char* why = validity_error(str) ) + sysio::check( false, why ); + value = pack(str); + } + + /// Non-validating encode - the constexpr path used by literals. Characters + /// outside the alphabet pack as symbol 0. + static constexpr uint64_t pack( std::string_view str ) { + uint64_t v = 0; const int n = static_cast(str.size()); - for ( int i = 0; i < Traits::max_len && i < n; ++i ) { - const uint64_t sym = symbol( str[i] ); + for ( int i = 0; i < Traits::max_len && i < n; ++i ) + v |= (sym_of(str[i]) & width_mask(i)) << shift(i); + return v; + } + + /// Is `str` a valid, canonical spelling? Delegates to validity_error so the + /// literal path and the throwing constructor can never disagree. + static constexpr bool is_valid_literal( std::string_view str ) { + return validity_error(str) == nullptr; + } + + /// THE validation algorithm. Returns nullptr when `str` is a valid, + /// canonical spelling; otherwise the traits' message for the FIRST rule it + /// breaks. Rules 4-6 make pack() lossless, so to_string(pack(str)) IS str. + /// Identical to fc::basic_name::validity_error - keep the two in lock-step. + static constexpr const char* validity_error( std::string_view str ) { + // 1. length + if ( str.size() > static_cast(Traits::max_len) ) + return Traits::too_long_message; + + // 2. leading symbol, for traits that restrict it + if constexpr ( basic_name_has_leading_alphabet ) { + if ( !str.empty() + && std::string_view{ Traits::leading_alphabet }.find( str[0] ) + == std::string_view::npos ) + return Traits::bad_leading_char_message; + } + + for ( std::size_t i = 0; i < str.size(); ++i ) { + const std::size_t sym = alphabet.find( str[i] ); + + // 3. in the alphabet + if ( sym == std::string_view::npos ) + return Traits::bad_char_message; + + // 4. a zero-terminated alphabet has no INTERIOR pad: to_string() stops + // at the first symbol-0 slot, so such a spelling cannot round-trip. if constexpr ( Traits::zero_terminates ) { - // sym == 0 is the pad/terminator slot. For zero_terminates traits, - // an interior pad would make to_string() truncate (e.g. "A\0B" - // decodes to "A"), so the input would not round-trip. Reject. if ( sym == 0 ) - sysio::check( false, Traits::bad_char_message ); + return Traits::bad_char_message; } - if ( sym > width_mask(i) ) - sysio::check( false, Traits::bad_final_symbol_message ); - value |= sym << shift(i); + + // 5. the final slot may be narrower than `bits` (13 x 5 > 64 for name, + // leaving 4 bits), and pack() would silently truncate a symbol too + // wide for it. + if ( static_cast(sym) > width_mask( static_cast(i) ) ) + return Traits::bad_final_symbol_message; } + + // 6. a non-zero-terminated alphabet strips TRAILING pads in to_string(), + // so a trailing pad cannot round-trip either. + if constexpr ( !Traits::zero_terminates ) { + if ( !str.empty() && str.back() == alphabet[0] ) + return Traits::not_normalized_message; + } + + return nullptr; } constexpr uint64_t to_uint64_t() const { return value; } @@ -116,10 +180,10 @@ struct basic_name { // slot; for name, symbol 0 ('.') is an ordinary interior character. if ( Traits::zero_terminates && sym == 0 ) break; - s.push_back( character( sym ) ); + s.push_back( char_of( sym ) ); } if ( !Traits::zero_terminates ) { - const char pad = character(0); + const char pad = char_of(0); while ( !s.empty() && s.back() == pad ) s.pop_back(); } @@ -129,16 +193,14 @@ struct basic_name { /// character -> symbol; sysio::check-throws on a character outside the /// alphabet. (sysio::name re-exposes this as char_to_value.) static constexpr uint64_t symbol( char c ) { - const std::string_view a = Traits::alphabet; - for ( std::size_t s = 0; s < a.size(); ++s ) - if ( a[s] == c ) return static_cast(s); + for ( std::size_t s = 0; s < alphabet.size(); ++s ) + if ( alphabet[s] == c ) return static_cast(s); sysio::check( false, Traits::bad_char_message ); return 0; // unreachable } /// symbol -> character; out-of-range symbols decode as the pad (alphabet[0]). static constexpr char character( uint64_t s ) { - const std::string_view a = Traits::alphabet; - return s < a.size() ? a[s] : a[0]; + return s < alphabet.size() ? alphabet[s] : alphabet[0]; } // Total order on the packed value. With MSB packing this matches the @@ -150,20 +212,48 @@ struct basic_name { friend constexpr bool operator==( basic_name a, basic_name b ) = default; SYSLIB_SERIALIZE( basic_name, (value) ) + // Bluegrass reflection, needed by CDT's to_key: its generic dispatches on is_floating_point / + // is_integral / is_enum and otherwise reflects, never consulting operator<<. Without this a basic_name + // reaching to_key reflects as invalid_fields and silently encodes a ZERO-BYTE key. Declared here rather + // than per-instantiation so every traits specialisation is covered. + CDT_REFLECT(value); private: + /// `Traits::alphabet` as a view. The traits concept requires only that the + /// member be CONVERTIBLE to string_view, so every use binds here rather than + /// calling find/size/operator[] on the traits member and silently demanding + /// more of a policy than the concept declares. Mirrors fc::basic_name. + static constexpr std::string_view alphabet = Traits::alphabet; + // --- symbol width: minimal bits to index the alphabet --- static constexpr int symbol_bits( std::size_t alphabet_size ) { int b = 0; while ( (std::size_t{1} << b) < alphabet_size ) ++b; return b; } - static constexpr int bits = symbol_bits( Traits::alphabet.size() ); + static constexpr int bits = symbol_bits( alphabet.size() ); static constexpr int total_bits = Traits::max_len * bits < 64 ? Traits::max_len * bits : 64; static_assert( (Traits::max_len - 1) * bits < 64, "basic_name: symbol layout does not fit in 64 bits" ); + /// symbol -> character; out-of-range symbols decode as the pad (alphabet[0]). + /// The private counterpart to sym_of, matching the host-side fc::basic_name. + /// symbol()/character() below stay as this type's PUBLIC surface, which + /// sysio::name re-exposes as char_to_value. + static constexpr char char_of( uint64_t s ) { + return s < alphabet.size() ? alphabet[s] : alphabet[0]; + } + + /// character -> symbol, NON-throwing: any character outside the alphabet + /// maps to 0. Used by pack(), which is non-validating by contract; callers + /// that need rejection go through validity_error(). Mirrors fc's sym_of. + static constexpr uint64_t sym_of( char c ) { + for ( std::size_t s = 0; s < alphabet.size(); ++s ) + if ( alphabet[s] == c ) return static_cast(s); + return 0; + } + // --- Bit layout. Direction is set by Traits::packing. The final symbol // absorbs any shortfall when max_len * bits > 64. --- /// Bit offset of symbol i. MSB: symbol 0 occupies the highest bits and the diff --git a/libraries/sysiolib/core/sysio/check.hpp b/libraries/sysiolib/core/sysio/check.hpp index 7f9b3883f..0238b505f 100644 --- a/libraries/sysiolib/core/sysio/check.hpp +++ b/libraries/sysiolib/core/sysio/check.hpp @@ -4,6 +4,7 @@ */ #pragma once +#include #include #include diff --git a/libraries/sysiolib/core/sysio/name.hpp b/libraries/sysiolib/core/sysio/name.hpp index 25ebb55b7..081b0e7f3 100644 --- a/libraries/sysiolib/core/sysio/name.hpp +++ b/libraries/sysiolib/core/sysio/name.hpp @@ -41,6 +41,9 @@ namespace sysio { "string is too long to be a valid name"; static constexpr const char* bad_final_symbol_message = "thirteenth character in name cannot be a letter that comes after j"; + // A legal character in an illegal position (a trailing pad) leaves a + // spelling that does not round-trip through to_string(). Host wording. + static constexpr const char* not_normalized_message = "name is not properly normalized"; }; /** @@ -213,8 +216,10 @@ namespace sysio { CDT_REFLECT(value); // name's own serialization: an exact-match operator<<(ds, const name&) // must exist, else the generic bluegrass::meta field-iterator is chosen - // and rejects name as a non-aggregate. (basic_name has its own, used by - // slug_name, which is the alias type itself rather than a derived type.) + // and rejects name as a non-aggregate. The base's hidden friend does not + // serve a derived type -- reaching it needs a conversion, which loses to + // the exact-matching generic overload. Every derived basic_name needs its + // own; slug_name carries SYSLIB_SERIALIZE_DERIVED_EMPTY for this reason. SYSLIB_SERIALIZE( name, (value) ) }; diff --git a/libraries/sysiolib/core/sysio/slug_name.hpp b/libraries/sysiolib/core/sysio/slug_name.hpp new file mode 100644 index 000000000..9311217f7 --- /dev/null +++ b/libraries/sysiolib/core/sysio/slug_name.hpp @@ -0,0 +1,123 @@ +#pragma once + +#include "basic_name.hpp" +#include "name.hpp" // for sysio::detail::to_const_char_arr + +#include + +namespace sysio { + + /** + * @defgroup slug_name + * @ingroup core + * @ingroup types + * @brief Packed registry-code identifier + */ + + /// Alphabet + length traits for the registry-code encoding: up to 8 symbols over [A-Z0-9_]. + /// Drives sysio::basic_name. + /// + /// Byte-identical with the host-side fc::slug_name, which instantiates fc::basic_name over the same + /// alphabet, length, terminator rule and packing direction. The two traits structs are the whole + /// specification — keep them diffable line for line. + struct slug_name_traits { + /// 8, not the 10 that would fill 64 bits: 8 symbols x 6 bits = 48, so every encoded value stays in + /// [0, 2^48) — under JS Number's 2^53 safe integer limit, letting TS consumers use `number` rather + /// than bigint. + static constexpr int max_len = 8; + + /// Symbol 0 is the '\0' pad/terminator; 1-26 = A-Z, 27-36 = 0-9, 37 = '_'. Held as a named array so + /// the length comes from sizeof — a string_view built straight from the literal would stop at the + /// leading NUL. + static constexpr char alphabet_storage[] = "\0ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_"; + static constexpr std::string_view alphabet{ alphabet_storage, sizeof(alphabet_storage) - 1 }; + + /// A code must START with a letter. This is what makes the host's string + /// carrier unambiguous: no legal code can be spelled like a number, so a + /// bare JSON string is always a code and never a decimal. Without it the + /// alphabet's digits make "7" both a valid code and a valid decimal, and + /// "1E3" / "0X10" additionally collide with JS numeric syntax. Digits and + /// '_' remain legal in every position after the first ("V1", "TRAIL_"). + /// The empty string is unaffected - it is the zero sentinel. + static constexpr std::string_view leading_alphabet{ "ABCDEFGHIJKLMNOPQRSTUVWXYZ" }; + + /// A symbol-0 slot TERMINATES the string, unlike name's '.' which is an ordinary interior character. + /// This is why a slug_name is not total over uint64: every value below 2^42 has a zero in the char[0] + /// slot and so decodes to the empty string. + static constexpr bool zero_terminates = true; + + /// MSB-first: char[0] occupies bits [42..47]. This is what gives the packed value its grouping + /// property — a shared textual prefix is a shared leading bit prefix, so prefix-related codes are + /// contiguous in key order and retrievable as a range. + static constexpr basic_name_endianness packing = basic_name_endianness::MSB; + + static constexpr const char* bad_char_message = + "character is not in allowed character set for slug_names ([A-Z0-9_])"; + static constexpr const char* too_long_message = "string is too long to be a valid slug_name"; + static constexpr const char* bad_leading_char_message = + "slug_name must start with a letter ([A-Z])"; + static constexpr const char* bad_final_symbol_message = + "final character in slug_name does not fit its packed slot"; + static constexpr const char* not_normalized_message = + "slug_name is not properly normalized"; + }; + + /// Packed registry-code identifier — up to 8 symbols over [A-Z0-9_]. + /// + /// A DERIVED STRUCT, not an alias -- the same shape as sysio::name, and for the same reason. + /// + /// abigen matches builtins on the namespace-stripped written spelling, and `slug_name` is in that + /// set (plugins/sysio/gen.hpp) -- but an ALIAS never reaches that match: abigen resolves it through + /// to the underlying template first and then emits BOTH a typedef + /// (`slug_name` -> `basic_name_slug_name_traits`) AND a struct_def for the instantiation. The host, + /// which knows `slug_name` intrinsically, then rejects the ABI outright: + /// duplicate_abi_type_def_exception: type already exists 'slug_name' + /// `sysio::name` avoids this only because it is a derived struct, so the builtin match applies to + /// the written name. slug_name follows it. + struct slug_name : basic_name { + using base = basic_name; + using base::base; // slug_name(uint64_t), slug_name(std::string_view) + constexpr slug_name() = default; + + /// slug_name's own serialization, for the same reason sysio::name carries one: a DERIVED type + /// needs an EXACT-match operator on itself. The base's hidden friend takes `const basic_name&`, + /// so reaching it from a `slug_name` requires a derived-to-base conversion -- and the generic + /// class-template overload, which matches exactly, wins instead. That overload hands the type to + /// the bluegrass::meta field iterator, which rejects anything with a user-declared constructor: + /// "Types with user specified constructors are not supported" + /// Clang happens to tolerate it; GCC 13 does not, which matters because add_native_contract() + /// builds with the HOST compiler and its generated dispatcher deserializes action arguments + /// through this path. Forwarding to the base keeps the bytes identical to basic_name's. + SYSLIB_SERIALIZE_DERIVED_EMPTY( slug_name, base ) + + /// Does this value have a canonical spelling? A slug_name built from a RAW uint64 bypasses the + /// validating constructor -- and nothing validates on deserialization either, since the + /// reflected member is written directly -- so it can hold a value no spelling produces: + /// anything whose leading symbol slot is empty, or that uses one of the 26 unused symbol + /// values, or that sets any of bits 48-63. Such a value cannot round-trip. + /// + /// This lives on slug_name and NOT on basic_name because it is meaningless for `name`: that + /// alphabet is exactly 2^5 with no gaps and its 13 symbols consume all 64 bits, so every + /// uint64 IS a canonical name and the predicate could never be false. + bool is_canonical() const { + const std::string text = to_string(); + return is_valid_literal(text) && pack(text) == value; + } + }; + +} // namespace sysio + +/** + * Compile-time slug_name literal: `"ETH"_s`, `"LIQSOL"_s`. Validation happens in basic_name's constexpr + * constructor — a character outside [A-Z0-9_], or more than 8 characters, fails the constant evaluation. + * Mirrors the shape of sysio::name's `_n` literal, including its global scope. + */ +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wgnu-string-literal-operator-template" +template +inline constexpr sysio::slug_name operator""_s() { + constexpr auto x = sysio::slug_name{ std::string_view{ sysio::detail::to_const_char_arr::value, + sizeof...(Str) } }; + return x; +} +#pragma clang diagnostic pop diff --git a/plugins/sysio/abigen.hpp b/plugins/sysio/abigen.hpp index f223f7fc9..b9a2d87a6 100644 --- a/plugins/sysio/abigen.hpp +++ b/plugins/sysio/abigen.hpp @@ -287,6 +287,18 @@ namespace sysio { namespace cdt { } void add_struct( const clang::CXXRecordDecl* decl, const std::string& rname="" ) { + // A type the ABI knows INTRINSICALLY is never described -- and neither is + // its base. `struct slug_name : basic_name` (the same + // shape as `name`) would otherwise leak an orphan + // `basic_name_slug_name_traits` struct_def that no field references, + // because the base walk below runs unconditionally, before any builtin + // check applies to the derived type. add_type() already skips builtins, so + // this is only reachable where a caller force-adds a struct: the kv-key + // path adds a table's key struct so clients can reference it, which is + // right for a composite key and wrong for one that is already a builtin. + const std::string emitted_name = rname.empty() ? decl->getName().str() : rname; + if ( is_builtin_type(emitted_name) ) + return; abi_struct ret; if ( decl->getNumBases() == 1 ) { ret.base = get_type(decl->bases_begin()->getType()); @@ -332,6 +344,14 @@ namespace sysio { namespace cdt { } abi_struct new_struct; new_struct.name = decl->getNameAsString(); + // The wrapper is named after the METHOD, so an action method named after a builtin + // produces a struct the ABI cannot carry: `validate_struct` drops it, leaving an action + // whose `type` names the builtin. The host then resolves the builtin's shape -- one + // 8-byte slug for `slug_name` -- while the generated dispatcher still deserializes the + // real parameter list, so the action is silently unusable. Refuse at compile time. + CDT_CHECK_ERROR(!is_builtin_type(new_struct.name), "abigen_error", decl->getLocation(), + "action method '" + new_struct.name + "' collides with the built-in ABI type of the " + "same name; rename the method (the [[sysio::action(\"...\")]] name may stay)"); for (auto param : decl->parameters() ) { auto param_type = param->getType().getNonReferenceType().getUnqualifiedType(); new_struct.fields.push_back({param->getNameAsString(), get_type(param_type)}); @@ -534,9 +554,21 @@ namespace sysio { namespace cdt { t.key_names.push_back("scope"); t.key_types.push_back("name"); } - for (auto* field : key_source->fields()) { - t.key_names.push_back(field->getName().str()); - t.key_types.push_back(translate_type(field->getType())); + if (key_source->field_empty() && is_builtin_type(key_source->getNameAsString())) { + // A key that IS a builtin -- `kv::table<"codes"_n, sysio::slug_name, row>` -- is a + // single packed scalar, not a composite, so there are no fields to walk and the loop + // below would leave key_names/key_types EMPTY. A host cannot decode a next_key or + // encode a bound from an empty shape, and nothing downstream can recover what the + // key was. Publish the one leaf it actually is, in the same single-leaf shape + // kv::global already emits (name/name). + const std::string leaf = key_source->getNameAsString(); + t.key_names.push_back(leaf); + t.key_types.push_back(leaf); + } else { + for (auto* field : key_source->fields()) { + t.key_names.push_back(field->getName().str()); + t.key_types.push_back(translate_type(field->getType())); + } } t.secondary_indexes = std::move(sec_indexes); kv_key_structs.insert(key_decl->getNameAsString()); diff --git a/plugins/sysio/gen.hpp b/plugins/sysio/gen.hpp index 4d9cfe22b..aee55ddd3 100644 --- a/plugins/sysio/gen.hpp +++ b/plugins/sysio/gen.hpp @@ -827,6 +827,7 @@ struct generation_utils { } // --- Builtin / reserved checks --- + inline bool is_builtin_type( const std::string& t ) { static const std::set builtins = { @@ -859,6 +860,7 @@ struct generation_utils { "signature", "symbol", "symbol_code", + "slug_name", "asset", "extended_asset" }; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 5950086b8..01331cae1 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -14,6 +14,7 @@ add_unit_test( datastream_tests ) add_unit_test( fixed_bytes_tests ) add_unit_test( name_tests ) add_unit_test( basic_name_tests ) +add_unit_test( slug_name_tests ) add_unit_test( rope_tests ) add_unit_test( print_tests ) add_unit_test( protobuf_wire_tests ) diff --git a/tests/toolchain/abigen-fail/builtin_type_name_collision.cpp b/tests/toolchain/abigen-fail/builtin_type_name_collision.cpp new file mode 100644 index 000000000..36f8eb798 --- /dev/null +++ b/tests/toolchain/abigen-fail/builtin_type_name_collision.cpp @@ -0,0 +1,31 @@ +// An action method named after a builtin ABI type must be a DIAGNOSTIC, not a silent ABI. +// +// The wrapper struct abigen synthesizes for an action is named after the METHOD. Once +// `slug_name` joined the builtin set (wire-cdt #119), a method named `slug_name` produced a +// wrapper whose name is a builtin -- and `validate_struct` drops any such struct. The ABI then +// carried the action with `type: "slug_name"` and `structs: []`, so the host resolved the +// BUILTIN's shape (one 8-byte slug) while the generated dispatcher still deserialized the real +// parameter list (uint64 + uint32, 12 bytes). Nothing failed at build time and nothing failed at +// deploy time; the action was simply unusable, and no error pointed at why. +// +// Emitting the wrapper instead is not an option either -- the host rejects an ABI that defines a +// type it already knows intrinsically (`duplicate_abi_type_def_exception`). Since neither +// outcome is recoverable at run time, abigen refuses at compile time, where renaming the method +// costs nothing. The [[sysio::action("...")]] NAME is unaffected; only the C++ method spelling +// has to move. +#include +#include + +using namespace sysio; + +class [[sysio::contract("builtin_type_name_collision")]] builtin_type_name_collision + : public contract { +public: + using contract::contract; + + [[sysio::action("regslug")]] + void slug_name(uint64_t id, uint32_t flags) { + check(flags != 0, "flags must be set"); + print(id); + } +}; diff --git a/tests/toolchain/abigen-fail/builtin_type_name_collision.json b/tests/toolchain/abigen-fail/builtin_type_name_collision.json new file mode 100644 index 000000000..9f58ac939 --- /dev/null +++ b/tests/toolchain/abigen-fail/builtin_type_name_collision.json @@ -0,0 +1,10 @@ +{ + "tests": [ + { + "compile_flags": ["--abigen"], + "expected": { + "stderr": "collides with the built-in ABI type of the same name" + } + } + ] +} diff --git a/tests/toolchain/abigen-pass/slug_name_builtin.abi b/tests/toolchain/abigen-pass/slug_name_builtin.abi new file mode 100644 index 000000000..0b301cece --- /dev/null +++ b/tests/toolchain/abigen-pass/slug_name_builtin.abi @@ -0,0 +1,61 @@ +{ + "____comment": "This file was generated with sysio-abigen. DO NOT EDIT ", + "version": "sysio::abi/1.2", + "types": [], + "structs": [ + { + "name": "code_key", + "base": "", + "fields": [ + { + "name": "code", + "type": "slug_name" + } + ] + }, + { + "name": "code_row", + "base": "", + "fields": [ + { + "name": "amount", + "type": "uint64" + } + ] + }, + { + "name": "reg", + "base": "", + "fields": [ + { + "name": "code", + "type": "slug_name" + }, + { + "name": "amount", + "type": "uint64" + } + ] + } + ], + "actions": [ + { + "name": "reg", + "type": "reg", + "ricardian_contract": "" + } + ], + "tables": [ + { + "name": "codes", + "type": "code_row", + "index_type": "i64", + "key_names": ["code"], + "key_types": ["slug_name"], + "table_id": 15464 + } + ], + "ricardian_clauses": [], + "variants": [], + "action_results": [] +} \ No newline at end of file diff --git a/tests/toolchain/abigen-pass/slug_name_builtin.cpp b/tests/toolchain/abigen-pass/slug_name_builtin.cpp new file mode 100644 index 000000000..8e138c3c1 --- /dev/null +++ b/tests/toolchain/abigen-pass/slug_name_builtin.cpp @@ -0,0 +1,42 @@ +// `sysio::slug_name` must reach the ABI as the BARE builtin spelling -- no struct_def, +// no typedef -- on both paths that can leak it: an action field type, and a kv table's +// key_types. +// +// It is a DERIVED STRUCT (`struct slug_name : basic_name`), the same shape +// as `sysio::name`, precisely so the builtin match applies to the written spelling. As an +// ALIAS it would not: `is_aliasing` returns true and abigen emits +// `types: [slug_name -> basic_name_slug_name_traits_]` plus that struct, and the host resolves +// typedefs BEFORE its builtin lookup, so every slug field would silently serialize as +// `{"value": N}` again -- no error anywhere. That is what this fixture pins: the expected ABI +// below must contain NO slug_name struct and NO typedef. +// +// Expected: action field `code` of type "slug_name"; table `codes` with +// key_types ["slug_name"]. +#include +#include +#include + +using namespace sysio; + +class [[sysio::contract("slug_name_builtin")]] slug_name_builtin : public contract { +public: + using contract::contract; + + struct code_key { + sysio::slug_name code; + SYSLIB_SERIALIZE(code_key, (code)) + }; + + struct [[sysio::table("codes")]] code_row { + uint64_t amount; + SYSLIB_SERIALIZE(code_row, (amount)) + }; + + using codes = kv::table<"codes"_n, code_key, code_row>; + + [[sysio::action]] + void reg(sysio::slug_name code, uint64_t amount) { + codes tbl(get_self()); + tbl.emplace(get_self(), {code}, {amount}); + } +}; diff --git a/tests/toolchain/abigen-pass/slug_name_builtin.json b/tests/toolchain/abigen-pass/slug_name_builtin.json new file mode 100644 index 000000000..2929dcf0b --- /dev/null +++ b/tests/toolchain/abigen-pass/slug_name_builtin.json @@ -0,0 +1 @@ +{ "tests": [{ "expected": { "abi-file": "slug_name_builtin.abi" } }] } diff --git a/tests/toolchain/abigen-pass/slug_name_direct_kv_key.abi b/tests/toolchain/abigen-pass/slug_name_direct_kv_key.abi new file mode 100644 index 000000000..c98b048dd --- /dev/null +++ b/tests/toolchain/abigen-pass/slug_name_direct_kv_key.abi @@ -0,0 +1,51 @@ +{ + "____comment": "This file was generated with sysio-abigen. DO NOT EDIT ", + "version": "sysio::abi/1.2", + "types": [], + "structs": [ + { + "name": "code_row", + "base": "", + "fields": [ + { + "name": "amount", + "type": "uint64" + } + ] + }, + { + "name": "reg", + "base": "", + "fields": [ + { + "name": "code", + "type": "slug_name" + }, + { + "name": "amount", + "type": "uint64" + } + ] + } + ], + "actions": [ + { + "name": "reg", + "type": "reg", + "ricardian_contract": "" + } + ], + "tables": [ + { + "name": "codes", + "type": "code_row", + "index_type": "i64", + "key_names": ["slug_name"], + "key_types": ["slug_name"], + "table_id": 15464 + } + ], + "ricardian_clauses": [], + "variants": [], + "action_results": [] +} \ No newline at end of file diff --git a/tests/toolchain/abigen-pass/slug_name_direct_kv_key.cpp b/tests/toolchain/abigen-pass/slug_name_direct_kv_key.cpp new file mode 100644 index 000000000..98bf76362 --- /dev/null +++ b/tests/toolchain/abigen-pass/slug_name_direct_kv_key.cpp @@ -0,0 +1,40 @@ +// A kv::table keyed DIRECTLY on a builtin must publish that builtin as its one key leaf. +// +// `slug_name_builtin` covers the WRAPPED form -- a `code_key` struct holding one slug -- which is +// the shape wire-sysio's registries use. That fixture never exercised the direct form, and the +// direct form was broken: key_names/key_types are derived by walking the key type's FIELDS, and a +// builtin key is a single packed scalar with none, so the ABI carried `key_names: []` and +// `key_types: []`. A host cannot decode a next_key or encode a bound from an empty shape, and +// nothing downstream can recover what the key was -- wire-sysio #619's slug key codec included. +// +// Distinct from `abigen-fail/kv_table_scalar_key`: that case keys on `uint64_t`, a NON-CLASS type +// with no CXXRecordDecl at all, and is still refused. `sysio::slug_name` is a class (a derived +// struct, the same shape as `sysio::name`), so it reaches the field walk legitimately and is +// described as the single leaf it is -- matching the one-leaf shape `kv::global` already emits. +// +// Expected: table `codes` with key_names ["slug_name"] and key_types ["slug_name"], and NO +// slug_name struct_def or typedef anywhere in the ABI. +#include +#include +#include + +using namespace sysio; + +class [[sysio::contract("slug_name_direct_kv_key")]] slug_name_direct_kv_key : public contract { +public: + using contract::contract; + + struct [[sysio::table("codes")]] code_row { + uint64_t amount; + SYSLIB_SERIALIZE(code_row, (amount)) + }; + + /// Keyed on the builtin itself, with no wrapper struct. + using codes = kv::table<"codes"_n, sysio::slug_name, code_row>; + + [[sysio::action]] + void reg(sysio::slug_name code, uint64_t amount) { + codes tbl(get_self()); + tbl.emplace(get_self(), code, {amount}); + } +}; diff --git a/tests/toolchain/abigen-pass/slug_name_direct_kv_key.json b/tests/toolchain/abigen-pass/slug_name_direct_kv_key.json new file mode 100644 index 000000000..236c07ea6 --- /dev/null +++ b/tests/toolchain/abigen-pass/slug_name_direct_kv_key.json @@ -0,0 +1 @@ +{ "tests": [{ "expected": { "abi-file": "slug_name_direct_kv_key.abi" } }] } diff --git a/tests/unit/CMakeLists.txt b/tests/unit/CMakeLists.txt index 685da1d80..872927097 100644 --- a/tests/unit/CMakeLists.txt +++ b/tests/unit/CMakeLists.txt @@ -25,6 +25,7 @@ add_cdt_unit_test(datastream_tests) add_cdt_unit_test(fixed_bytes_tests) add_cdt_unit_test(name_tests) add_cdt_unit_test(basic_name_tests) +add_cdt_unit_test(slug_name_tests) add_cdt_unit_test(rope_tests) add_cdt_unit_test(serialize_tests) add_cdt_unit_test(symbol_tests) diff --git a/tests/unit/basic_name_tests.cpp b/tests/unit/basic_name_tests.cpp index 0b713a1b8..cfecd5930 100644 --- a/tests/unit/basic_name_tests.cpp +++ b/tests/unit/basic_name_tests.cpp @@ -39,6 +39,8 @@ struct test_slug_traits { "slug: string is longer than 8 characters"; static constexpr const char* bad_final_symbol_message = "slug: final symbol does not fit its slot"; + static constexpr const char* not_normalized_message = + "slug: spelling is not properly normalized"; }; using test_slug = basic_name; @@ -59,6 +61,8 @@ struct test_slug_lsb_traits { "slug-lsb: string is longer than 8 characters"; static constexpr const char* bad_final_symbol_message = "slug-lsb: final symbol does not fit its slot"; + static constexpr const char* not_normalized_message = + "slug-lsb: spelling is not properly normalized"; }; using test_slug_lsb = basic_name; @@ -70,8 +74,35 @@ struct incomplete_traits { static constexpr const char* bad_char_message = "x"; static constexpr const char* too_long_message = "x"; static constexpr const char* bad_final_symbol_message = "x"; + static constexpr const char* not_normalized_message = "x"; }; +// An alphabet member that is only CONVERTIBLE to std::string_view: it has no +// find(), no size(), no operator[] of its own. basic_name_traits asks for +// exactly this much, so basic_name must bind a view before using it -- a direct +// member call on the traits' alphabet would not compile against this policy. +struct convertible_alphabet { + static constexpr char storage[] = ".12345abcdefghijklmnopqrstuvwxyz"; + constexpr operator std::string_view() const { + return { storage, sizeof(storage) - 1 }; + } +}; + +// Name-style (zero_terminates = false) so ONE instantiation reaches all three +// sites: the alphabet scan in validity_error, rule 6's trailing-pad test (which +// indexes alphabet[0]), and the symbol-width derivation (which takes size()). +struct convertible_alphabet_traits { + static constexpr int max_len = 13; + static constexpr convertible_alphabet alphabet{}; + static constexpr bool zero_terminates = false; + static constexpr basic_name_endianness packing = basic_name_endianness::MSB; + static constexpr const char* bad_char_message = "conv: character is not in the alphabet"; + static constexpr const char* too_long_message = "conv: string is too long"; + static constexpr const char* bad_final_symbol_message = "conv: final symbol does not fit its slot"; + static constexpr const char* not_normalized_message = "conv: spelling is not properly normalized"; +}; +using test_conv = basic_name; + } // namespace // basic_name_traits concept: real policies satisfy it, an incomplete one does not. @@ -83,6 +114,22 @@ SYSIO_TEST_BEGIN(basic_name_test_concept) CHECK_EQUAL( basic_name_traits, false ) SYSIO_TEST_END +// A policy whose alphabet is only CONVERTIBLE to string_view is usable. The +// concept promises no more than that, so the implementation must not demand +// more; this whole test is a compile-time assertion as much as a runtime one. +SYSIO_TEST_BEGIN(basic_name_test_convertible_alphabet) + static_assert( basic_name_traits ); + // Round trip: the alphabet scan and the symbol-width derivation both ran. + CHECK_EQUAL( test_conv{"sysio"}.to_string(), "sysio" ) + CHECK_EQUAL( test_conv{"a.b"}.to_string(), "a.b" ) + // Rule 6 (!zero_terminates): a trailing pad is not normalized. This is the + // check that indexes alphabet[0]. + CHECK_ASSERT( "conv: spelling is not properly normalized", + ([]() { test_conv{"a."}; }) ) + CHECK_ASSERT( "conv: character is not in the alphabet", + ([]() { test_conv{"A"}; }) ) +SYSIO_TEST_END + // A slug round-trips: string -> packed uint64 -> string. SYSIO_TEST_BEGIN(basic_name_test_slug_roundtrip) CHECK_EQUAL( test_slug{""}.to_string(), "" ) @@ -238,6 +285,7 @@ int main(int argc, char* argv[]) { silence_output(!verbose); SYSIO_TEST(basic_name_test_concept) + SYSIO_TEST(basic_name_test_convertible_alphabet) SYSIO_TEST(basic_name_test_slug_roundtrip) SYSIO_TEST(basic_name_test_slug_zero_terminates) SYSIO_TEST(basic_name_test_slug_compare) diff --git a/tests/unit/name_tests.cpp b/tests/unit/name_tests.cpp index 0e4a9a2d7..5b9266195 100644 --- a/tests/unit/name_tests.cpp +++ b/tests/unit/name_tests.cpp @@ -49,9 +49,9 @@ SYSIO_TEST_BEGIN(name_type_test_ctr_str_lit) CHECK_EQUAL( name{".abc"}.value, 112167778219196416ULL ) CHECK_EQUAL( name{".........abc"}.value, 102016ULL ) - CHECK_EQUAL( name{"123."}.value, 614178399182651392ULL ) - CHECK_EQUAL( name{"123........."}.value, 614178399182651392ULL ) - CHECK_EQUAL( name{".a.b.c.1.2.3."}.value, 108209673814966320ULL ) + CHECK_EQUAL( name{name::pack("123.")}.value, 614178399182651392ULL ) + CHECK_EQUAL( name{name::pack("123.........")}.value, 614178399182651392ULL ) + CHECK_EQUAL( name{name::pack(".a.b.c.1.2.3.")}.value, 108209673814966320ULL ) CHECK_EQUAL( name{"abc.123"}.value, 3589369488740450304ULL ) CHECK_EQUAL( name{"123.abc"}.value, 614181822271586304ULL ) @@ -167,7 +167,7 @@ SYSIO_TEST_BEGIN(name_type_test_prefix) CHECK_EQUAL( name{"sysioacco.unj"}.prefix(), name{"sysioacco"} ) CHECK_EQUAL( name{"sysioaccou.nj"}.prefix(), name{"sysioaccou"} ) CHECK_EQUAL( name{"sysioaccoun.j"}.prefix(), name{"sysioaccoun"} ) - CHECK_EQUAL( name{"sysioaccounj."}.prefix(), name{"sysioaccounj"} ) + CHECK_EQUAL( name{name::pack("sysioaccounj.")}.prefix(), name{"sysioaccounj"} ) CHECK_EQUAL( name{"sysioaccountj"}.prefix(), name{"sysioaccountj"} ) CHECK_EQUAL( name{"e.o.s.i.o.a.c"}.prefix(), name{"e.o.s.i.o.a"} ) @@ -192,9 +192,9 @@ SYSIO_TEST_BEGIN(name_type_test_raw) CHECK_EQUAL( name{".abc"}.operator name::raw(), static_cast(112167778219196416ULL) ) CHECK_EQUAL( name{".........abc"}.operator name::raw(), static_cast(102016ULL) ) - CHECK_EQUAL( name{"123."}.operator name::raw(), static_cast(614178399182651392ULL) ) - CHECK_EQUAL( name{"123........."}.operator name::raw(), static_cast(614178399182651392ULL) ) - CHECK_EQUAL( name{".a.b.c.1.2.3."}.operator name::raw(), static_cast(108209673814966320ULL) ) + CHECK_EQUAL( name{name::pack("123.")}.operator name::raw(), static_cast(614178399182651392ULL) ) + CHECK_EQUAL( name{name::pack("123.........")}.operator name::raw(), static_cast(614178399182651392ULL) ) + CHECK_EQUAL( name{name::pack(".a.b.c.1.2.3.")}.operator name::raw(), static_cast(108209673814966320ULL) ) CHECK_EQUAL( name{"abc.123"}.operator name::raw(), static_cast(3589369488740450304ULL) ) CHECK_EQUAL( name{"123.abc"}.operator name::raw(), static_cast(614181822271586304ULL) ) @@ -255,11 +255,11 @@ SYSIO_TEST_BEGIN(name_type_test_memcmp) CHECK_EQUAL( memcmp(str.c_str(), buffer, strlen(str.c_str())), 0 ) name{str = ".........abc"}.write_as_string( buffer, buffer + sizeof(buffer) ); CHECK_EQUAL( memcmp(str.c_str(), buffer, strlen(str.c_str())), 0 ) - name{str = "123."}.write_as_string( buffer, buffer + sizeof(buffer) ); + name{name::pack(str = "123.")}.write_as_string( buffer, buffer + sizeof(buffer) ); CHECK_EQUAL( memcmp("123", buffer, 3), 0 ) - name{str = "123........."}.write_as_string( buffer, buffer + sizeof(buffer) ); + name{name::pack(str = "123.........")}.write_as_string( buffer, buffer + sizeof(buffer) ); CHECK_EQUAL( memcmp("123", buffer, 3), 0 ) - name{str = ".a.b.c.1.2.3."}.write_as_string( buffer, buffer + sizeof(buffer) ); + name{name::pack(str = ".a.b.c.1.2.3.")}.write_as_string( buffer, buffer + sizeof(buffer) ); CHECK_EQUAL( memcmp(".a.b.c.1.2.3", buffer, 12), 0 ) name{str = "abc.123"}.write_as_string( buffer, buffer + sizeof(buffer) ); @@ -298,9 +298,9 @@ SYSIO_TEST_BEGIN(name_type_test_to_str) CHECK_EQUAL( name{".abc"}.to_string(), ".abc" ) CHECK_EQUAL( name{".........abc"}.to_string(), ".........abc" ) - CHECK_EQUAL( name{"123."}.to_string(), "123" ) - CHECK_EQUAL( name{"123........."}.to_string(), "123" ) - CHECK_EQUAL( name{".a.b.c.1.2.3."}.to_string(), ".a.b.c.1.2.3" ) + CHECK_EQUAL( name{name::pack("123.")}.to_string(), "123" ) + CHECK_EQUAL( name{name::pack("123.........")}.to_string(), "123" ) + CHECK_EQUAL( name{name::pack(".a.b.c.1.2.3.")}.to_string(), ".a.b.c.1.2.3" ) CHECK_EQUAL( name{"abc.123"}.to_string(), "abc.123" ) CHECK_EQUAL( name{"123.abc"}.to_string(), "123.abc" ) @@ -329,9 +329,9 @@ SYSIO_TEST_BEGIN(name_type_test_equal) CHECK_EQUAL( name{".abc"} == name{".abc"}, true ) CHECK_EQUAL( name{".........abc"} == name{".........abc"}, true ) - CHECK_EQUAL( name{"123."} == name{"123"}, true ) - CHECK_EQUAL( name{"123........."} == name{"123"}, true ) - CHECK_EQUAL( name{".a.b.c.1.2.3."} == name{".a.b.c.1.2.3"}, true ) + CHECK_EQUAL( name{name::pack("123.")} == name{"123"}, true ) + CHECK_EQUAL( name{name::pack("123.........")} == name{"123"}, true ) + CHECK_EQUAL( name{name::pack(".a.b.c.1.2.3.")} == name{".a.b.c.1.2.3"}, true ) CHECK_EQUAL( name{"abc.123"} == name{"abc.123"}, true ) CHECK_EQUAL( name{"123.abc"} == name{"123.abc"}, true ) @@ -362,9 +362,9 @@ SYSIO_TEST_BEGIN(name_type_test_not_equal) CHECK_EQUAL( name{".abc"} != name{}, true ) CHECK_EQUAL( name{".........abc"} != name{}, true ) - CHECK_EQUAL( name{"123."} != name{}, true ) - CHECK_EQUAL( name{"123........."} != name{}, true ) - CHECK_EQUAL( name{".a.b.c.1.2.3."} != name{}, true ) + CHECK_EQUAL( name{name::pack("123.")} != name{}, true ) + CHECK_EQUAL( name{name::pack("123.........")} != name{}, true ) + CHECK_EQUAL( name{name::pack(".a.b.c.1.2.3.")} != name{}, true ) CHECK_EQUAL( name{"abc.123"} != name{}, true ) CHECK_EQUAL( name{"123.abc"} != name{}, true ) @@ -395,9 +395,9 @@ SYSIO_TEST_BEGIN(name_type_test_less_than) CHECK_EQUAL( name{} < name{".abc"}, true ) CHECK_EQUAL( name{} < name{".........abc"}, true ) - CHECK_EQUAL( name{} < name{"123."}, true ) - CHECK_EQUAL( name{} < name{"123........."}, true ) - CHECK_EQUAL( name{} < name{".a.b.c.1.2.3."}, true ) + CHECK_EQUAL( name{} < name{name::pack("123.")}, true ) + CHECK_EQUAL( name{} < name{name::pack("123.........")}, true ) + CHECK_EQUAL( name{} < name{name::pack(".a.b.c.1.2.3.")}, true ) CHECK_EQUAL( name{} < name{"abc.123"}, true ) CHECK_EQUAL( name{} < name{"123.abc"}, true ) @@ -431,9 +431,14 @@ SYSIO_TEST_BEGIN(name_type_test_op_n) CHECK_EQUAL( name{".abc"}, ".abc"_n ) CHECK_EQUAL( name{".........abc"}, ".........abc"_n ) - CHECK_EQUAL( name{"123."}, "123."_n ) - CHECK_EQUAL( name{"123........."}, "123........."_n ) - CHECK_EQUAL( name{".a.b.c.1.2.3."}, ".a.b.c.1.2.3."_n ) + // A TRAILING pad is a legal character in an illegal position: to_string() + // strips it, so the spelling does not round-trip and both the literal and the + // runtime constructor now reject it. Interior pads stay legal (".abc" above). + CHECK_ASSERT( "name is not properly normalized", []() { name{"123."}; } ) + CHECK_ASSERT( "name is not properly normalized", []() { name{"123........."}; } ) + CHECK_ASSERT( "name is not properly normalized", []() { name{".a.b.c.1.2.3."}; } ) + // ...but they still PACK the way they always did - the encoding is unchanged. + CHECK_EQUAL( name{name::pack("123.")}, "123"_n ) CHECK_EQUAL( name{"abc.123"}, "abc.123"_n ) CHECK_EQUAL( name{"123.abc"}, "123.abc"_n ) diff --git a/tests/unit/slug_name_tests.cpp b/tests/unit/slug_name_tests.cpp new file mode 100644 index 000000000..01f54fc0d --- /dev/null +++ b/tests/unit/slug_name_tests.cpp @@ -0,0 +1,169 @@ +/** + * @file + * @copyright defined in sysio.cdt/LICENSE.txt + * + * Unit tests for sysio::slug_name — the packed registry-code identifier, an instantiation of sysio::basic_name over + * the [A-Z0-9_] alphabet. + * + * basic_name's generic behaviour (both packing directions, the traits concept) is covered by basic_name_tests.cpp. + * What is pinned HERE is the slug policy itself, and above all its BYTE IDENTITY with the host-side fc::slug_name: + * the two are separate instantiations in separate toolchains, agreeing only because their traits agree. The expected + * values below are what fc produces. If either side's alphabet, length, terminator rule or packing direction drifts, + * these fail — which is the only mechanical guard the cross-language encoding has. + */ + +#include +#include +#include + +#include +#include +#include +#include + +using sysio::slug_name; + +// ── the traits policy, pinned ─────────────────────────────────────────────── +static_assert(sysio::slug_name_traits::max_len == 8, + "8 symbols x 6 bits = 48, keeping every value under JS Number's 2^53 limit"); +static_assert(sysio::slug_name_traits::zero_terminates, + "a symbol-0 slot terminates a slug, unlike name's '.'"); +static_assert(sysio::slug_name_traits::packing == sysio::basic_name_endianness::MSB, + "MSB-first is what gives a shared prefix a shared leading bit prefix"); +static_assert(sysio::slug_name_traits::alphabet.size() == 38, + "'\\0' pad + A-Z + 0-9 + '_'"); + +SYSIO_TEST_BEGIN(slug_name_byte_identity_with_the_host) + // These constants are fc::slug_name's output. Do not "fix" a failure by editing them — a mismatch means the two + // traits have diverged. + CHECK_EQUAL(slug_name{"A"}.value, 4398046511104ull) + CHECK_EQUAL(slug_name{"ETH"}.value, 23373212024832ull) + CHECK_EQUAL(slug_name{"WIRE"}.value, 101792956284928ull) + CHECK_EQUAL(slug_name{"SOLANA"}.value, 84606581215232ull) + CHECK_EQUAL(slug_name{"LIQSOL"}.value, 53413609783296ull) + CHECK_EQUAL(slug_name{"Z1234567"}.value, 116305004726370ull) + CHECK_EQUAL(slug_name{"Z_______"}.value, 116932188985701ull) +SYSIO_TEST_END + +SYSIO_TEST_BEGIN(slug_name_round_trips_canonical_spellings) + for (const char* s : {"A", "ETH", "WIRE", "SOLANA", "LIQSOL", "Z1234567", "Z_______"}) { + CHECK_EQUAL(slug_name{std::string_view{s}}.to_string(), std::string{s}) + } + CHECK_EQUAL(slug_name{}.to_string(), std::string{}) + CHECK_EQUAL(slug_name{std::string_view{""}}.value, 0ull) +SYSIO_TEST_END + +SYSIO_TEST_BEGIN(slug_name_must_start_with_a_letter) + // The rule that makes the host's string carrier unambiguous: no legal code + // can be spelled like a number. Byte-identical with the host-side rule in + // fc::slug_name_traits::leading_alphabet — keep the two diffable. + CHECK_ASSERT("slug_name must start with a letter ([A-Z])", + []() { slug_name{std::string_view{"7"}}; }) + CHECK_ASSERT("slug_name must start with a letter ([A-Z])", + []() { slug_name{std::string_view{"0"}}; }) + CHECK_ASSERT("slug_name must start with a letter ([A-Z])", + []() { slug_name{std::string_view{"1E3"}}; }) + CHECK_ASSERT("slug_name must start with a letter ([A-Z])", + []() { slug_name{std::string_view{"12345678"}}; }) + CHECK_ASSERT("slug_name must start with a letter ([A-Z])", + []() { slug_name{std::string_view{"_"}}; }) + // Digits and '_' stay legal after the first symbol, and "" is the sentinel. + CHECK_EQUAL(slug_name{std::string_view{"V1"}}.to_string(), std::string{"V1"}) + CHECK_EQUAL(slug_name{std::string_view{"TRAIL_"}}.to_string(), std::string{"TRAIL_"}) + CHECK_EQUAL(slug_name{std::string_view{""}}.value, 0ull) +SYSIO_TEST_END + +SYSIO_TEST_BEGIN(slug_name_literals_are_compile_time) + static_assert("ETH"_s.value == 23373212024832ull); + static_assert("LIQSOL"_s.value == 53413609783296ull); + static_assert("ETH"_s == slug_name{23373212024832ull}); + CHECK_EQUAL("WIRE"_s.value, 101792956284928ull) +SYSIO_TEST_END + +SYSIO_TEST_BEGIN(slug_name_canonical_values_are_at_or_above_the_2_42_floor) + // char[0] sits at bits [42..47], so any canonical (non-empty) slug is >= 1<<42 — and conversely every value below + // the floor has a zero in the char[0] slot and so decodes to the empty string. This is why the host's JSON carrier + // cannot be string-only. + constexpr uint64_t floor = 1ull << 42; + CHECK_EQUAL(slug_name{"A"}.value >= floor, true) + CHECK_EQUAL(slug_name{"ETH"}.value >= floor, true) + CHECK_EQUAL(slug_name{uint64_t{7}}.to_string(), std::string{}) + CHECK_EQUAL(slug_name{floor - 1}.to_string(), std::string{}) +SYSIO_TEST_END + +SYSIO_TEST_BEGIN(slug_name_groups_shared_prefixes_in_the_high_bits) + // The grouping property slug_name exists for: a shared textual prefix is a shared leading BIT prefix, so + // prefix-related codes are contiguous in key order. k symbols share the top 6k bits of the 48-bit payload. + auto shared_high_bits = [](slug_name a, slug_name b) { + int n = 0; + for (int bit = 47; bit >= 0; --bit) { + if (((a.value >> bit) & 1ull) != ((b.value >> bit) & 1ull)) break; + ++n; + } + return n; + }; + CHECK_EQUAL(shared_high_bits("LIQSOL"_s, "LIQETH"_s) >= 18, true) // "LIQ" = 3 x 6 + CHECK_EQUAL(shared_high_bits("WIRE"_s, "WIREUSD"_s) >= 24, true) // "WIRE" = 4 x 6 + // And a differing first symbol shares nothing in the top 6 bits. + CHECK_EQUAL(shared_high_bits("ETH"_s, "WIRE"_s) < 6, true) +SYSIO_TEST_END + +SYSIO_TEST_BEGIN(slug_name_to_key_writes_eight_bytes) + // Guards CDT_REFLECT(value) on basic_name. to_key's generic arm dispatches floating-point / integral / enum and + // otherwise REFLECTS (key_utils.hpp:302-325) -- it never consults operator<<, so SYSLIB_SERIALIZE does not help + // it. An unreflected basic_name yields field_count 0 and writes a ZERO-BYTE key, silently. Every other case in + // this file exercises the TYPE (packing, literals, the 2^42 floor, prefix grouping) and would still pass with the + // reflection deleted; this one would not. + // + // to_key has no callers today -- key_utils.hpp is included by nothing, and both kv::table and multi_index encode + // through kv_utils.hpp's be_key_stream -- so the reflection is defensive. This test is what keeps it correct for + // the day something does reach it. + sysio::slug_name code{"LIQSOL"}; + char buf[16] = {}; + sysio::datastream ds(buf, sizeof(buf)); + sysio::to_key(code, ds); + CHECK_EQUAL( ds.tellp(), 8 ) + + // Big-endian, so byte order matches value order -- the property prefix grouping depends on. + uint64_t be = 0; + for (int i = 0; i < 8; ++i) be = (be << 8) | static_cast(buf[i]); + CHECK_EQUAL( be, code.value ) +SYSIO_TEST_END + +SYSIO_TEST_BEGIN(slug_name_datastream_round_trip) + // A DERIVED basic_name needs its OWN exact-match operator<>. The base's hidden friend takes + // `const basic_name&`, so reaching it from a slug_name requires a derived-to-base conversion -- + // and the generic class-template overload, matching exactly, wins instead. That overload hands + // the type to the bluegrass::meta field iterator, which refuses anything with a user-declared + // constructor ("Types with user specified constructors are not supported"). Clang tolerates it; + // GCC 13 does not, and add_native_contract() builds with the HOST compiler -- so a GCC-native + // contract taking a slug_name action parameter would not compile. SYSLIB_SERIALIZE_DERIVED_EMPTY + // supplies the exact match; this test is what keeps it present. + // + // The bytes must also stay identical to the base's, since the host decodes them as one uint64. + const sysio::slug_name code{"LIQSOL"}; + const std::vector packed = sysio::pack(code); + CHECK_EQUAL( packed.size(), 8 ) + CHECK_EQUAL( packed, sysio::pack(code.value) ) + + const auto decoded = sysio::unpack(packed.data(), packed.size()); + CHECK_EQUAL( decoded.value, code.value ) + CHECK_EQUAL( decoded.to_string(), std::string{"LIQSOL"} ) + + // The zero sentinel round-trips too -- it is a legal, renderable value (""). + const sysio::slug_name zero{}; + const std::vector zero_packed = sysio::pack(zero); + CHECK_EQUAL( sysio::unpack(zero_packed.data(), zero_packed.size()).value, 0ull ) +SYSIO_TEST_END + +int main(int argc, char* argv[]) { + SYSIO_TEST(slug_name_byte_identity_with_the_host) + SYSIO_TEST(slug_name_round_trips_canonical_spellings) + SYSIO_TEST(slug_name_must_start_with_a_letter) + SYSIO_TEST(slug_name_literals_are_compile_time) + SYSIO_TEST(slug_name_canonical_values_are_at_or_above_the_2_42_floor) + SYSIO_TEST(slug_name_groups_shared_prefixes_in_the_high_bits) + SYSIO_TEST(slug_name_to_key_writes_eight_bytes) + SYSIO_TEST(slug_name_datastream_round_trip) + return has_failed(); +}