morph 0.1.0
Asynchronous bridge between GUI thread and business models
Loading...
Searching...
No Matches
forms.hpp
Go to the documentation of this file.
1// SPDX-License-Identifier: Apache-2.0
2
3#pragma once
4
5/// @file forms/forms.hpp
6/// @brief JSON-Forms-style schema generation for morph actions.
7///
8/// Given an action type `A` (a plain aggregate, as registered with
9/// `BRIDGE_REGISTER_ACTION`), this header produces a standard JSON Schema a
10/// client can render a form from at runtime — the "describe" half of an
11/// auto-generated GUI. It builds on glaze's `write_json_schema` (which
12/// already contributes types, `$defs`, per-field metadata declared via
13/// `glz::json_schema<A>`, and the `ExtUnits` stamped by
14/// `morph::units::Quantity`) and closes the gaps glaze leaves open. The list
15/// below is the main surface, not an exhaustive enumeration of every key
16/// emitted:
17///
18/// - **`required`** — under the options `schemaJson<A>()` uses, glaze derives no
19/// `required` entries from member types: `glz::requires_key` only returns
20/// `true` for a member when `meta<T>::requires_key` says so or when
21/// `Opts.error_on_missing_keys` is set, and morph sets neither. (A type
22/// declaring `meta<V>::required`, and a tagged variant's discriminator, do
23/// still get one.)
24/// `schemaJson<A>()` always writes its own, overwriting whatever the schema
25/// writer did or did not produce: a member is *required* unless it is a
26/// `std::optional<...>` or its name is listed in the action's opt-out list
27/// (see below).
28/// - **`x-decimalPlaces`** — for `Quantity` members, the field's *declared*
29/// precision (`Quantity<U, Dec>::declaredDecimals`, which defaults to the
30/// unit's `UnitTraits` default but is overridable per member), so a client
31/// knows the input step without hardcoding unit knowledge.
32/// - **`x-order`** — the member's declaration index on every property, so a
33/// renderer can lay fields out in declaration order (JSON object key order
34/// is not reliable once schemas pass through DOMs/maps).
35/// - **`x-unitAlternatives`** — for `Quantity` members whose unit system
36/// declares convertible units (`UnitTraits<E>::relations`): an array of
37/// `{id, display, decimals, num, den}` entries, where `num/den` is the
38/// exact alternative-to-canonical ratio. Renderers offer a unit selector
39/// and recalculate entered values exactly on switch; payloads always carry
40/// the canonical unit.
41/// - **`x-optionsAction` / `x-optionValue` / `x-optionLabel`** — for
42/// `morph::forms::Choice` members: which registered action serves the
43/// options, and which result-row fields carry the submitted value and the
44/// display label. Renderers turn these into combo boxes populated by
45/// executing the named action.
46/// - **`x-optionsDependsOn`** — for a `Choice` whose options are
47/// parameterised by sibling field values (`Choice`'s `DependsOn` pack): the
48/// wire names of those sibling fields. Renderers send `{name: value, ...}`
49/// as the options-action request body instead of an empty one, and
50/// re-fetch when a named field changes. Omitted when the `Choice` declares
51/// no dependency.
52/// - **`x-layout` / `x-group` / `x-section` / `x-colspan`** — for actions
53/// declaring a `static constexpr` `formLayout` and/or `fieldSpans`
54/// (`morph::forms::FieldGroup` / `FieldSpan`, `forms/layout.hpp`): visual
55/// structure (sections, tabs, an accordion) over the flat field list, and
56/// per-field grid column spans. Absent either declaration, none of these
57/// keys are emitted and a renderer lays fields out exactly as it does
58/// today.
59/// - **`x-widget`** — for a field whose type declares a `noexcept static
60/// constexpr widget()` (`Multiline`, `Ranged` — widget_hints.hpp), or any
61/// field named in a `fieldMetadata`-shaped override: the renderer's
62/// preferred control id (`"textarea"`, `"slider"`, `"radio"`, …).
63/// - **`x-min` / `x-max` / `x-step`** — for a field whose type additionally
64/// declares `min()`/`max()`/`step()` (the `Ranged` shape): the slider's
65/// control-track bounds and increment — advisory, not a validation bound.
66/// - **`x-rules`** — for an action declaring a `static constexpr formRules`
67/// (`morph::forms::ruleList(...)`): a closed, typed cross-field rule
68/// vocabulary (`requiredWhen`, comparisons, membership, presentation, and
69/// the compound `andOf`/`orOf`/`notOf` conditions that nest a condition
70/// tree to any depth) evaluated identically by the schema, the client, and
71/// the server. See `morph::forms::allRulesSatisfied` below and
72/// docs/spec/forms/forms.md.
73/// - **`minimum` / `maximum` / `multipleOf`** — for a field whose
74/// `fieldMetadata` entry declares them (`morph::forms::FieldMeta`): a
75/// per-field scalar bound, stamped on the property node as the standard
76/// JSON-Schema keys of those names. `multipleOf: 1` is how "whole number"
77/// is spelled. `morph::forms::allFieldBoundsSatisfied` evaluates the same
78/// declaration in C++, so an action's `validate()` enforces exactly what
79/// the client was served. Bounds are per *field*, unlike
80/// `UnitTraits::bounds`, which is per unit.
81/// - **`ExtUnits` / `x-displayDecimals`** — for a *non-`Quantity`* field whose
82/// `FieldMeta` declares `unit` / `decimals`: the display unit (in the same
83/// `ExtUnits` shape a `Quantity` carries) and the fraction digits a renderer
84/// shows and accepts. Presentation only — the member keeps its plain JSON
85/// encoding.
86/// - **`x-computed` / `x-readonly`** — for a member listed as the destination
87/// of an action's `computedFields` declaration: the field is derived from
88/// sibling inputs (named in `x-computed.inputs`) and must not be rendered as
89/// an editable control (`x-readonly: true`). See `morph::forms::computed`,
90/// `morph::forms::computeList`, and `morph::forms::recomputeAll`.
91///
92/// `morph::time::Timestamp` members need no extension keys: their schema
93/// carries the standard `"format": "date-time"` annotation.
94///
95/// **Nested aggregates (recursive, cycle-safe).** A member whose type is
96/// itself a reflectable aggregate — a plain nested struct, or
97/// `std::vector<Sub>` — gets its own members annotated too: `x-order`,
98/// title/`FieldMeta`, `required`, and the `Quantity`/`Choice`/widget/
99/// ranged-bounds rules above, applied against the nested type's own
100/// reflection. Unlike the top level, this recurses into the type graph, to
101/// whatever depth it has: there is no depth limit, and a self- or
102/// mutually-referential type is described rather than rejected — glaze emits
103/// a finite `$ref`-cyclic schema for it, and the runtime `$defs` visited set
104/// walks that once. Computed fields/`formLayout`/`fieldSpans`/`formRules`
105/// remain top-level-only regardless of depth. See docs/spec/forms/forms.md,
106/// "Nested aggregates (recursive, cycle-safe)", and
107/// `detail::annotateNestedAggregateRef`.
108///
109/// @par Declaring optional fields
110/// Required is the default. An action opts individual fields out with a
111/// static member list:
112/// @code{.cpp}
113/// struct RecordMeasurement {
114/// std::int64_t sampleId = 0;
115/// Density density{};
116/// Moisture moisture{}; // may stay empty
117///
118/// static constexpr std::array optionalFields{std::string_view{"moisture"}};
119/// [[nodiscard]] bool validate() const { return morph::forms::allRequiredEngaged(*this); }
120/// };
121/// @endcode
122///
123/// @par Declaring computed fields
124/// A derived, read-only field is declared with a `static constexpr` map from a
125/// destination member to its declared input members and a pure derivation:
126/// @code{.cpp}
127/// struct LineItem {
128/// Quantity<Units, 2> qty;
129/// Quantity<Units, 2> price;
130/// Quantity<Units, 2> total; // computed -- not user-entered
131///
132/// // A generic (auto) lambda parameter: this initializer runs while
133/// // LineItem is still an incomplete type, so the body's member access
134/// // must stay dependent until first use, after the class is complete.
135/// static constexpr auto computedFields = morph::forms::computeList(
136/// morph::forms::computed<&LineItem::total, &LineItem::qty, &LineItem::price>(
137/// [](const auto& s) { return s.qty * s.price; }));
138/// };
139/// @endcode
140/// `schemaJson<A>()` then emits `x-computed`/`x-readonly` on `total` and
141/// excludes it from `required`; `recomputeAll<A>(action)` is the single
142/// evaluator the reactive `set<>` path and every dispatch path call to
143/// overwrite it authoritatively -- see `bridge.md`/`registry.md`.
144///
145/// @par Readiness helper
146/// `allRequiredEngaged(action)` returns `true` when every *required*
147/// empty-capable member (`Quantity`, `Choice`, `Timestamp` — anything with a
148/// `hasValue()`) is engaged. Wire it up as the action's `validate()`
149/// (the existing `ActionValidator` machinery picks that method up
150/// automatically) so the same declaration drives the schema's `required`
151/// array, the client-side submit gate, and the fielded-action readiness
152/// check. Non-quantity members are not checked — a plain `int64_t` cannot
153/// express "not filled in"; use a `Quantity` (or a custom `validate()`) when
154/// that distinction matters. That exclusion covers `std::string` too: a
155/// `std::string` member exposes `empty()`, not `hasValue()`, so a "this text
156/// field must be non-empty" rule is a custom `validate()` body, never
157/// something this helper reports on.
158///
159/// Its walk is **flat**, unlike the schema generation described above: it
160/// inspects the action's own top-level members and does not descend into a
161/// nested aggregate, a `std::vector` of one, or a `std::optional` holding
162/// one. A nested type's own required fields are therefore *not* gated by an
163/// outer `allRequiredEngaged` — delegating to the nested value's `validate()`
164/// is the caller's to write.
165
166#include <algorithm>
167#include <array>
168#include <cctype>
169#include <compare>
170#include <concepts>
171#include <cstddef>
172#include <cstdint>
173#include <glaze/glaze.hpp>
174#include <limits>
175#include <memory>
178#include <optional>
179#include <stdexcept>
180#include <string>
181#include <string_view>
182#include <tuple>
183#include <type_traits>
184#include <unordered_map>
185#include <unordered_set>
186#include <utility>
187#include <vector>
188
189#include "../util/quantity.hpp"
190#include "../util/rational.hpp"
191#include "blank_as.hpp"
192#include "choice.hpp"
193#include "layout.hpp"
194
195namespace morph::forms {
196
197/// @brief Per-field presentation overrides and scalar bounds: label, help,
198/// placeholder, read-only, hidden, `minimum`/`maximum`/`multipleOf`,
199/// and a plain member's display `unit`/`decimals`
200/// (docs/spec/forms/forms.md, "Field metadata").
201///
202/// An action opts in with a `static constexpr std::array<FieldMeta, N>`
203/// (or, for the `describe<>()` sugar, a `static const` array defined
204/// out-of-line — see `describe()`'s documentation) named `fieldMetadata`,
205/// mirroring the existing `optionalFields` convention. Every member other
206/// than `field` defaults to "not declared": an empty `label`/`help`/
207/// `placeholder` means "infer the title, omit the rest"; `readOnly`/`hidden`
208/// default to `false`; a disengaged `minimum`/`maximum`/`multipleOf` emits
209/// nothing and checks nothing; an empty `unit` and a disengaged `decimals`
210/// emit nothing. `mergeSchemaExtras` looks up the entry (if any)
211/// matching each reflected member by wire key and patches the property node;
212/// an entry naming a field that does not exist on the action is ignored.
213///
214/// @par Bounds are per **field**, and are not presentation
215/// The three numeric members are the one part of this type that is *not*
216/// presentation: `morph::forms::allFieldBoundsSatisfied` evaluates them as a
217/// C++ predicate, so an action whose `validate()` calls it enforces the same
218/// declaration the schema advertises — the "the DTO *is* the form definition"
219/// rule (`examples/IMPLEMENTATION.md` rule 3) applied to a scalar bound, which
220/// the `formRules` vocabulary cannot express because every comparison node
221/// there takes two member pointers, never a literal. They are
222/// keyed by field rather than by unit precisely so a floor declared for one
223/// `Quantity` member does not constrain a sibling of the same type —
224/// `UnitTraits::bounds` is per-unit and cannot make that distinction.
225struct FieldMeta {
226 /// @brief Wire key of the member this entry describes.
227 std::string_view field;
228 /// @brief Display label; empty infers a title-cased name from `field`.
229 std::string_view label{};
230 /// @brief Help text; empty omits `description`.
231 std::string_view help{};
232 /// @brief In-control placeholder hint; empty omits `x-placeholder`.
233 std::string_view placeholder{};
234 /// @brief Explicit message-key **stem** override for this field's i18n
235 /// catalog keys; empty means "derive the stem from the action's
236 /// `ActionTraits<A>::typeId()` and this field's wire key" (see
237 /// `morph::forms::i18n::fieldKey`, `forms/i18n.hpp`). A non-empty
238 /// stem replaces only the `<actionTypeId>.<wireField>` portion of
239 /// the key — the per-slot `.label` / `.help` / `.placeholder`
240 /// suffix is still appended on top of it (see
241 /// `morph::forms::i18n::explicitFieldKey`), so `"myKey"` expands
242 /// to `"myKey.label"` / `"myKey.help"` / `"myKey.placeholder"`,
243 /// never to a single complete key on its own. Emitted as
244 /// `x-i18nKey` only when non-empty.
245 std::string_view i18nKey{};
246 /// @brief Widget-selection override; empty means "use the field type's
247 /// own `widget()`, if any". A non-empty value replaces that
248 /// type-derived default and is emitted as `x-widget`
249 /// (docs/spec/forms/widget_hints.md). Read structurally by
250 /// `detail::widgetOverride`, not through this type by name — see
251 /// `detail::HasFieldMetadataWidgets`.
252 std::string_view widget{};
253 /// @brief Displayed but not editable when `true`; emits `x-readonly`.
254 bool readOnly{false};
255 /// @brief Not shown at all when `true`; emits `x-hidden`. The field still
256 /// travels in the payload (see `docs/spec/forms/forms.md`,
257 /// "Field metadata is not a security control").
258 bool hidden{false};
259
260 /// @brief Inclusive lower bound on the field's numeric value; emitted as
261 /// the standard JSON-Schema `minimum`. Disengaged means "no floor".
262 ///
263 /// For a `Quantity` member this bounds the **scalar value the field
264 /// denotes**, in the canonical unit — not the `{num,den,dp}` object the
265 /// member serialises as.
266 std::optional<::morph::math::Rational> minimum{};
267
268 /// @brief Inclusive upper bound on the field's numeric value; emitted as
269 /// the standard JSON-Schema `maximum`. Disengaged means "no
270 /// ceiling". Same canonical-unit reading as `minimum`.
271 std::optional<::morph::math::Rational> maximum{};
272
273 /// @brief The field's value must be an exact integer multiple of this;
274 /// emitted as the standard JSON-Schema `multipleOf`. Disengaged
275 /// means "any value".
276 ///
277 /// `multipleOf = 1` is how integrality is spelled — the constraint
278 /// `Quantity` cannot carry in its type, since `Quantity<U, Dec>` requires
279 /// `Dec >= 1` and therefore always represents tenths exactly.
280 ///
281 /// A non-positive value is **ignored** (neither emitted nor checked),
282 /// matching JSON Schema, which requires `multipleOf` to be strictly
283 /// positive: a zero divisor has no meaning and a negative one divides
284 /// exactly the same set of values as its magnitude.
285 std::optional<::morph::math::Rational> multipleOf{};
286
287 /// @brief Display unit for a member whose C++ type carries none (a plain
288 /// `double`, `float` or integral member); emitted as the property's
289 /// `ExtUnits` (`{"unitAscii": unit, "unitUnicode": unit}`), the key
290 /// a `Quantity` already carries. Empty emits nothing.
291 ///
292 /// Presentation only: the unit never travels in the payload and nothing
293 /// converts through it. **Ignored on a `Quantity` member**, whose unit is
294 /// part of its type and already emitted.
295 // Spelled like every sibling's default, which a consumer's
296 // -Wmissing-field-initializers reads as "has a default".
297 // NOLINTNEXTLINE(readability-redundant-member-init)
298 std::string_view unit{};
299
300 /// @brief Display and entry precision: for a plain `double`/`float` member
301 /// emitted as `x-displayDecimals`, for a bare `math::Rational` member
302 /// as `x-decimalPlaces`. Disengaged emits nothing.
303 ///
304 /// A `double` gets `x-displayDecimals`, not `x-decimalPlaces`: the latter
305 /// hands a property the exact `{num,den,dp}` encoding, which a `double`
306 /// cannot decode, while the former keeps the plain JSON-number encoding and
307 /// only tells a renderer how many fraction digits to show and accept. A
308 /// `Rational` already travels as `{num,den,dp}`, so it gets
309 /// `x-decimalPlaces` and is entered as one decimal number at those places
310 /// rather than as three integers. **Ignored on a `Quantity` member** (its
311 /// `x-decimalPlaces` is authoritative) and when it exceeds
312 /// `morph::math::kMaxDecimalPlaces`.
313 // NOLINTNEXTLINE(readability-redundant-member-init) -- as `unit` above
314 std::optional<::morph::math::DecimalPlaces> decimals{};
315
316 /// @brief What a blank control submits for a `std::string` /
317 /// `std::optional<std::string>` member; `BlankAs::Empty` emits
318 /// `"x-blankAs": "empty"`.
319 ///
320 /// An edit form prefilled from a stored record cannot otherwise clear an
321 /// optional string: a blank control is omitted, and an omitted member
322 /// reads as "leave it unchanged". With `Empty` a field the user emptied --
323 /// or one prefilled with `""` -- submits `""`; a field never prefilled
324 /// and never typed into is still omitted. **Ignored on any other member
325 /// type.**
327
328 /// @brief Type id of the registered action whose result rows are this member's options, for a member that is not
329 /// a `Choice<…>`: the member is emitted with `x-optionsAction`, `x-optionValue` and `x-optionLabel`, as a
330 /// `Choice` is, so a renderer draws it as a lookup select. Empty emits nothing. Set together with
331 /// `valueField` and `labelField`; a `Choice` member, whose type already names its options, refuses it.
332 // NOLINTNEXTLINE(readability-redundant-member-init) -- as `unit` above
333 std::string_view optionsAction{};
334 /// @brief Field of each options row submitted as the value (`x-optionValue`); set with `optionsAction`.
335 // NOLINTNEXTLINE(readability-redundant-member-init) -- as `unit` above
336 std::string_view valueField{};
337 /// @brief Field of each options row shown to the user (`x-optionLabel`); set with `optionsAction`.
338 // NOLINTNEXTLINE(readability-redundant-member-init) -- as `unit` above
339 std::string_view labelField{};
340
341 /// @brief Returns a copy with `placeholder` set to @p text.
342 /// @param text The placeholder hint.
343 /// @return The updated descriptor.
344 [[nodiscard]] constexpr FieldMeta withPlaceholder(std::string_view text) const noexcept {
345 FieldMeta copy = *this;
346 copy.placeholder = text;
347 return copy;
348 }
349
350 /// @brief Returns a copy with `readOnly` set to `true`.
351 /// @return The updated descriptor.
352 [[nodiscard]] constexpr FieldMeta withReadOnly() const noexcept {
353 FieldMeta copy = *this;
354 copy.readOnly = true;
355 return copy;
356 }
357
358 /// @brief Returns a copy with `hidden` set to `true`.
359 /// @return The updated descriptor.
360 [[nodiscard]] constexpr FieldMeta withHidden() const noexcept {
361 FieldMeta copy = *this;
362 copy.hidden = true;
363 return copy;
364 }
365
366 /// @brief Returns a copy with `minimum` set to @p bound.
367 /// @param bound Inclusive lower bound, in the field's canonical unit.
368 /// @return The updated descriptor.
369 [[nodiscard]] constexpr FieldMeta withMinimum(::morph::math::Rational bound) const noexcept {
370 FieldMeta copy = *this;
371 copy.minimum = bound;
372 return copy;
373 }
374
375 /// @brief Returns a copy with `maximum` set to @p bound.
376 /// @param bound Inclusive upper bound, in the field's canonical unit.
377 /// @return The updated descriptor.
378 [[nodiscard]] constexpr FieldMeta withMaximum(::morph::math::Rational bound) const noexcept {
379 FieldMeta copy = *this;
380 copy.maximum = bound;
381 return copy;
382 }
383
384 /// @brief Returns a copy with `multipleOf` set to @p step.
385 /// @param step Divisor the value must be an exact integer multiple of;
386 /// non-positive values are ignored (see the member).
387 /// @return The updated descriptor.
388 [[nodiscard]] constexpr FieldMeta withMultipleOf(::morph::math::Rational step) const noexcept {
389 FieldMeta copy = *this;
390 copy.multipleOf = step;
391 return copy;
392 }
393
394 /// @brief Returns a copy with `unit` set to @p text.
395 /// @param text The display unit, e.g. `"kg/m³"`.
396 /// @return The updated descriptor.
397 [[nodiscard]] constexpr FieldMeta withUnit(std::string_view text) const noexcept {
398 FieldMeta copy = *this;
399 copy.unit = text;
400 return copy;
401 }
402
403 /// @brief Returns a copy with `decimals` set to @p places.
404 /// @param places Fraction digits to display and accept.
405 /// @return The updated descriptor.
406 [[nodiscard]] constexpr FieldMeta withDecimals(::morph::math::DecimalPlaces places) const noexcept {
407 FieldMeta copy = *this;
408 copy.decimals = places;
409 return copy;
410 }
411
412 /// @brief Returns a copy with `blankAs` set to @p mode.
413 /// @param mode What a blank control submits.
414 /// @return The updated descriptor.
415 [[nodiscard]] constexpr FieldMeta withBlankAs(BlankAs mode) const noexcept {
416 FieldMeta copy = *this;
417 copy.blankAs = mode;
418 return copy;
419 }
420};
421
422/// @brief Concept: a field type with an internal empty state (`Quantity`,
423/// `Choice`, `Timestamp`, or any user type exposing `hasValue()`).
424///
425/// `hasValue()` must be `noexcept`: `allRequiredEngaged` is `noexcept` and calls
426/// it on every member, so a throwing `hasValue()` would cross a `noexcept`
427/// boundary and call `std::terminate`. Requiring it here turns that into a
428/// compile-time rejection instead — a user field with a throwing `hasValue()`
429/// simply does not satisfy the concept.
430template <typename T>
431concept EmptyCapableField = requires(const T& field) {
432 { field.hasValue() } noexcept -> std::convertible_to<bool>;
433};
434
435namespace detail {
436
437/// @brief Trait: is `T` a `std::optional<...>` (and therefore never required)?
438template <typename T>
439struct IsStdOptional : std::false_type {};
440
441template <typename T>
442struct IsStdOptional<std::optional<T>> : std::true_type {};
443
444template <typename T>
445inline constexpr bool isStdOptional = IsStdOptional<std::remove_cvref_t<T>>::value;
446
447/// @brief Concept: action declares a `static constexpr` iterable
448/// `optionalFields` list of field names.
449template <typename A>
450concept HasOptionalFields = requires {
451 std::begin(A::optionalFields);
452 std::end(A::optionalFields);
453};
454
455/// @brief Returns `true` when @p fieldName appears in `A::optionalFields`.
456template <typename A>
457[[nodiscard]] constexpr bool declaredOptional(std::string_view fieldName) noexcept {
458 if constexpr (HasOptionalFields<A>) {
459 for (std::string_view const candidate : A::optionalFields) {
460 if (candidate == fieldName) {
461 return true;
462 }
463 }
464 } else {
465 static_cast<void>(fieldName);
466 }
467 return false;
468}
469
470/// @brief Concept: action declares a `static constexpr`/`static const`
471/// iterable `fieldMetadata` list of `FieldMeta` entries.
472///
473/// Constrained to `FieldMeta` elements specifically (not just "some iterable
474/// named `fieldMetadata`"): `findFieldMeta` below hands back a `const
475/// FieldMeta*`, so an action whose `fieldMetadata` holds a different element
476/// shape must not satisfy this concept — that shape may still be a valid
477/// *widget*-override source for `detail::HasFieldMetadataWidgets` /
478/// `detail::widgetOverride` (structural, `.field`/`.widget` only), which is
479/// deliberately independent of this concept.
480template <typename A>
481concept HasFieldMetadata = requires {
482 { *std::begin(A::fieldMetadata) } -> std::convertible_to<const FieldMeta&>;
483 std::end(A::fieldMetadata);
484};
485
486/// @brief Returns the `FieldMeta` entry naming @p fieldName in
487/// `A::fieldMetadata`, or `nullptr` if @p A declares no such list or
488/// no entry names @p fieldName.
489template <typename A>
490[[nodiscard]] const FieldMeta* findFieldMeta(std::string_view fieldName) noexcept {
491 if constexpr (HasFieldMetadata<A>) {
492 for (auto const& candidate : A::fieldMetadata) {
493 if (candidate.field == fieldName) {
494 return &candidate;
495 }
496 }
497 } else {
498 static_cast<void>(fieldName);
499 }
500 return nullptr;
501}
502
503/// @brief Splits @p fieldName on camelCase/underscore boundaries and
504/// title-cases each word (`dryMassPct` -> `"Dry Mass Pct"`,
505/// `sample_id` -> `"Sample Id"`, `notes` -> `"Notes"`).
506inline std::string inferTitle(std::string_view fieldName) {
507 std::string result;
508 bool startOfWord = true;
509 for (std::size_t i = 0; i < fieldName.size(); ++i) {
510 char const c = fieldName[i];
511 if (c == '_') {
512 startOfWord = true;
513 continue;
514 }
515 bool const isUpper = std::isupper(static_cast<unsigned char>(c)) != 0;
516 bool const prevUpper = i > 0 && std::isupper(static_cast<unsigned char>(fieldName[i - 1])) != 0;
517 if (i > 0 && isUpper && !prevUpper) {
518 startOfWord = true;
519 }
520 if (startOfWord && !result.empty()) {
521 result += ' ';
522 }
523 result += startOfWord ? static_cast<char>(std::toupper(static_cast<unsigned char>(c)))
524 : static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
525 startOfWord = false;
526 }
527 return result;
528}
529
530/// @brief Concept: a field type that declares its own preferred control id via
531/// a `noexcept` `static constexpr widget()` — the shape `Multiline` and
532/// `Ranged` (forms/widget_hints.hpp) expose; any user type may opt in
533/// the same way.
534template <typename T>
535concept DeclaresWidget = requires {
536 { std::remove_cvref_t<T>::widget() } noexcept -> std::convertible_to<std::string_view>;
537};
538
539/// @brief Concept: a field type that marks its schema property with a
540/// keyword of its own via a `noexcept` `static constexpr schemaMarker()`
541/// — `morph::table::TableQuery` declares `x-table` this way.
542template <typename T>
543concept DeclaresSchemaMarker = requires {
544 { std::remove_cvref_t<T>::schemaMarker() } noexcept -> std::convertible_to<std::string_view>;
545};
546
547/// @brief Sets the keyword a member type declares with `schemaMarker()` on
548/// its property; does nothing for any other type.
549/// @tparam Member The member's type.
550/// @param property The property's schema node.
551template <typename Member>
552void annotateSchemaMarker(glz::generic_u64& property) {
553 if constexpr (DeclaresSchemaMarker<Member>) {
554 // NOLINTNEXTLINE(cppcoreguidelines-pro-bounds-avoid-unchecked-container-access) -- glaze DOM requires operator[]
555 property[std::string{Member::schemaMarker()}] = true;
556 }
557}
558
559/// @brief Concept: a field type that declares slider bounds via `noexcept`
560/// `static constexpr min()` / `max()` / `step()` — the `Ranged` shape.
561template <typename T>
562concept DeclaresRangedBounds = requires {
563 { std::remove_cvref_t<T>::min() } noexcept;
564 { std::remove_cvref_t<T>::max() } noexcept;
565 { std::remove_cvref_t<T>::step() } noexcept;
566};
567
568/// @brief Concept: `A` declares a `static constexpr` iterable `fieldMetadata`
569/// (structural check only — the element type is not named here).
570template <typename A>
571concept HasFieldMetadataEntries = requires {
572 std::begin(A::fieldMetadata);
573 std::end(A::fieldMetadata);
574};
575
576/// @brief Concept: `A::fieldMetadata` entries additionally expose `.field` and
577/// `.widget`, both convertible to `std::string_view` — the shape
578/// `FieldMeta` (above) has. Checked structurally (duck-typed) so this
579/// header never has to include or name that type by name in this
580/// lookup: any descriptor array with the two members is honoured as a
581/// widget-override source, regardless of which header declares it.
582template <typename A>
583concept HasFieldMetadataWidgets =
584 HasFieldMetadataEntries<A> && requires(std::remove_cvref_t<decltype(*std::begin(A::fieldMetadata))> entry) {
585 { entry.field } -> std::convertible_to<std::string_view>;
586 { entry.widget } -> std::convertible_to<std::string_view>;
587 };
588
589/// @brief Returns the non-empty `widget` of the `A::fieldMetadata` entry whose
590/// `field` equals @p fieldName, or an empty view when `A` declares no
591/// `fieldMetadata` (or none of its entries name @p fieldName).
592template <typename A>
593[[nodiscard]] constexpr std::string_view widgetOverride(std::string_view fieldName) noexcept {
594 if constexpr (HasFieldMetadataWidgets<A>) {
595 for (auto const& entry : A::fieldMetadata) {
596 if (std::string_view{entry.field} == fieldName) {
597 return std::string_view{entry.widget};
598 }
599 }
600 } else {
601 static_cast<void>(fieldName);
602 }
603 return {};
604}
605
606/// @brief Invokes `visitor.operator()<I>(name, member)` for every key glaze
607/// reflects on @p action, with the value glaze reads and writes under
608/// that key (`morph::detail::reflectedMember`). Under a `glz::meta`
609/// the walk follows the meta: its order, its names, and only the members
610/// it lists.
611template <typename A, typename Visitor>
612// Neither forwarding reference is forwarded, and neither may be. `action` is
613// read member-by-member below, and each member reference handed to the visitor
614// points into it; moving from it would leave those references pointing at a
615// moved-from object. `visitor` is invoked once per reflected member by the
616// fold expression, so forwarding it would move from it on the first member and
617// call a moved-from callable for every one after. Both are `&&` to preserve the
618// argument's cv-qualification — a `const A&` must yield const members — not to
619// enable a move. The directive stays on one physical line deliberately; see the
620// note at detail/fixed_string.hpp:48.
621// NOLINTNEXTLINE(cppcoreguidelines-missing-std-forward)
622constexpr void forEachNamedMember(A&& action, Visitor&& visitor) {
623 using Plain = std::remove_cvref_t<A>;
624 constexpr auto memberCount = glz::reflect<Plain>::size;
625 [&]<std::size_t... I>(std::index_sequence<I...>) {
626 // `I` is a pack of `std::index_sequence<memberCount>`, i.e. every value in
627 // [0, glz::reflect<Plain>::size), and `keys` is an array of exactly that
628 // size — the index cannot be out of range by construction. The directive
629 // stays on one physical line; see the note at detail/fixed_string.hpp:48.
630 // NOLINTNEXTLINE(cppcoreguidelines-pro-bounds-avoid-unchecked-container-access)
631 (visitor.template operator()<I>(glz::reflect<Plain>::keys[I], morph::detail::reflectedMember<I>(action)), ...);
632 }(std::make_index_sequence<memberCount>{});
633}
634
635/// @brief Extracts the containing class type from a pointer-to-member type.
636template <typename T>
637struct MemberPointerClass;
638
639/// @brief Partial specialisation matching `Member Class::*`.
640template <typename C, typename M>
641struct MemberPointerClass<M C::*> {
642 /// @brief The pointer-to-member's containing class.
643 using type = C;
644};
645
646/// @brief Convenience alias for `MemberPointerClass<T>::type`.
647template <typename T>
648using MemberPointerClassT = typename MemberPointerClass<T>::type;
649
650/// @brief Resolves the wire key name of @p MemberPtr by comparing member
651/// addresses on a default-constructed probe of its containing type.
652///
653/// Deliberately **not** `constexpr`/`consteval`: glaze's `get_member` for
654/// reflectable (pure-reflection) aggregates is not itself constexpr, so this
655/// can only run at ordinary runtime — which is why `describe<>()` is a plain
656/// function and why a `fieldMetadata` array built from it must be defined
657/// out-of-line (see `describe()`'s documentation for why and how).
658template <auto MemberPtr>
659[[nodiscard]] std::string_view memberWireName() noexcept {
660 using A = MemberPointerClassT<decltype(MemberPtr)>;
661 A probe{};
662 std::string_view found{};
663 forEachNamedMember(probe, [&]<std::size_t I>(std::string_view name, auto& member) {
664 using MemberT = std::remove_reference_t<decltype(member)>;
665 using TargetT = std::remove_reference_t<decltype(probe.*MemberPtr)>;
666 if constexpr (std::is_same_v<MemberT, TargetT>) {
667 if (std::addressof(member) == std::addressof(probe.*MemberPtr)) {
668 found = name;
669 }
670 }
671 });
672 return found;
673}
674
675/// @brief Deduces the action type a condition/rule node's `test(const A&)
676/// const` ranges over, from its member-function-pointer type. Every
677/// condition/rule node in this header (`Engaged`, `Equals`, `Greater`, …, and
678/// the compound `And`/`Or`/`Not` nodes) exposes exactly this shape, so
679/// `andOf`/`orOf`/`notOf` use it to recover `A` without requiring every leaf
680/// node to name it as a separate member type.
681/// @tparam TestMemberPtr Pointer-to-member-function type of `&Cond::test`.
682template <typename TestMemberPtr>
683struct ConditionActionTypeFromTest;
684
685/// @brief Specialisation matching `bool (Cond::*)(const A&) const noexcept`.
686/// @tparam Cond The condition/rule node type.
687/// @tparam A The deduced action type.
688template <typename Cond, typename A>
689struct ConditionActionTypeFromTest<bool (Cond::*)(const A&) const noexcept> {
690 /// @brief The deduced action type.
691 using type = A;
692};
693
694/// @brief The action type @p Cond's `test()` ranges over.
695/// @tparam Cond A condition/rule node type (must expose `test(const A&) const noexcept`).
696template <typename Cond>
697using ConditionActionType = typename ConditionActionTypeFromTest<decltype(&Cond::test)>::type;
698
699/// @brief The closed set of cross-field rule and condition kinds `x-rules`
700/// carries in its `kind` field. One flat enum serves both top-level rules
701/// (`RequiredWhen`, `Greater`, `ExactlyOneOf`, `VisibleWhen`, ...) and the
702/// condition nodes nested inside a rule's `when` clause (`Engaged`,
703/// `NotEngaged`, `Equals`, and the comparison kinds reused as booleans) —
704/// see docs/spec/forms/forms.md's `x-rules` renderer-contract table.
705enum class RuleKind : std::uint8_t {
706 Engaged,
707 NotEngaged,
708 Equals,
709 Greater,
710 GreaterOrEqual,
711 Less,
712 LessOrEqual,
713 RequiredWhen,
714 ExactlyOneOf,
715 AtLeastOneOf,
716 MutuallyExclusive,
717 VisibleWhen,
718 ReadonlyWhen,
719 And,
720 Or,
721 Not,
722};
723
724/// @brief The wire `"kind"` string for @p kind, exactly as documented in
725/// forms.md's `x-rules` table.
726[[nodiscard]] constexpr std::string_view ruleKindName(RuleKind kind) noexcept {
727 switch (kind) {
728 case RuleKind::Engaged:
729 return "engaged";
730 case RuleKind::NotEngaged:
731 return "notEngaged";
732 case RuleKind::Equals:
733 return "equals";
734 case RuleKind::Greater:
735 return "greater";
736 case RuleKind::GreaterOrEqual:
737 return "greaterOrEqual";
738 case RuleKind::Less:
739 return "less";
740 case RuleKind::LessOrEqual:
741 return "lessOrEqual";
742 case RuleKind::RequiredWhen:
743 return "requiredWhen";
744 case RuleKind::ExactlyOneOf:
745 return "exactlyOneOf";
746 case RuleKind::AtLeastOneOf:
747 return "atLeastOneOf";
748 case RuleKind::MutuallyExclusive:
749 return "mutuallyExclusive";
750 case RuleKind::VisibleWhen:
751 return "visibleWhen";
752 case RuleKind::ReadonlyWhen:
753 return "readonlyWhen";
754 case RuleKind::And:
755 return "and";
756 case RuleKind::Or:
757 return "or";
758 case RuleKind::Not:
759 return "not";
760 default:
761 // Unreachable through any real code path: every rule/condition
762 // node's `kind` member is a `static constexpr detail::RuleKind`
763 // initialised from one of the enumerators above, and the switch
764 // handles all of them explicitly. This arm only exists to satisfy
765 // the compiler that the function returns on every enum value,
766 // including one manufactured by an out-of-range `static_cast`.
767 return "";
768 }
769}
770
771/// @brief Whether @p value counts as "engaged" for rule purposes:
772/// `hasValue()` for an `EmptyCapableField` (`Quantity`/`Choice`/`Timestamp`),
773/// otherwise `has_value()` for a plain `std::optional<T>` — the rule
774/// vocabulary treats both as "a field with an empty state", unlike
775/// `allRequiredEngaged` (which only inspects `EmptyCapableField`; a plain
776/// `std::optional<T>` exposes `has_value()`, not `hasValue()`, so it never
777/// satisfies that concept — see forms.md's "two exclusions" note). Only ever
778/// called on a type satisfying `EngageableField` (defined below), enforced
779/// by every public factory that calls it.
780template <typename T>
781[[nodiscard]] constexpr bool isEngaged(const T& value) noexcept {
782 if constexpr (::morph::forms::EmptyCapableField<T>) {
783 return value.hasValue();
784 } else {
785 return value.has_value();
786 }
787}
788
789/// @brief Resolves the wire (JSON) field name of @p field on `A`, the same
790/// way `x-order` is derived (`mergeSchemaExtras`): a fresh probe instance is
791/// walked with `forEachNamedMember`, matching by member address. `A` must be
792/// default-constructible (already required by `schemaJson<A>()`). Returns an
793/// empty string if @p field does not name a reflected member of `A` (should
794/// not happen for a pointer-to-member of `A` itself; defensive only).
795template <typename V, typename A>
796[[nodiscard]] inline std::string resolveFieldName(V A::* field) {
797 std::string found;
798 A probe{};
799 forEachNamedMember(probe, [&]<std::size_t I>(std::string_view name, const auto& member) {
800 static_cast<void>(I);
801 using Member = std::remove_cvref_t<decltype(member)>;
802 if constexpr (std::is_same_v<Member, V>) {
803 if (static_cast<const void*>(&member) == static_cast<const void*>(&(probe.*field))) {
804 found = std::string{name};
805 }
806 }
807 });
808 return found;
809}
810
811/// @brief Constraint for the comparison rule/condition kinds (`greater`,
812/// `greaterOrEqual`, `less`, `lessOrEqual`): an
813/// `EmptyCapableField` whose engaged value (`operator*()`) is three-way
814/// comparable to itself — satisfied by `Quantity` (dereferences to
815/// `math::Rational`) and `morph::time::Timestamp` (dereferences to
816/// `DateTime`), matching forms.md's "numeric / Timestamp" scope for
817/// comparisons.
818template <typename V>
819concept ComparableField = ::morph::forms::EmptyCapableField<V> && requires(const V& value) {
820 { *value <=> *value };
821};
822
823/// @brief The shared compile-time string used to capture an `equals` literal.
824///
825/// Aliased, not redefined: `morph::detail::FixedString` is the project's single
826/// canonical NTTP-capable fixed string (the forms `Choice` layer and the units
827/// layer already alias it too).
828///
829/// `equals(&A::code, "URGENT")` used to store its argument as a `std::string`,
830/// which quietly bounded the documented
831/// `static constexpr auto formRules = ruleList(...)` form to whatever fits the
832/// standard library's small-string buffer — 15 characters on libstdc++. One
833/// character more and the string allocates, so the rule node is no longer a
834/// constant expression and the declaration fails with "refers to a result of
835/// `operator new`". The limit is invisible in the source: the same code
836/// compiles or does not depending only on how long the literal is, and on which
837/// standard library is in use.
838///
839/// Holding the characters inline removes the allocation, so a literal of any
840/// length works. Passing an explicit `std::string` still stores a `std::string`
841/// (see `RuleLiteral`) and still cannot be `constexpr` when it allocates — that
842/// is inherent to the type the caller chose, not something this can fix.
843///
844/// @tparam N Literal length including its trailing NUL.
845template <std::size_t N>
846using LiteralString = ::morph::detail::FixedString<N>;
847
848/// @brief Trait: is @p T a `LiteralString`? `false` for every other type.
849/// @tparam T Type to test.
850template <typename T>
851inline constexpr bool isLiteralString = false;
852
853/// @brief `isLiteralString` specialization recognising `LiteralString<N>`,
854/// where `N` is the recognised literal's length.
855template <std::size_t N>
856inline constexpr bool isLiteralString<LiteralString<N>> = true;
857
858} // namespace detail
859
860/// @brief Broader than `EmptyCapableField`: also covers a plain
861/// `std::optional<T>` member (e.g. `std::optional<std::string> email`),
862/// which does **not** satisfy `EmptyCapableField` — it exposes
863/// `has_value()`, not `hasValue()` (see forms.md's `allRequiredEngaged`
864/// "two exclusions" note). The cross-field rule vocabulary's engagement
865/// checks (`engaged`, `notEngaged`, `requiredWhen`, and the membership rules
866/// `exactlyOneOf`/`atLeastOneOf`/`mutuallyExclusive`) accept either kind of
867/// field, since docs/spec/forms/forms.md's worked example ranges an
868/// `exactlyOneOf` over two plain
869/// `std::optional<std::string>` fields.
870template <typename T>
871concept EngageableField = EmptyCapableField<T> || detail::isStdOptional<T>;
872
873/// @brief Concept: @p Cond may be used as a **nested condition** — inside an
874/// `andOf`/`orOf`/`notOf` tree, or as the `when` clause of a `requiredWhen` /
875/// `visibleWhen` / `readonlyWhen` rule.
876///
877/// Every rule and condition node in this header exposes the same shape (`kind`,
878/// `test(const A&) const noexcept`, `emitNode()`), which is what makes them
879/// substitutable — and what made "has a `test()`" a useless admission test.
880/// `VisibleWhen::test()` and `ReadonlyWhen::test()` return `true`
881/// *unconditionally, by design*: they are presentation rules and never gate
882/// submission, so a renderer reads their `when` clause rather than calling
883/// them. Nested as a condition, such a node therefore contributes a constant
884/// and can never influence the tree it sits in — `andOf(visibleWhen(…), c)`
885/// silently collapses to `c`, having compiled and looking like it says
886/// something. `RequiredWhen` is excluded for the neighbouring reason: it is a
887/// rule *about* a condition, not a condition, and nesting one emits a
888/// `"requiredWhen"` node in a `when` position that no renderer's condition
889/// vocabulary has a case for.
890///
891/// So the marker is declared explicitly by the nodes whose `test()` is a
892/// function of the action, rather than inferred from `isPresentation` — which
893/// would admit `RequiredWhen` — or from the presence of `test()`, which admits
894/// everything. Adding a node to the condition vocabulary is one line on the
895/// node itself.
896///
897/// The membership rules (`exactlyOneOf` / `atLeastOneOf` / `mutuallyExclusive`)
898/// *are* conditions by this rule: their `test()` really does range over the
899/// action. A renderer that does not recognise them nested treats them as
900/// "cannot evaluate" and defers to the server, which is the sanctioned
901/// fallback (forms.md, "Renderer fallback") rather than a disagreement.
902///
903/// (`Cond` is the candidate node type. It is deliberately not documented with
904/// a parameter-doc command: -Wdocumentation does not consider a concept a
905/// template declaration, so such a command here is an error under
906/// -Weverything -Werror, which is how the WASM ladder builds. `HasFormRules`
907/// and `HasExplicitSubmit` below omit it for the same reason.)
908template <typename Cond>
909concept Condition = requires {
910 { Cond::isCondition } -> std::convertible_to<bool>;
911} && Cond::isCondition;
912
913/// @brief Condition: `field` is engaged (has a value). One of the closed
914/// condition kinds a `requiredWhen` / `visibleWhen` / `readonlyWhen` rule's
915/// `when` clause accepts.
916/// @tparam V Field member type (must satisfy `EngageableField`).
917/// @tparam A Action type the field belongs to.
918template <typename V, typename A>
919struct Engaged {
920 /// @brief Pointer to the member this condition inspects.
921 V A::* field;
922 /// @brief The wire `"kind"` this condition emits: `"engaged"`.
923 static constexpr detail::RuleKind kind = detail::RuleKind::Engaged;
924 /// @brief Usable as a nested condition: this node's `test()` is a
925 /// function of the action, so composing it into an `and`/`or`/`not`
926 /// tree changes what that tree evaluates to.
927 static constexpr bool isCondition = true;
928
929 /// @brief Evaluates the condition against @p action.
930 /// @param action The action snapshot to inspect.
931 /// @return `true` when the field is engaged.
932 [[nodiscard]] constexpr bool test(const A& action) const noexcept { return detail::isEngaged(action.*field); }
933
934 /// @brief Emits this condition's `x-rules` JSON node.
935 /// @return `{"kind":"engaged","fields":["<wire name>"]}`.
936 [[nodiscard]] glz::generic_u64 emitNode() const {
937 glz::generic_u64 node{};
938 node["kind"] = std::string{detail::ruleKindName(kind)};
939 glz::generic_u64::array_t fields{};
940 fields.emplace_back(detail::resolveFieldName(field));
941 node["fields"] = fields;
942 return node;
943 }
944};
945
946/// @brief Builds an `Engaged<V, A>` condition testing whether @p field is
947/// engaged.
948/// @tparam V Field member type (deduced; must satisfy `EngageableField`).
949/// @tparam A Action type (deduced).
950/// @param field Pointer to the member to test.
951/// @return The condition node.
952template <typename V, typename A>
953 requires EngageableField<V>
954[[nodiscard]] constexpr auto engaged(V A::* field) {
955 return Engaged<V, A>{field};
956}
957
958/// @brief Condition: `field` is **not** engaged. The complement of
959/// `engaged`.
960/// @tparam V Field member type (must satisfy `EngageableField`).
961/// @tparam A Action type the field belongs to.
962template <typename V, typename A>
964 /// @brief Pointer to the member this condition inspects.
965 V A::* field;
966 /// @brief The wire `"kind"` this condition emits: `"notEngaged"`.
967 static constexpr detail::RuleKind kind = detail::RuleKind::NotEngaged;
968 /// @brief Usable as a nested condition: this node's `test()` is a
969 /// function of the action, so composing it into an `and`/`or`/`not`
970 /// tree changes what that tree evaluates to.
971 static constexpr bool isCondition = true;
972
973 /// @brief Evaluates the condition against @p action.
974 /// @param action The action snapshot to inspect.
975 /// @return `true` when the field is **not** engaged.
976 [[nodiscard]] constexpr bool test(const A& action) const noexcept { return !detail::isEngaged(action.*field); }
977
978 /// @brief Emits this condition's `x-rules` JSON node.
979 /// @return `{"kind":"notEngaged","fields":["<wire name>"]}`.
980 [[nodiscard]] glz::generic_u64 emitNode() const {
981 glz::generic_u64 node{};
982 node["kind"] = std::string{detail::ruleKindName(kind)};
983 glz::generic_u64::array_t fields{};
984 fields.emplace_back(detail::resolveFieldName(field));
985 node["fields"] = fields;
986 return node;
987 }
988};
989
990/// @brief Builds a `NotEngaged<V, A>` condition testing whether @p field is
991/// **not** engaged.
992/// @tparam V Field member type (deduced; must satisfy `EngageableField`).
993/// @tparam A Action type (deduced).
994/// @param field Pointer to the member to test.
995/// @return The condition node.
996template <typename V, typename A>
997 requires EngageableField<V>
998[[nodiscard]] constexpr auto notEngaged(V A::* field) {
999 return NotEngaged<V, A>{field};
1000}
1001
1002/// @brief Rule/condition: `*lhs > *rhs` when both operands are engaged;
1003/// vacuously satisfied when either is unengaged (a form still being filled
1004/// in must not fail this comparison prematurely — see forms.md). Compares
1005/// the operands' **engaged values** (`operator*()`) directly — e.g. the
1006/// underlying `math::Rational` for `Quantity`, or `DateTime` for
1007/// `Timestamp` — never the field type's own (possibly throwing, for
1008/// `Quantity`) `operator<=>`.
1009/// @tparam V Field member type shared by both operands (must satisfy
1010/// `detail::ComparableField`).
1011/// @tparam A Action type both fields belong to.
1012template <typename V, typename A>
1013struct Greater {
1014 /// @brief Pointer to the left-hand member.
1015 V A::* lhs;
1016 /// @brief Pointer to the right-hand member.
1017 V A::* rhs;
1018 /// @brief The wire `"kind"` this node emits: `"greater"`.
1019 static constexpr detail::RuleKind kind = detail::RuleKind::Greater;
1020 /// @brief Usable as a nested condition: this node's `test()` is a
1021 /// function of the action, so composing it into an `and`/`or`/`not`
1022 /// tree changes what that tree evaluates to.
1023 static constexpr bool isCondition = true;
1024 /// @brief Validation rule (not presentation): participates in the gate.
1025 static constexpr bool isPresentation = false;
1026
1027 /// @brief Evaluates the comparison against @p action.
1028 /// @param action The action snapshot to inspect.
1029 /// @return `true` when either operand is unengaged, or `*lhs > *rhs`.
1030 [[nodiscard]] constexpr bool test(const A& action) const noexcept {
1031 const auto& lv = action.*lhs;
1032 const auto& rv = action.*rhs;
1033 if (!lv.hasValue() || !rv.hasValue()) {
1034 return true;
1035 }
1036 return (*lv <=> *rv) == std::strong_ordering::greater;
1037 }
1038
1039 /// @brief Emits this rule's `x-rules` JSON node.
1040 /// @return `{"kind":"greater","fields":["<lhs wire name>","<rhs wire name>"]}`.
1041 [[nodiscard]] glz::generic_u64 emitNode() const {
1042 glz::generic_u64 node{};
1043 node["kind"] = std::string{detail::ruleKindName(kind)};
1044 glz::generic_u64::array_t fields{};
1045 fields.emplace_back(detail::resolveFieldName(lhs));
1046 fields.emplace_back(detail::resolveFieldName(rhs));
1047 node["fields"] = fields;
1048 return node;
1049 }
1050};
1051
1052/// @brief Builds a `Greater<V, A>` rule/condition: `*lhs > *rhs`.
1053/// @tparam V Field member type shared by both operands (deduced; must
1054/// satisfy `detail::ComparableField`).
1055/// @tparam A Action type (deduced).
1056/// @param lhs Pointer to the left-hand member.
1057/// @param rhs Pointer to the right-hand member.
1058/// @return The rule/condition node.
1059template <typename V, typename A>
1060 requires detail::ComparableField<V>
1061[[nodiscard]] constexpr auto greater(V A::* lhs, V A::* rhs) {
1062 return Greater<V, A>{lhs, rhs};
1063}
1064
1065/// @brief Rule/condition: `*lhs >= *rhs` when both operands are engaged;
1066/// vacuously satisfied when either is unengaged. See `Greater` for the
1067/// exact-value / vacuous-operand rationale.
1068/// @tparam V Field member type shared by both operands (must satisfy
1069/// `detail::ComparableField`).
1070/// @tparam A Action type both fields belong to.
1071template <typename V, typename A>
1073 /// @brief Pointer to the left-hand member.
1074 V A::* lhs;
1075 /// @brief Pointer to the right-hand member.
1076 V A::* rhs;
1077 /// @brief The wire `"kind"` this node emits: `"greaterOrEqual"`.
1078 static constexpr detail::RuleKind kind = detail::RuleKind::GreaterOrEqual;
1079 /// @brief Usable as a nested condition: this node's `test()` is a
1080 /// function of the action, so composing it into an `and`/`or`/`not`
1081 /// tree changes what that tree evaluates to.
1082 static constexpr bool isCondition = true;
1083 /// @brief Validation rule (not presentation): participates in the gate.
1084 static constexpr bool isPresentation = false;
1085
1086 /// @brief Evaluates the comparison against @p action.
1087 /// @param action The action snapshot to inspect.
1088 /// @return `true` when either operand is unengaged, or `*lhs >= *rhs`.
1089 [[nodiscard]] constexpr bool test(const A& action) const noexcept {
1090 const auto& lv = action.*lhs;
1091 const auto& rv = action.*rhs;
1092 if (!lv.hasValue() || !rv.hasValue()) {
1093 return true;
1094 }
1095 return std::is_gteq(*lv <=> *rv);
1096 }
1097
1098 /// @brief Emits this rule's `x-rules` JSON node.
1099 /// @return `{"kind":"greaterOrEqual","fields":["<lhs wire name>","<rhs wire name>"]}`.
1100 [[nodiscard]] glz::generic_u64 emitNode() const {
1101 glz::generic_u64 node{};
1102 node["kind"] = std::string{detail::ruleKindName(kind)};
1103 glz::generic_u64::array_t fields{};
1104 fields.emplace_back(detail::resolveFieldName(lhs));
1105 fields.emplace_back(detail::resolveFieldName(rhs));
1106 node["fields"] = fields;
1107 return node;
1108 }
1109};
1110
1111/// @brief Builds a `GreaterOrEqual<V, A>` rule/condition: `*lhs >= *rhs`.
1112/// @tparam V Field member type shared by both operands (deduced; must
1113/// satisfy `detail::ComparableField`).
1114/// @tparam A Action type (deduced).
1115/// @param lhs Pointer to the left-hand member.
1116/// @param rhs Pointer to the right-hand member.
1117/// @return The rule/condition node.
1118template <typename V, typename A>
1119 requires detail::ComparableField<V>
1120[[nodiscard]] constexpr auto greaterOrEqual(V A::* lhs, V A::* rhs) {
1121 return GreaterOrEqual<V, A>{lhs, rhs};
1122}
1123
1124/// @brief Rule/condition: `*lhs < *rhs` when both operands are engaged;
1125/// vacuously satisfied when either is unengaged. See `Greater` for the
1126/// exact-value / vacuous-operand rationale.
1127/// @tparam V Field member type shared by both operands (must satisfy
1128/// `detail::ComparableField`).
1129/// @tparam A Action type both fields belong to.
1130template <typename V, typename A>
1131struct Less {
1132 /// @brief Pointer to the left-hand member.
1133 V A::* lhs;
1134 /// @brief Pointer to the right-hand member.
1135 V A::* rhs;
1136 /// @brief The wire `"kind"` this node emits: `"less"`.
1137 static constexpr detail::RuleKind kind = detail::RuleKind::Less;
1138 /// @brief Usable as a nested condition: this node's `test()` is a
1139 /// function of the action, so composing it into an `and`/`or`/`not`
1140 /// tree changes what that tree evaluates to.
1141 static constexpr bool isCondition = true;
1142 /// @brief Validation rule (not presentation): participates in the gate.
1143 static constexpr bool isPresentation = false;
1144
1145 /// @brief Evaluates the comparison against @p action.
1146 /// @param action The action snapshot to inspect.
1147 /// @return `true` when either operand is unengaged, or `*lhs < *rhs`.
1148 [[nodiscard]] constexpr bool test(const A& action) const noexcept {
1149 const auto& lv = action.*lhs;
1150 const auto& rv = action.*rhs;
1151 if (!lv.hasValue() || !rv.hasValue()) {
1152 return true;
1153 }
1154 return std::is_lt(*lv <=> *rv);
1155 }
1156
1157 /// @brief Emits this rule's `x-rules` JSON node.
1158 /// @return `{"kind":"less","fields":["<lhs wire name>","<rhs wire name>"]}`.
1159 [[nodiscard]] glz::generic_u64 emitNode() const {
1160 glz::generic_u64 node{};
1161 node["kind"] = std::string{detail::ruleKindName(kind)};
1162 glz::generic_u64::array_t fields{};
1163 fields.emplace_back(detail::resolveFieldName(lhs));
1164 fields.emplace_back(detail::resolveFieldName(rhs));
1165 node["fields"] = fields;
1166 return node;
1167 }
1168};
1169
1170/// @brief Builds a `Less<V, A>` rule/condition: `*lhs < *rhs`.
1171/// @tparam V Field member type shared by both operands (deduced; must
1172/// satisfy `detail::ComparableField`).
1173/// @tparam A Action type (deduced).
1174/// @param lhs Pointer to the left-hand member.
1175/// @param rhs Pointer to the right-hand member.
1176/// @return The rule/condition node.
1177template <typename V, typename A>
1178 requires detail::ComparableField<V>
1179[[nodiscard]] constexpr auto less(V A::* lhs, V A::* rhs) {
1180 return Less<V, A>{lhs, rhs};
1181}
1182
1183/// @brief Rule/condition: `*lhs <= *rhs` when both operands are engaged;
1184/// vacuously satisfied when either is unengaged. See `Greater` for the
1185/// exact-value / vacuous-operand rationale.
1186/// @tparam V Field member type shared by both operands (must satisfy
1187/// `detail::ComparableField`).
1188/// @tparam A Action type both fields belong to.
1189template <typename V, typename A>
1191 /// @brief Pointer to the left-hand member.
1192 V A::* lhs;
1193 /// @brief Pointer to the right-hand member.
1194 V A::* rhs;
1195 /// @brief The wire `"kind"` this node emits: `"lessOrEqual"`.
1196 static constexpr detail::RuleKind kind = detail::RuleKind::LessOrEqual;
1197 /// @brief Usable as a nested condition: this node's `test()` is a
1198 /// function of the action, so composing it into an `and`/`or`/`not`
1199 /// tree changes what that tree evaluates to.
1200 static constexpr bool isCondition = true;
1201 /// @brief Validation rule (not presentation): participates in the gate.
1202 static constexpr bool isPresentation = false;
1203
1204 /// @brief Evaluates the comparison against @p action.
1205 /// @param action The action snapshot to inspect.
1206 /// @return `true` when either operand is unengaged, or `*lhs <= *rhs`.
1207 [[nodiscard]] constexpr bool test(const A& action) const noexcept {
1208 const auto& lv = action.*lhs;
1209 const auto& rv = action.*rhs;
1210 if (!lv.hasValue() || !rv.hasValue()) {
1211 return true;
1212 }
1213 return std::is_lteq(*lv <=> *rv);
1214 }
1215
1216 /// @brief Emits this rule's `x-rules` JSON node.
1217 /// @return `{"kind":"lessOrEqual","fields":["<lhs wire name>","<rhs wire name>"]}`.
1218 [[nodiscard]] glz::generic_u64 emitNode() const {
1219 glz::generic_u64 node{};
1220 node["kind"] = std::string{detail::ruleKindName(kind)};
1221 glz::generic_u64::array_t fields{};
1222 fields.emplace_back(detail::resolveFieldName(lhs));
1223 fields.emplace_back(detail::resolveFieldName(rhs));
1224 node["fields"] = fields;
1225 return node;
1226 }
1227};
1228
1229/// @brief Builds a `LessOrEqual<V, A>` rule/condition: `*lhs <= *rhs`.
1230/// @tparam V Field member type shared by both operands (deduced; must
1231/// satisfy `detail::ComparableField`).
1232/// @tparam A Action type (deduced).
1233/// @param lhs Pointer to the left-hand member.
1234/// @param rhs Pointer to the right-hand member.
1235/// @return The rule/condition node.
1236template <typename V, typename A>
1237 requires detail::ComparableField<V>
1238[[nodiscard]] constexpr auto lessOrEqual(V A::* lhs, V A::* rhs) {
1239 return LessOrEqual<V, A>{lhs, rhs};
1240}
1241
1242/// @brief Constraint: literal types `equals(...)` accepts — the closed,
1243/// JSON-representable scalar set a field can hold. Restricting the literal
1244/// type keeps `equals` losslessly serialisable into `x-rules`'s `value` key
1245/// (see forms.md): a numeric literal is always the exact `math::Rational`,
1246/// never a `double`.
1247template <typename T>
1248concept RuleLiteral = std::same_as<T, std::int64_t> || std::same_as<T, bool> || std::same_as<T, std::string> ||
1249 std::same_as<T, ::morph::math::Rational> || detail::isLiteralString<T>;
1250
1251namespace detail {
1252
1253/// @brief The value `Equals::test` actually compares: the engaged payload of an
1254/// `EngageableField`, or the member itself when it has no empty state.
1255/// @tparam V Field member type.
1256/// @param fieldValue The member to read.
1257/// @return A reference to the value the literal is compared against.
1258template <typename V>
1259[[nodiscard]] constexpr decltype(auto) equalsOperand(const V& fieldValue) noexcept {
1260 // The parentheses are load-bearing, not redundant: with `decltype(auto)`,
1261 // `return (e);` yields `decltype((e))`, which preserves an lvalue operand as
1262 // a reference while still returning a prvalue one by value. `Choice` and
1263 // `std::optional` dereference to a reference, `Ranged` to a prvalue, and
1264 // both have to work — dropping the parentheses would copy the first, and
1265 // binding the result to `const auto&` instead would dangle on the second.
1266 if constexpr (EngageableField<V>) {
1267 // Unevaluated in the concept below; `Equals::test` guards engagement.
1268 // NOLINTNEXTLINE(bugprone-unchecked-optional-access, readability-redundant-parentheses)
1269 return (*fieldValue);
1270 } else {
1271 // NOLINTNEXTLINE(readability-redundant-parentheses)
1272 return (fieldValue);
1273 }
1274}
1275
1276} // namespace detail
1277
1278/// @brief Constraint: a field of type @p V can be compared against a literal of
1279/// type @p L at all.
1280///
1281/// Checked at the `equals(...)` call site rather than left to fail inside
1282/// `Equals::test`. `equals(&A::someQuantity, "URGENT")` produced 83 lines whose
1283/// first error was `no match for 'operator=='` deep inside this header, with no
1284/// mention of `equals` and nothing pointing at the caller's own `formRules`.
1285///
1286/// (@p V is the field member type and @p L the literal type. Neither is
1287/// documented with a parameter-doc command: on a concept that is an error
1288/// under -Weverything -Werror -- see the note on `Condition` above.)
1289template <typename V, typename L>
1290concept ComparableAgainstLiteral = requires(const V& fieldValue, const L& literal) {
1291 { detail::equalsOperand(fieldValue) == literal } -> std::convertible_to<bool>;
1292};
1293
1294/// @brief The largest N such that *every* integer in `[0, N]` is exactly
1295/// representable as an IEEE-754 double: 2^53.
1296///
1297/// Not "the largest value a double holds exactly" — 2^60 is exact too. What
1298/// stops at 2^53 is the *contiguous* range: past it, consecutive integers start
1299/// sharing a representation, so an integer bound above it cannot be relied on to
1300/// survive `JSON.parse` intact and needs an exact companion the renderer can
1301/// read instead.
1302inline constexpr std::uint64_t kExactDoubleLimit = 9007199254740992ULL;
1303
1304/// @brief Signed spelling of `kExactDoubleLimit`, for the negative bound.
1305inline constexpr std::int64_t kExactDoubleLimitSigned = 9007199254740992LL;
1306
1307/// @brief Condition: `field`'s engaged value equals @p literal. An
1308/// unengaged field is **not** vacuously satisfied here (unlike the
1309/// comparison kinds) — a field with no value cannot equal anything, so
1310/// `equals` returns `false` while the field is unengaged.
1311/// @tparam V Field member type.
1312/// @tparam A Action type the field belongs to.
1313/// @tparam L Literal type (must satisfy `RuleLiteral`).
1314template <typename V, typename A, typename L>
1315struct Equals {
1316 /// @brief Pointer to the member this condition inspects.
1317 V A::* field;
1318 /// @brief The literal to compare the field's engaged value against.
1320 /// @brief The wire `"kind"` this condition emits: `"equals"`.
1321 static constexpr detail::RuleKind kind = detail::RuleKind::Equals;
1322 /// @brief Usable as a nested condition: this node's `test()` is a
1323 /// function of the action, so composing it into an `and`/`or`/`not`
1324 /// tree changes what that tree evaluates to.
1325 static constexpr bool isCondition = true;
1326
1327 /// @brief Evaluates the condition against @p action.
1328 /// @param action The action snapshot to inspect.
1329 /// @return `true` when the field is engaged (or has no empty state) and
1330 /// its value equals `literal`.
1331 [[nodiscard]] constexpr bool test(const A& action) const noexcept {
1332 const auto& fieldValue = action.*field;
1333 if constexpr (EngageableField<V>) {
1334 if (!detail::isEngaged(fieldValue)) {
1335 return false;
1336 }
1337 return static_cast<bool>(*fieldValue == literal);
1338 } else {
1339 return static_cast<bool>(fieldValue == literal);
1340 }
1341 }
1342
1343 /// @brief Emits this condition's `x-rules` JSON node.
1344 /// @return `{"kind":"equals","fields":["<wire name>"],"value":...}`,
1345 /// where `value` is `{"num":...,"den":...}` for a `Rational`
1346 /// literal and the bare scalar otherwise.
1347 [[nodiscard]] glz::generic_u64 emitNode() const {
1348 glz::generic_u64 node{};
1349 node["kind"] = std::string{detail::ruleKindName(kind)};
1350 glz::generic_u64::array_t fields{};
1351 fields.emplace_back(detail::resolveFieldName(field));
1352 node["fields"] = fields;
1353 if constexpr (std::is_same_v<L, ::morph::math::Rational>) {
1354 glz::generic_u64 value{};
1355 value["num"] = literal.numerator;
1356 value["den"] = literal.denominator;
1357 node["value"] = value;
1358 } else if constexpr (detail::isLiteralString<L>) {
1359 // Serialises identically to a std::string literal — the inline
1360 // storage is a compile-time representation detail, not a wire one.
1361 // Glaze DOM builder — same shape as every sibling assignment here.
1362 // NOLINTNEXTLINE(cppcoreguidelines-pro-bounds-avoid-unchecked-container-access)
1363 node["value"] = std::string{literal.view()};
1364 } else {
1365 node["value"] = literal;
1366 // An integral literal beyond 2^53 does not survive the renderer's
1367 // JSON.parse: it arrives rounded, and an `equals` against it then
1368 // compares two values the schema kept distinct. Carry the exact
1369 // digits alongside, as `x-exactMinimum`/`x-exactMaximum` do for
1370 // bounds; a renderer that ignores `valueText` is unaffected.
1371 if constexpr (std::is_integral_v<L> && !std::is_same_v<L, bool>) {
1372 if (std::cmp_greater(literal, kExactDoubleLimit) || std::cmp_less(literal, -kExactDoubleLimitSigned)) {
1373 // Glaze DOM builder — same shape as every sibling assignment here.
1374 // NOLINTNEXTLINE(cppcoreguidelines-pro-bounds-avoid-unchecked-container-access)
1375 node["valueText"] = std::to_string(literal);
1376 }
1377 }
1378 }
1379 return node;
1380 }
1381};
1382
1383/// @brief Builds an `Equals<V, A, L>` condition: `field`'s engaged value
1384/// equals @p literal.
1385/// @tparam V Field member type (deduced).
1386/// @tparam A Action type (deduced).
1387/// @tparam L Literal type (deduced; must satisfy `RuleLiteral`).
1388/// @param field Pointer to the member to test.
1389/// @param literal The value to compare against.
1390/// @return The condition node.
1391template <typename V, typename A, RuleLiteral L>
1392 requires ComparableAgainstLiteral<V, L>
1393[[nodiscard]] constexpr auto equals(V A::* field, L literal) {
1394 return Equals<V, A, L>{field, std::move(literal)};
1395}
1396
1397/// @brief `equals` overload for a string-literal argument
1398/// (`equals(&A::code, "X")`), so callers do not have to spell
1399/// `std::string{"X"}` explicitly.
1400///
1401/// The literal is captured inline as a `detail::LiteralString`, not copied into a
1402/// `std::string`, so the resulting node stays a literal type and the documented
1403/// `static constexpr auto formRules = ruleList(...)` form works for a literal of
1404/// any length. Stored as a `std::string`, it only worked while the text fit the
1405/// standard library's small-string buffer — see `detail::LiteralString`.
1406/// Serialisation is unaffected: `emitNode()` emits the same JSON string either
1407/// way.
1408///
1409/// @tparam V Field member type (deduced).
1410/// @tparam A Action type (deduced).
1411/// @tparam N String literal length (deduced), including the trailing `'\0'`.
1412/// @param field Pointer to the member to test.
1413/// @param literal The string literal to compare against.
1414/// @return The condition node, with the literal stored inline.
1415template <typename V, typename A, std::size_t N>
1416 requires ComparableAgainstLiteral<V, detail::LiteralString<N>>
1417[[nodiscard]] constexpr auto equals(V A::* field, const char (&literal)[N]) {
1418 return Equals<V, A, detail::LiteralString<N>>{field, detail::LiteralString<N>{literal}};
1419}
1420
1421/// @brief Rule: `field` must be engaged whenever @p Cond holds; vacuously
1422/// satisfied (not required) while the condition does not hold.
1423/// @tparam V Field member type (must satisfy `EngageableField`).
1424/// @tparam A Action type the field belongs to.
1425/// @tparam Cond Condition node type (e.g. `Engaged<V2, A>`).
1426template <typename V, typename A, typename Cond>
1428 /// @brief Pointer to the member this rule may require.
1429 V A::* field;
1430 /// @brief The condition that, when true, makes `field` required.
1431 Cond when;
1432 /// @brief The wire `"kind"` this rule emits: `"requiredWhen"`.
1433 static constexpr detail::RuleKind kind = detail::RuleKind::RequiredWhen;
1434 /// @brief Validation rule (not presentation): participates in the gate.
1435 static constexpr bool isPresentation = false;
1436
1437 /// @brief Evaluates the rule against @p action.
1438 /// @param action The action snapshot to inspect.
1439 /// @return `true` when `when` does not hold, or `field` is engaged.
1440 [[nodiscard]] constexpr bool test(const A& action) const noexcept {
1441 if (!when.test(action)) {
1442 return true;
1443 }
1444 return detail::isEngaged(action.*field);
1445 }
1446
1447 /// @brief Emits this rule's `x-rules` JSON node.
1448 /// @return `{"kind":"requiredWhen","fields":["<wire name>"],"when":{...}}`.
1449 [[nodiscard]] glz::generic_u64 emitNode() const {
1450 glz::generic_u64 node{};
1451 node["kind"] = std::string{detail::ruleKindName(kind)};
1452 glz::generic_u64::array_t fields{};
1453 fields.emplace_back(detail::resolveFieldName(field));
1454 node["fields"] = fields;
1455 node["when"] = when.emitNode();
1456 return node;
1457 }
1458};
1459
1460/// @brief Builds a `RequiredWhen<V, A, Cond>` rule: @p field becomes
1461/// required exactly when @p when holds.
1462/// @tparam V Field member type (deduced; must satisfy `EngageableField`).
1463/// @tparam A Action type (deduced).
1464/// @tparam Cond Condition node type (deduced).
1465/// @param field Pointer to the member that becomes conditionally required.
1466/// @param when The condition node (`engaged(...)`, `notEngaged(...)`, a
1467/// comparison, or `equals(...)`).
1468/// @return The rule node.
1469template <typename V, typename A, Condition Cond>
1470 requires EngageableField<V> && std::same_as<detail::ConditionActionType<Cond>, A>
1471[[nodiscard]] constexpr auto requiredWhen(V A::* field, Cond when) {
1472 return RequiredWhen<V, A, Cond>{field, when};
1473}
1474
1475/// @brief Rule: exactly one of the listed fields is engaged.
1476/// @tparam A Action type all fields belong to.
1477/// @tparam Vs Field member types, one per listed field (must each satisfy
1478/// `EngageableField`).
1479template <typename A, typename... Vs>
1481 /// @brief Pointers to the member fields this rule ranges over.
1482 std::tuple<Vs A::*...> fields;
1483 /// @brief The wire `"kind"` this rule emits: `"exactlyOneOf"`.
1484 static constexpr detail::RuleKind kind = detail::RuleKind::ExactlyOneOf;
1485 /// @brief Usable as a nested condition: this node's `test()` is a
1486 /// function of the action, so composing it into an `and`/`or`/`not`
1487 /// tree changes what that tree evaluates to.
1488 static constexpr bool isCondition = true;
1489 /// @brief Validation rule (not presentation): participates in the gate.
1490 static constexpr bool isPresentation = false;
1491
1492 /// @brief Evaluates the rule against @p action.
1493 /// @param action The action snapshot to inspect.
1494 /// @return `true` when exactly one listed field is engaged.
1495 [[nodiscard]] constexpr bool test(const A& action) const noexcept {
1496 int engagedCount = 0;
1497 std::apply([&](auto... field) { ((engagedCount += (detail::isEngaged(action.*field) ? 1 : 0)), ...); },
1498 fields);
1499 return engagedCount == 1;
1500 }
1501
1502 /// @brief Emits this rule's `x-rules` JSON node.
1503 /// @return `{"kind":"exactlyOneOf","fields":["<wire name>", ...]}`.
1504 [[nodiscard]] glz::generic_u64 emitNode() const {
1505 glz::generic_u64 node{};
1506 node["kind"] = std::string{detail::ruleKindName(kind)};
1507 glz::generic_u64::array_t names{};
1508 std::apply([&](auto... field) { (names.emplace_back(detail::resolveFieldName(field)), ...); }, fields);
1509 node["fields"] = names;
1510 return node;
1511 }
1512};
1513
1514/// @brief Builds an `ExactlyOneOf<A, Vs...>` rule over @p fields.
1515/// @tparam A Action type (deduced).
1516/// @tparam Vs Field member types (deduced; each must satisfy
1517/// `EngageableField`).
1518/// @param fields Pointers to the member fields, at least two.
1519/// @return The rule node.
1520template <typename A, typename... Vs>
1521 requires(EngageableField<Vs> && ...)
1522[[nodiscard]] constexpr auto exactlyOneOf(Vs A::*... fields) {
1523 return ExactlyOneOf<A, Vs...>{std::tuple<Vs A::*...>{fields...}};
1524}
1525
1526/// @brief Rule: at least one of the listed fields is engaged.
1527/// @tparam A Action type all fields belong to.
1528/// @tparam Vs Field member types, one per listed field (must each satisfy
1529/// `EngageableField`).
1530template <typename A, typename... Vs>
1532 /// @brief Pointers to the member fields this rule ranges over.
1533 std::tuple<Vs A::*...> fields;
1534 /// @brief The wire `"kind"` this rule emits: `"atLeastOneOf"`.
1535 static constexpr detail::RuleKind kind = detail::RuleKind::AtLeastOneOf;
1536 /// @brief Usable as a nested condition: this node's `test()` is a
1537 /// function of the action, so composing it into an `and`/`or`/`not`
1538 /// tree changes what that tree evaluates to.
1539 static constexpr bool isCondition = true;
1540 /// @brief Validation rule (not presentation): participates in the gate.
1541 static constexpr bool isPresentation = false;
1542
1543 /// @brief Evaluates the rule against @p action.
1544 /// @param action The action snapshot to inspect.
1545 /// @return `true` when at least one listed field is engaged.
1546 [[nodiscard]] constexpr bool test(const A& action) const noexcept {
1547 int engagedCount = 0;
1548 std::apply([&](auto... field) { ((engagedCount += (detail::isEngaged(action.*field) ? 1 : 0)), ...); },
1549 fields);
1550 return engagedCount >= 1;
1551 }
1552
1553 /// @brief Emits this rule's `x-rules` JSON node.
1554 /// @return `{"kind":"atLeastOneOf","fields":["<wire name>", ...]}`.
1555 [[nodiscard]] glz::generic_u64 emitNode() const {
1556 glz::generic_u64 node{};
1557 node["kind"] = std::string{detail::ruleKindName(kind)};
1558 glz::generic_u64::array_t names{};
1559 std::apply([&](auto... field) { (names.emplace_back(detail::resolveFieldName(field)), ...); }, fields);
1560 node["fields"] = names;
1561 return node;
1562 }
1563};
1564
1565/// @brief Builds an `AtLeastOneOf<A, Vs...>` rule over @p fields.
1566/// @tparam A Action type (deduced).
1567/// @tparam Vs Field member types (deduced; each must satisfy
1568/// `EngageableField`).
1569/// @param fields Pointers to the member fields, at least two.
1570/// @return The rule node.
1571template <typename A, typename... Vs>
1572 requires(EngageableField<Vs> && ...)
1573[[nodiscard]] constexpr auto atLeastOneOf(Vs A::*... fields) {
1574 return AtLeastOneOf<A, Vs...>{std::tuple<Vs A::*...>{fields...}};
1575}
1576
1577/// @brief Rule: at most one of the listed fields is engaged.
1578/// @tparam A Action type all fields belong to.
1579/// @tparam Vs Field member types, one per listed field (must each satisfy
1580/// `EngageableField`).
1581template <typename A, typename... Vs>
1583 /// @brief Pointers to the member fields this rule ranges over.
1584 std::tuple<Vs A::*...> fields;
1585 /// @brief The wire `"kind"` this rule emits: `"mutuallyExclusive"`.
1586 static constexpr detail::RuleKind kind = detail::RuleKind::MutuallyExclusive;
1587 /// @brief Usable as a nested condition: this node's `test()` is a
1588 /// function of the action, so composing it into an `and`/`or`/`not`
1589 /// tree changes what that tree evaluates to.
1590 static constexpr bool isCondition = true;
1591 /// @brief Validation rule (not presentation): participates in the gate.
1592 static constexpr bool isPresentation = false;
1593
1594 /// @brief Evaluates the rule against @p action.
1595 /// @param action The action snapshot to inspect.
1596 /// @return `true` when at most one listed field is engaged.
1597 [[nodiscard]] constexpr bool test(const A& action) const noexcept {
1598 int engagedCount = 0;
1599 std::apply([&](auto... field) { ((engagedCount += (detail::isEngaged(action.*field) ? 1 : 0)), ...); },
1600 fields);
1601 return engagedCount <= 1;
1602 }
1603
1604 /// @brief Emits this rule's `x-rules` JSON node.
1605 /// @return `{"kind":"mutuallyExclusive","fields":["<wire name>", ...]}`.
1606 [[nodiscard]] glz::generic_u64 emitNode() const {
1607 glz::generic_u64 node{};
1608 node["kind"] = std::string{detail::ruleKindName(kind)};
1609 glz::generic_u64::array_t names{};
1610 std::apply([&](auto... field) { (names.emplace_back(detail::resolveFieldName(field)), ...); }, fields);
1611 node["fields"] = names;
1612 return node;
1613 }
1614};
1615
1616/// @brief Builds a `MutuallyExclusive<A, Vs...>` rule over @p fields.
1617/// @tparam A Action type (deduced).
1618/// @tparam Vs Field member types (deduced; each must satisfy
1619/// `EngageableField`).
1620/// @param fields Pointers to the member fields, at least two.
1621/// @return The rule node.
1622template <typename A, typename... Vs>
1623 requires(EngageableField<Vs> && ...)
1624[[nodiscard]] constexpr auto mutuallyExclusive(Vs A::*... fields) {
1625 return MutuallyExclusive<A, Vs...>{std::tuple<Vs A::*...>{fields...}};
1626}
1627
1628/// @brief Presentation rule: the listed field is shown only while @p Cond
1629/// holds. Never gates `allRulesSatisfied` — while hidden, the field's
1630/// current draft value still travels in the payload (hiding never clears
1631/// it), exactly like a static `x-hidden` field.
1632/// @tparam V Field member type.
1633/// @tparam A Action type the field belongs to.
1634/// @tparam Cond Condition node type.
1635template <typename V, typename A, typename Cond>
1637 /// @brief Pointer to the member whose visibility this rule controls.
1638 V A::* field;
1639 /// @brief The condition that, when true, makes `field` visible.
1640 Cond when;
1641 /// @brief The wire `"kind"` this rule emits: `"visibleWhen"`.
1642 static constexpr detail::RuleKind kind = detail::RuleKind::VisibleWhen;
1643 /// @brief Presentation rule: never participates in the gate.
1644 static constexpr bool isPresentation = true;
1645
1646 /// @brief Always `true`: presentation rules never gate submission. A
1647 /// renderer inspects `when` directly (not this method) to decide the
1648 /// field's visibility.
1649 /// @param action Unused (kept for interface uniformity with every other
1650 /// rule node).
1651 /// @return `true`, unconditionally.
1652 [[nodiscard]] constexpr bool test(const A& action) const noexcept {
1653 static_cast<void>(action);
1654 return true;
1655 }
1656
1657 /// @brief Emits this rule's `x-rules` JSON node.
1658 /// @return `{"kind":"visibleWhen","fields":["<wire name>"],"when":{...}}`.
1659 [[nodiscard]] glz::generic_u64 emitNode() const {
1660 glz::generic_u64 node{};
1661 node["kind"] = std::string{detail::ruleKindName(kind)};
1662 glz::generic_u64::array_t fields{};
1663 fields.emplace_back(detail::resolveFieldName(field));
1664 node["fields"] = fields;
1665 node["when"] = when.emitNode();
1666 return node;
1667 }
1668};
1669
1670/// @brief Builds a `VisibleWhen<V, A, Cond>` presentation rule: @p field is
1671/// shown only while @p when holds.
1672/// @tparam V Field member type (deduced).
1673/// @tparam A Action type (deduced).
1674/// @tparam Cond Condition node type (deduced).
1675/// @param field Pointer to the member whose visibility is controlled.
1676/// @param when The condition node.
1677/// @return The rule node.
1678template <typename V, typename A, Condition Cond>
1679 requires std::same_as<detail::ConditionActionType<Cond>, A>
1680[[nodiscard]] constexpr auto visibleWhen(V A::* field, Cond when) {
1681 return VisibleWhen<V, A, Cond>{field, when};
1682}
1683
1684/// @brief Presentation rule: the listed field is editable only while
1685/// @p Cond does **not** hold (the field is read-only while `when` holds).
1686/// Never gates `allRulesSatisfied`, exactly like `VisibleWhen`.
1687/// @tparam V Field member type.
1688/// @tparam A Action type the field belongs to.
1689/// @tparam Cond Condition node type.
1690template <typename V, typename A, typename Cond>
1692 /// @brief Pointer to the member whose editability this rule controls.
1693 V A::* field;
1694 /// @brief The condition that, when true, makes `field` read-only.
1695 Cond when;
1696 /// @brief The wire `"kind"` this rule emits: `"readonlyWhen"`.
1697 static constexpr detail::RuleKind kind = detail::RuleKind::ReadonlyWhen;
1698 /// @brief Presentation rule: never participates in the gate.
1699 static constexpr bool isPresentation = true;
1700
1701 /// @brief Always `true`: presentation rules never gate submission. A
1702 /// renderer inspects `when` directly (not this method) to decide the
1703 /// field's editability.
1704 /// @param action Unused (kept for interface uniformity with every other
1705 /// rule node).
1706 /// @return `true`, unconditionally.
1707 [[nodiscard]] constexpr bool test(const A& action) const noexcept {
1708 static_cast<void>(action);
1709 return true;
1710 }
1711
1712 /// @brief Emits this rule's `x-rules` JSON node.
1713 /// @return `{"kind":"readonlyWhen","fields":["<wire name>"],"when":{...}}`.
1714 [[nodiscard]] glz::generic_u64 emitNode() const {
1715 glz::generic_u64 node{};
1716 node["kind"] = std::string{detail::ruleKindName(kind)};
1717 glz::generic_u64::array_t fields{};
1718 fields.emplace_back(detail::resolveFieldName(field));
1719 node["fields"] = fields;
1720 node["when"] = when.emitNode();
1721 return node;
1722 }
1723};
1724
1725/// @brief Builds a `ReadonlyWhen<V, A, Cond>` presentation rule: @p field
1726/// is editable only while @p when does **not** hold.
1727/// @tparam V Field member type (deduced).
1728/// @tparam A Action type (deduced).
1729/// @tparam Cond Condition node type (deduced).
1730/// @param field Pointer to the member whose editability is controlled.
1731/// @param when The condition node.
1732/// @return The rule node.
1733template <typename V, typename A, Condition Cond>
1734 requires std::same_as<detail::ConditionActionType<Cond>, A>
1735[[nodiscard]] constexpr auto readonlyWhen(V A::* field, Cond when) {
1736 return ReadonlyWhen<V, A, Cond>{field, when};
1737}
1738
1739/// @brief Compound condition: all of `Conds...` hold. Nests to any depth —
1740/// each `Cond` may itself be a leaf (`Engaged`, `Equals`, a comparison, …) or
1741/// another `And`/`Or`/`Not`. Usable both as a nested `when` clause and
1742/// directly as a top-level `formRules` entry (it declares `isPresentation`
1743/// and `test()` exactly like every other validation rule), which is what
1744/// lets a single rule carry a compound condition tree instead of factoring
1745/// the composition into multiple single-condition rules.
1746/// @tparam A Action type every nested condition ranges over.
1747/// @tparam Conds Nested condition node types, at least two.
1748template <typename A, typename... Conds>
1749struct And {
1750 /// @brief The nested conditions, in declaration order.
1751 std::tuple<Conds...> conditions;
1752 /// @brief The wire `"kind"` this node emits: `"and"`.
1753 static constexpr detail::RuleKind kind = detail::RuleKind::And;
1754 /// @brief Usable as a nested condition: this node's `test()` is a
1755 /// function of the action, so composing it into an `and`/`or`/`not`
1756 /// tree changes what that tree evaluates to.
1757 static constexpr bool isCondition = true;
1758 /// @brief Validation rule (not presentation): participates in the gate
1759 /// when used as a top-level `formRules` entry.
1760 static constexpr bool isPresentation = false;
1761
1762 /// @brief Evaluates the condition against @p action.
1763 /// @param action The action snapshot to inspect.
1764 /// @return `true` when every nested condition holds.
1765 [[nodiscard]] constexpr bool test(const A& action) const noexcept {
1766 return std::apply([&](const auto&... cond) { return (cond.test(action) && ...); }, conditions);
1767 }
1768
1769 /// @brief Emits this condition's `x-rules` JSON node.
1770 /// @return `{"kind":"and","conditions":[{...}, ...]}`.
1771 [[nodiscard]] glz::generic_u64 emitNode() const {
1772 glz::generic_u64 node{};
1773 node["kind"] = std::string{detail::ruleKindName(kind)};
1774 glz::generic_u64::array_t nested{};
1775 std::apply([&](const auto&... cond) { (nested.emplace_back(cond.emitNode()), ...); }, conditions);
1776 node["conditions"] = nested;
1777 return node;
1778 }
1779};
1780
1781/// @brief Builds an `And<A, Conds...>` condition: every listed condition must
1782/// hold.
1783/// @tparam Cond0 First condition's node type (deduced); its action type `A`
1784/// is recovered from `test()` and shared by every other node.
1785/// @tparam Conds Remaining nested condition node types (deduced).
1786/// @param condition0 The first nested condition.
1787/// @param conditions The remaining nested conditions, at least one more.
1788/// @return The compound condition node.
1789///
1790/// The action type is recovered from `Cond0` alone, so every other operand is
1791/// required to agree with it here rather than failing deep inside
1792/// `std::tuple`'s instantiation: `andOf(engaged(&C::x), engaged(&B::y))`
1793/// produced 153 lines whose first error was inside `<type_traits>` and which
1794/// named the caller's own `formRules` line only near the end.
1795template <Condition Cond0, Condition... Conds>
1796 requires(std::same_as<detail::ConditionActionType<Cond0>, detail::ConditionActionType<Conds>> && ...)
1797[[nodiscard]] constexpr auto andOf(Cond0 condition0, Conds... conditions) {
1798 static_assert(sizeof...(Conds) >= 1, "andOf: needs at least two conditions; one operand is the condition itself");
1799 return And<detail::ConditionActionType<Cond0>, Cond0, Conds...>{
1800 std::tuple<Cond0, Conds...>{std::move(condition0), std::move(conditions)...}};
1801}
1802
1803/// @brief Compound condition: at least one of `Conds...` holds. Nests to any
1804/// depth, and is usable directly as a top-level `formRules` entry, exactly
1805/// like `And`.
1806/// @tparam A Action type every nested condition ranges over.
1807/// @tparam Conds Nested condition node types, at least two.
1808template <typename A, typename... Conds>
1809struct Or {
1810 /// @brief The nested conditions, in declaration order.
1811 std::tuple<Conds...> conditions;
1812 /// @brief The wire `"kind"` this node emits: `"or"`.
1813 static constexpr detail::RuleKind kind = detail::RuleKind::Or;
1814 /// @brief Usable as a nested condition: this node's `test()` is a
1815 /// function of the action, so composing it into an `and`/`or`/`not`
1816 /// tree changes what that tree evaluates to.
1817 static constexpr bool isCondition = true;
1818 /// @brief Validation rule (not presentation): participates in the gate
1819 /// when used as a top-level `formRules` entry.
1820 static constexpr bool isPresentation = false;
1821
1822 /// @brief Evaluates the condition against @p action.
1823 /// @param action The action snapshot to inspect.
1824 /// @return `true` when at least one nested condition holds.
1825 [[nodiscard]] constexpr bool test(const A& action) const noexcept {
1826 return std::apply([&](const auto&... cond) { return (cond.test(action) || ...); }, conditions);
1827 }
1828
1829 /// @brief Emits this condition's `x-rules` JSON node.
1830 /// @return `{"kind":"or","conditions":[{...}, ...]}`.
1831 [[nodiscard]] glz::generic_u64 emitNode() const {
1832 glz::generic_u64 node{};
1833 node["kind"] = std::string{detail::ruleKindName(kind)};
1834 glz::generic_u64::array_t nested{};
1835 std::apply([&](const auto&... cond) { (nested.emplace_back(cond.emitNode()), ...); }, conditions);
1836 node["conditions"] = nested;
1837 return node;
1838 }
1839};
1840
1841/// @brief Builds an `Or<A, Conds...>` condition: at least one listed
1842/// condition must hold.
1843/// @tparam Cond0 First condition's node type (deduced); its action type `A`
1844/// is recovered from `test()` and shared by every other node.
1845/// @tparam Conds Remaining nested condition node types (deduced).
1846/// @param condition0 The first nested condition.
1847/// @param conditions The remaining nested conditions, at least one more.
1848/// @return The compound condition node.
1849/// Constrained exactly as `andOf` is, and for the same reasons.
1850template <Condition Cond0, Condition... Conds>
1851 requires(std::same_as<detail::ConditionActionType<Cond0>, detail::ConditionActionType<Conds>> && ...)
1852[[nodiscard]] constexpr auto orOf(Cond0 condition0, Conds... conditions) {
1853 static_assert(sizeof...(Conds) >= 1, "orOf: needs at least two conditions; one operand is the condition itself");
1854 return Or<detail::ConditionActionType<Cond0>, Cond0, Conds...>{
1855 std::tuple<Cond0, Conds...>{std::move(condition0), std::move(conditions)...}};
1856}
1857
1858/// @brief Compound condition: the nested condition does **not** hold. Nests
1859/// to any depth, and is usable directly as a top-level `formRules` entry,
1860/// exactly like `And`/`Or`.
1861/// @tparam A Action type the nested condition ranges over.
1862/// @tparam Cond Nested condition node type.
1863template <typename A, typename Cond>
1864struct Not {
1865 /// @brief The negated condition.
1867 /// @brief The wire `"kind"` this node emits: `"not"`.
1868 static constexpr detail::RuleKind kind = detail::RuleKind::Not;
1869 /// @brief Usable as a nested condition: this node's `test()` is a
1870 /// function of the action, so composing it into an `and`/`or`/`not`
1871 /// tree changes what that tree evaluates to.
1872 static constexpr bool isCondition = true;
1873 /// @brief Validation rule (not presentation): participates in the gate
1874 /// when used as a top-level `formRules` entry.
1875 static constexpr bool isPresentation = false;
1876
1877 /// @brief Evaluates the condition against @p action.
1878 /// @param action The action snapshot to inspect.
1879 /// @return `true` when the nested condition does **not** hold.
1880 [[nodiscard]] constexpr bool test(const A& action) const noexcept { return !condition.test(action); }
1881
1882 /// @brief Emits this condition's `x-rules` JSON node.
1883 /// @return `{"kind":"not","condition":{...}}`.
1884 [[nodiscard]] glz::generic_u64 emitNode() const {
1885 glz::generic_u64 node{};
1886 node["kind"] = std::string{detail::ruleKindName(kind)};
1887 node["condition"] = condition.emitNode();
1888 return node;
1889 }
1890};
1891
1892/// @brief Builds a `Not<A, Cond>` condition: negates @p condition.
1893/// @tparam Cond Nested condition node type (deduced); its action type `A` is
1894/// recovered from `test()`.
1895/// @param condition The condition to negate.
1896/// @return The compound condition node.
1897template <Condition Cond>
1898[[nodiscard]] constexpr auto notOf(Cond condition) {
1899 return Not<detail::ConditionActionType<Cond>, Cond>{std::move(condition)};
1900}
1901
1902/// @brief Composed list of an action's declared cross-field rules — the
1903/// value of `A::formRules`. Built by `ruleList(...)`; never constructed
1904/// directly.
1905/// @tparam Rules Rule node types, in declaration order.
1906template <typename... Rules>
1907struct RuleList {
1908 /// @brief The declared rules, in declaration order.
1909 std::tuple<Rules...> rules;
1910};
1911
1912/// @brief Composes @p rules into the `RuleList` an action assigns to its
1913/// `static constexpr formRules` member.
1914/// @tparam Rules Rule node types (deduced).
1915/// @param rules The rule nodes, in the order they should be evaluated and
1916/// emitted.
1917/// @return The composed `RuleList<Rules...>`.
1918template <typename... Rules>
1919[[nodiscard]] constexpr auto ruleList(Rules... rules) {
1920 return RuleList<Rules...>{std::tuple<Rules...>{std::move(rules)...}};
1921}
1922
1923/// @brief Concept: action `A` declares a `static constexpr` `formRules`
1924/// member (a `RuleList<...>`). Mirrors `detail::HasOptionalFields`.
1925template <typename A>
1926concept HasFormRules = requires { A::formRules; };
1927
1928/// @brief Concept: action `A` declares a `static constexpr bool
1929/// explicitSubmit`, opting its form out of auto-submit-on-validity.
1930///
1931/// Opt-in, like `formLayout`/`fieldSpans`/`formRules`: an action that says
1932/// nothing keeps the renderer's default (submit as soon as the form is valid),
1933/// so adding this changes no existing schema. Declaring it `true` emits
1934/// `x-submitMode: "explicit"`; declaring it `false` emits nothing, which is
1935/// the same as not declaring it at all.
1936///
1937/// A side-effectful action wants this. Auto-submit fires on *every* keystroke
1938/// that leaves the form valid, so a `CreatePaste` bound directly to a live
1939/// controller stores one paste per typed character.
1940///
1941/// (The template parameter is intentionally left undocumented: clang's
1942/// -Wdocumentation does not consider a concept a template declaration, so a
1943/// parameter-doc command here is an error under -Weverything -Werror, which is
1944/// how the WASM ladder builds. `HasFormRules` above omits it for the same
1945/// reason. Note the command cannot even be *named* in this comment -- clang
1946/// parses it inside backticks too.)
1947template <typename A>
1948concept HasExplicitSubmit = requires {
1949 { A::explicitSubmit } -> std::convertible_to<bool>;
1950};
1951
1952namespace detail {
1953
1954/// @brief Evaluates @p rule against @p action, skipping presentation rules
1955/// (`VisibleWhen` / `ReadonlyWhen`) by construction —
1956/// they never gate.
1957template <typename Rule, typename A>
1958[[nodiscard]] constexpr bool evaluateGatingRule(const Rule& rule, const A& action) noexcept {
1959 if constexpr (Rule::isPresentation) {
1960 static_cast<void>(rule);
1961 static_cast<void>(action);
1962 return true;
1963 } else {
1964 return rule.test(action);
1965 }
1966}
1967
1968} // namespace detail
1969
1970/// @brief Whether every **validation** rule in `A::formRules` holds for
1971/// @p action. Presentation rules (`visibleWhen` / `readonlyWhen`) are
1972/// skipped — they can never fail this check. Returns `true` unconditionally
1973/// for an action with no `formRules` (safe to call from every action's
1974/// `validate()` regardless of whether it declares rules).
1975/// @tparam A Action type.
1976/// @param action Action snapshot to check.
1977/// @return `true` when every validation rule holds (or there are none).
1978template <typename A>
1979[[nodiscard]] constexpr bool allRulesSatisfied(const A& action) noexcept {
1980 if constexpr (HasFormRules<A>) {
1981 return std::apply([&](const auto&... rule) { return (detail::evaluateGatingRule(rule, action) && ...); },
1982 A::formRules.rules);
1983 } else {
1984 static_cast<void>(action);
1985 return true;
1986 }
1987}
1988
1989/// @brief Thrown by `schemaJson<A>()` when `A`'s declared `formRules`
1990/// contradict `A`'s own derived `required` array so completely that no
1991/// submission could satisfy both.
1992///
1993/// The one contradiction that is decidable from the two halves alone: a rule
1994/// that **caps** how many of the fields it ranges over may be engaged at once
1995/// (`exactlyOneOf`, `mutuallyExclusive` — never `atLeastOneOf`, which is a
1996/// floor and can always be met by engaging everything) ranging over two or
1997/// more fields that `required` also demands. `required` says "fill in both";
1998/// the rule says "at most one"; the form is dead on arrival.
1999///
2000/// This is a **programming error in the action's declaration**, not a runtime
2001/// condition — hence `std::logic_error`, and hence a throw rather than the
2002/// silent tolerance schema generation extends to a `formLayout` entry naming
2003/// an unknown field. The fix is to name the rule's fields in
2004/// `A::optionalFields` (the rule is then the only gate on them) or to drop
2005/// the rule.
2006struct UnsatisfiableFormError : std::logic_error {
2007 /// @brief Constructs the error with a message naming the action type, the
2008 /// offending rule kind, and the fields that are both required and
2009 /// capped by it.
2010 /// @param actionName The action type's name (`glz::name_v<A>`).
2011 /// @param ruleKind The offending rule's wire `"kind"` string, e.g.
2012 /// `"exactlyOneOf"`.
2013 /// @param requiredFields Comma-separated wire names of the fields that are
2014 /// in `required` *and* ranged over by the rule.
2015 UnsatisfiableFormError(std::string_view actionName, std::string_view ruleKind, std::string_view requiredFields)
2016 : std::logic_error("morph::forms: unsatisfiable form for action '" + std::string{actionName} + "': rule '" +
2017 std::string{ruleKind} +
2018 "' caps how many of its fields may be engaged, but these are also "
2019 "in the schema's required array: " +
2020 std::string{requiredFields} +
2021 ". No submission can satisfy both. List them in the action's optionalFields, or drop the "
2022 "rule.") {}
2023};
2024
2025/// @brief Thrown by schema generation when a `Choice<…>` member's `FieldMeta` also sets `optionsAction`: the member's
2026/// type already names where its options come from, and two sources cannot both hold.
2027///
2028/// A programming error in the action's declaration, so a `std::logic_error`, thrown when the action registers. The
2029/// fix is to drop `optionsAction` from the meta, or to make the member a plain type.
2030struct ConflictingOptionsError : std::logic_error {
2031 /// @brief Constructs the error naming the action type and the member.
2032 /// @param actionName The type declaring the member (`glz::name_v<Owner>`).
2033 /// @param field The member's wire name.
2034 // NOLINTNEXTLINE(bugprone-easily-swappable-parameters): the type, then its member, as a message names them.
2035 ConflictingOptionsError(std::string_view actionName, std::string_view field)
2036 : std::logic_error("morph::forms: '" + std::string{actionName} + "." + std::string{field} +
2037 "' is a Choice and its FieldMeta also sets optionsAction; the Choice already names its "
2038 "options action. Drop optionsAction from the FieldMeta, or make the member a plain type.") {
2039 }
2040};
2041
2042/// @brief Thrown by schema generation when a `FieldMeta` sets `optionsAction` without both `valueField` and
2043/// `labelField`, or either of those without `optionsAction`: options need the action, the value field and the
2044/// label field together.
2045///
2046/// A programming error in the action's declaration, so a `std::logic_error`, thrown when the action registers.
2047struct IncompleteOptionsError : std::logic_error {
2048 /// @brief Constructs the error naming the action type, the member and what it lacks.
2049 /// @param actionName The type declaring the member (`glz::name_v<Owner>`).
2050 /// @param field The member's wire name.
2051 /// @param missing The `FieldMeta` members that must be set and are not.
2052 // NOLINTNEXTLINE(bugprone-easily-swappable-parameters): the type, its member and what is missing, in that order.
2053 IncompleteOptionsError(std::string_view actionName, std::string_view field, std::string_view missing)
2054 : std::logic_error("morph::forms: '" + std::string{actionName} + "." + std::string{field} +
2055 "' declares options in its FieldMeta without " + std::string{missing} +
2056 "; set optionsAction, valueField and labelField together.") {}
2057};
2058
2059namespace detail {
2060
2061/// @brief One computed-field declaration: binds a destination member to the
2062/// input members it derives from and a pure derivation function.
2063///
2064/// Built by `morph::forms::computed<Dst, Inputs...>(fn)`; never named directly
2065/// by user code. `Dst` and `Inputs...` are pointer-to-data-member NTTPs (so a
2066/// renamed or deleted field is a compile error); `Fn` is the deduced callable
2067/// type of the pure derivation `fn(const A&) -> ValueOfDst`.
2068/// @tparam Dst Pointer-to-data-member of the derived (destination) field.
2069/// @tparam Fn Deduced callable type of the derivation function.
2070/// @tparam Inputs Pointer-to-data-members of the fields the derivation reads.
2071template <auto Dst, typename Fn, auto... Inputs>
2072struct ComputedField {
2073 /// @brief The pure derivation: `ValueOfDst(const A&)`.
2074 Fn fn;
2075};
2076
2077/// @brief Concept: action declares a `static constexpr computedFields` member
2078/// (a `ComputeList` built by `morph::forms::computeList(...)`).
2079template <typename A>
2080concept HasComputedFields = requires { A::computedFields; };
2081
2082/// @brief Ordered collection of `ComputedField` declarations for one action type.
2083///
2084/// Built by `morph::forms::computeList(...)`; never named directly by user code.
2085/// @tparam Fields Deduced `ComputedField<...>` types, one per declared entry.
2086template <typename... Fields>
2087struct ComputeList {
2088 /// @brief The declarations, in declaration order.
2089 std::tuple<Fields...> fields;
2090};
2091
2092/// @brief Whether @p memberAddr is the destination address of @p field.
2093/// @tparam A Action type (a reflectable aggregate).
2094/// @tparam Dst Pointer-to-data-member of @p field's destination.
2095/// @tparam Fn Callable type of @p field's derivation function.
2096/// @tparam Inputs Pointer-to-data-members of @p field's declared inputs.
2097/// @param action The action instance @p memberAddr was taken from.
2098/// @param memberAddr Address of the member being tested.
2099/// @param field The computed-field declaration to test against.
2100/// @return `true` if `memberAddr == std::addressof(action.*Dst)`.
2101template <typename A, auto Dst, typename Fn, auto... Inputs>
2102[[nodiscard]] constexpr bool isDestinationOf(const A& action, const void* memberAddr,
2103 const ComputedField<Dst, Fn, Inputs...>& field) noexcept {
2104 static_cast<void>(field);
2105 return memberAddr == static_cast<const void*>(std::addressof(action.*Dst));
2106}
2107
2108/// @brief Whether @p memberAddr is the address of the destination member of
2109/// any entry in `A::computedFields`.
2110///
2111/// Used by `allRequiredEngaged` to exclude computed destinations from
2112/// required-ness the same way the schema's `required` array excludes them
2113/// (see `mergeSchemaExtras`). Compares addresses (not names) because the
2114/// caller already has a live member reference from `forEachNamedMember`, and
2115/// `Dst` gives a member reference on the *same* action instance via
2116/// `action.*Dst`.
2117/// @tparam A Action type (a reflectable aggregate).
2118/// @param action The action instance @p memberAddr was taken from.
2119/// @param memberAddr Address of the member being tested.
2120/// @return `true` if @p memberAddr is a computed destination; always `false`
2121/// when `A` declares no `computedFields`.
2122template <typename A>
2123[[nodiscard]] constexpr bool isComputedDestinationMember(const A& action, const void* memberAddr) noexcept {
2124 if constexpr (HasComputedFields<A>) {
2125 bool found = false;
2126 std::apply([&](const auto&... field) { ((found = found || isDestinationOf(action, memberAddr, field)), ...); },
2127 action.computedFields.fields);
2128 return found;
2129 } else {
2130 static_cast<void>(action);
2131 static_cast<void>(memberAddr);
2132 return false;
2133 }
2134}
2135
2136/// @brief Evaluates one `ComputedField` against @p action, writing the result
2137/// into the destination member in place.
2138///
2139/// If every declared input is engaged -- or is not itself empty-capable, in
2140/// which case it is always considered engaged, mirroring `allRequiredEngaged`'s
2141/// treatment of non-empty-capable members -- the destination member is
2142/// overwritten with `field.fn(action)`. For a `Quantity` destination the
2143/// result is first converted to the destination's own type (same unit, the
2144/// destination's own `DeclaredDecimals`) and then **rounded** to that type's
2145/// declared precision (`Quantity::atDeclaredPrecision()`), so the stored value
2146/// matches the field's advertised `x-decimalPlaces` regardless of what
2147/// declared precision `Fn`'s return type happened to carry -- and regardless of
2148/// how many decimals the derivation itself produced (a product of two 2-decimal
2149/// operands is exact to 4). If any declared input is unengaged, the destination
2150/// is instead reset to its default-constructed (empty, for
2151/// `Quantity`/`Choice`/`Timestamp`) value rather than computed from a missing
2152/// operand.
2153/// @tparam A Action type (a reflectable aggregate).
2154/// @tparam Dst Pointer-to-data-member of the destination field.
2155/// @tparam Fn Callable type of the derivation function.
2156/// @tparam Inputs Pointer-to-data-members of the declared input fields.
2157/// @param action Draft action whose destination member is overwritten in place.
2158/// @param field The declaration being evaluated.
2159template <typename A, auto Dst, typename Fn, auto... Inputs>
2160constexpr void recomputeOne(A& action, const ComputedField<Dst, Fn, Inputs...>& field) {
2161 bool allEngaged = true;
2162 [[maybe_unused]] auto checkInput = [&]<auto InputPtr>() {
2163 using InputMember = std::remove_cvref_t<decltype(action.*InputPtr)>;
2164 if constexpr (EmptyCapableField<InputMember>) {
2165 if (!(action.*InputPtr).hasValue()) {
2166 allEngaged = false;
2167 }
2168 }
2169 };
2170 (checkInput.template operator()<Inputs>(), ...);
2171
2172 using DstMember = std::remove_cvref_t<decltype(action.*Dst)>;
2173 if (!allEngaged) {
2174 action.*Dst = DstMember{};
2175 return;
2176 }
2177 auto result = field.fn(action);
2178 if constexpr (units::isQuantity<DstMember>) {
2179 DstMember const converted = result;
2180 action.*Dst = converted.atDeclaredPrecision();
2181 } else {
2182 action.*Dst = result;
2183 }
2184}
2185
2186/// @brief Resolves the wire (JSON) field name of a pointer-to-member by
2187/// locating the reflected member of @p probe whose address matches
2188/// `probe.*memberPtr`.
2189///
2190/// Translates the compile-time pointer-to-member NTTPs a `ComputedField`
2191/// carries into the wire field names `x-computed` reports -- the same names
2192/// `x-order`/`required` already key on.
2193/// @tparam A Action type (a reflectable aggregate).
2194/// @tparam MemberPtr Deduced pointer-to-data-member type.
2195/// @param probe A default-constructed instance of @p A.
2196/// @param memberPtr Pointer-to-data-member of @p A to resolve.
2197/// @return The member's reflected name (never empty in practice: @p memberPtr
2198/// always names a member of @p A).
2199template <typename A, typename MemberPtr>
2200[[nodiscard]] std::string_view resolveMemberName(const A& probe, MemberPtr memberPtr) {
2201 std::string_view result;
2202 forEachNamedMember(probe, [&]<std::size_t I>(std::string_view name, const auto& member) {
2203 static_cast<void>(I);
2204 if (static_cast<const void*>(std::addressof(member)) ==
2205 static_cast<const void*>(std::addressof(probe.*memberPtr))) {
2206 result = name;
2207 }
2208 });
2209 return result;
2210}
2211
2212/// @brief Records one `ComputedField`'s destination -> ordered input wire
2213/// names into @p out, resolved against @p probe.
2214/// @tparam A Action type (a reflectable aggregate).
2215/// @tparam Dst Pointer-to-data-member of the destination field.
2216/// @tparam Fn Callable type of the derivation function.
2217/// @tparam Inputs Pointer-to-data-members of the declared input fields.
2218/// @param probe Default-constructed instance of @p A used purely for name resolution.
2219/// @param field The declaration to record (its `fn` is not invoked here).
2220/// @param out Map from destination wire name to its ordered input wire names.
2221template <typename A, auto Dst, typename Fn, auto... Inputs>
2222void collectComputedInputs(const A& probe, const ComputedField<Dst, Fn, Inputs...>& field,
2223 std::unordered_map<std::string_view, std::vector<std::string_view>>& out) {
2224 static_cast<void>(field);
2225 out.emplace(resolveMemberName(probe, Dst), std::vector<std::string_view>{resolveMemberName(probe, Inputs)...});
2226}
2227
2228/// @brief Trait: is `T` a `std::vector<...>`? Exposes the element type as
2229/// `ValueType` (`void` when `T` is not a vector).
2230template <typename T>
2231struct IsStdVector : std::false_type {
2232 using ValueType = void;
2233};
2234
2235template <typename T, typename Alloc>
2236struct IsStdVector<std::vector<T, Alloc>> : std::true_type {
2237 using ValueType = T;
2238};
2239
2240/// @brief Concept: `T` is glaze-reflectable as a JSON object -- the same test
2241/// that decides whether glaze emits a member into `$defs`/`$ref`
2242/// rather than inline. Shared by the nested-aggregate recursion below
2243/// and `reconcileDeclaredPrecision` elsewhere.
2244template <typename T>
2245concept ReflectableAggregate = glz::reflectable<T> || glz::glaze_object_t<T>;
2246
2247/// @brief Concept: an integral member whose every value is exactly
2248/// representable as a `math::Rational` numerator, so a declared
2249/// `FieldMeta` bound can be *checked* against it in C++.
2250///
2251/// That is every signed integer type, plus every unsigned one narrower than
2252/// `std::int64_t`. A `std::uint64_t` member is deliberately excluded: half its
2253/// range has no `Rational` spelling, and silently checking the other half
2254/// would be a bound that holds for some values of a field and not others.
2255/// Such a member still *carries* its declared bound into the served schema —
2256/// only the C++ predicate stands aside (docs/spec/forms/forms.md, "Per-field
2257/// scalar bounds").
2258template <typename T>
2259concept BoundCheckableInteger =
2260 std::integral<T> && !std::same_as<T, bool> && (std::signed_integral<T> || std::numeric_limits<T>::digits < 64);
2261
2262/// @brief Writes one declared bound onto a property node under @p key.
2263///
2264/// An integral bound is emitted as an integer, not as a `double`: that is what
2265/// glaze's own `minimum`/`maximum` are, so `annotateExactNumericBounds` then
2266/// gives a bound beyond 2^53 the same `x-exactMinimum`/`x-exactMaximum`
2267/// companion it gives a compiled one, with nothing here to special-case. A
2268/// non-integral bound has no exact JSON-number spelling and is emitted as its
2269/// quotient — the renderer's live gate is an approximation either way, and
2270/// `allFieldBoundsSatisfied` is the exact check (docs/spec/forms/forms.md,
2271/// "Per-instance constraints" makes the same trade for `x-minimum`).
2272/// @param property Property node to annotate in place.
2273/// @param key Schema key to write: `"minimum"`, `"maximum"` or `"multipleOf"`.
2274/// @param bound The declared bound.
2275// NOLINTBEGIN(cppcoreguidelines-pro-bounds-avoid-unchecked-container-access) -- glaze DOM requires operator[]
2276inline void emitDeclaredBound(glz::generic_u64& property, const std::string& key,
2277 const ::morph::math::Rational& bound) {
2278 if (bound.isInteger()) {
2279 property[key] = bound.numerator;
2280 } else {
2281 property[key] = static_cast<double>(bound.numerator) / static_cast<double>(bound.denominator);
2282 }
2283}
2284// NOLINTEND(cppcoreguidelines-pro-bounds-avoid-unchecked-container-access)
2285
2286/// @brief Whether @p meta declares any of the three numeric bounds.
2287/// @param meta The field descriptor to inspect.
2288/// @return `true` when at least one of `minimum`/`maximum`/`multipleOf` is engaged.
2289[[nodiscard]] constexpr bool declaresAnyBound(const FieldMeta& meta) noexcept {
2290 return meta.minimum.has_value() || meta.maximum.has_value() || meta.multipleOf.has_value();
2291}
2292
2293/// @brief Whether a `multipleOf` declaration is usable — JSON Schema requires
2294/// it to be strictly positive, and a zero divisor has no meaning.
2295/// @param meta The field descriptor to inspect.
2296/// @return `true` when `multipleOf` is engaged and greater than zero.
2297[[nodiscard]] constexpr bool hasUsableMultipleOf(const FieldMeta& meta) noexcept {
2298 return meta.multipleOf.has_value() && !meta.multipleOf->isZero() && !meta.multipleOf->isNegative();
2299}
2300
2301/// @brief Stamps whichever of `minimum`/`maximum`/`multipleOf` @p meta
2302/// declares onto @p property.
2303///
2304/// A free function rather than three more branches inside
2305/// `annotateBasicMemberProperty`, which is already long enough that
2306/// clang-tidy's cognitive-complexity check is the next thing to fire (the same
2307/// reasoning `annotateSubmitMode` was split out for).
2308/// @param property Property node to annotate in place.
2309/// @param meta The field's declared metadata.
2310inline void annotateDeclaredBounds(glz::generic_u64& property, const FieldMeta& meta) {
2311 if (meta.minimum.has_value()) {
2312 emitDeclaredBound(property, "minimum", *meta.minimum);
2313 }
2314 if (meta.maximum.has_value()) {
2315 emitDeclaredBound(property, "maximum", *meta.maximum);
2316 }
2317 if (hasUsableMultipleOf(meta)) {
2318 emitDeclaredBound(property, "multipleOf", *meta.multipleOf);
2319 }
2320}
2321
2322/// @brief Whether a member is a bare `math::Rational`, or an optional one.
2323/// @tparam T The member's static type.
2324template <typename T>
2325inline constexpr bool isRationalMember = std::is_same_v<std::remove_cvref_t<T>, ::morph::math::Rational>;
2326// An optional member is a `Rational` member when its value is.
2327template <typename T>
2328inline constexpr bool isRationalMember<std::optional<T>> = isRationalMember<T>;
2329
2330/// @brief Stamps @p meta's display `unit` (as `ExtUnits`) and `decimals` (as
2331/// `x-displayDecimals`, or `x-decimalPlaces` on a bare `Rational`) onto
2332/// @p property, unless @p Member is a `Quantity`.
2333///
2334/// `ExtUnits` is the key a `Quantity` already carries, so every reader of a
2335/// unit -- a renderer's suffix label, a slot chosen by unit, a view column --
2336/// finds a plain member's unit where it finds a `Quantity`'s. A `Quantity`'s
2337/// own unit and precision are part of its type; a `FieldMeta` restating them
2338/// could only disagree, so for one this is a no-op.
2339/// @tparam Member The static type of the member being annotated.
2340/// @param property Property node to annotate in place.
2341/// @param meta The field's declared metadata.
2342// NOLINTBEGIN(cppcoreguidelines-pro-bounds-avoid-unchecked-container-access) -- glaze DOM requires operator[]
2343template <typename Member>
2344void annotateDisplayUnit(glz::generic_u64& property, const FieldMeta& meta) {
2345 if constexpr (!units::isQuantity<Member>) {
2346 if (!meta.unit.empty()) {
2347 glz::generic_u64 units{};
2348 units["unitAscii"] = std::string{meta.unit};
2349 units["unitUnicode"] = std::string{meta.unit};
2350 property["ExtUnits"] = std::move(units);
2351 }
2352 if (meta.decimals.has_value() && meta.decimals->value <= ::morph::math::kMaxDecimalPlaces) {
2353 // A Rational's wire object is the exact encoding x-decimalPlaces declares; a double's is a number.
2354 property[isRationalMember<Member> ? "x-decimalPlaces" : "x-displayDecimals"] =
2355 std::uint64_t{meta.decimals->value};
2356 }
2357 }
2358}
2359// NOLINTEND(cppcoreguidelines-pro-bounds-avoid-unchecked-container-access)
2360
2361/// @brief Whether @p value satisfies every bound @p meta declares.
2362///
2363/// The comparisons run on the exact `math::Rational`, never on a `double`, so
2364/// the C++ verdict is the one the schema's rounded numbers only approximate.
2365/// `multipleOf` uses `checkedDiv` rather than `dividedBy`: a saturated
2366/// quotient comes back from the latter as a *successful* `±INT64_MAX/1`, which
2367/// is an integer and would be read as "yes, a multiple". An unrepresentable
2368/// quotient is not a multiple this function can vouch for, so it is refused.
2369/// @param meta The field's declared metadata.
2370/// @param value The field's current value, in its canonical unit.
2371/// @return `true` when the value is within `[minimum, maximum]` and an exact
2372/// integer multiple of `multipleOf`, for whichever of the three are declared.
2373[[nodiscard]] constexpr bool satisfiesDeclaredBounds(const FieldMeta& meta,
2374 const ::morph::math::Rational& value) noexcept {
2375 if (meta.minimum.has_value() && std::is_lt(value <=> *meta.minimum)) {
2376 return false;
2377 }
2378 if (meta.maximum.has_value() && std::is_gt(value <=> *meta.maximum)) {
2379 return false;
2380 }
2381 if (hasUsableMultipleOf(meta)) {
2382 auto const quotient = ::morph::math::checkedDiv(value, *meta.multipleOf);
2383 if (!quotient.has_value() || !quotient->isInteger()) {
2384 return false;
2385 }
2386 }
2387 return true;
2388}
2389
2390/// @brief Emits the options annotations a `FieldMeta` declares for a member that is not a `Choice<…>`, refusing a
2391/// declaration that cannot hold.
2392/// @tparam Owner The type declaring @p name.
2393/// @tparam Member The member's type.
2394/// @param property DOM node for the property.
2395/// @param name The member's wire name.
2396/// @param meta Its `FieldMeta`.
2397/// @throws ConflictingOptionsError when @p Member is a `Choice` and @p meta sets `optionsAction`.
2398/// @throws IncompleteOptionsError when `optionsAction`, `valueField` and `labelField` are not all set or all empty.
2399template <typename Owner, typename Member>
2400void annotateMetaOptions(glz::generic_u64& property, std::string_view name, FieldMeta const& meta) {
2401 bool const action = !meta.optionsAction.empty();
2402 if (!action && meta.valueField.empty() && meta.labelField.empty()) {
2403 return;
2404 }
2405 if constexpr (isChoice<Member>) {
2406 if (action) {
2407 throw ConflictingOptionsError{glz::name_v<Owner>, name};
2408 }
2409 }
2410 std::string missing;
2411 auto const lacks = [&missing](bool set, std::string_view label) {
2412 if (!set) {
2413 missing += (missing.empty() ? "" : ", ") + std::string{label};
2414 }
2415 };
2416 lacks(action, "optionsAction");
2417 lacks(!meta.valueField.empty(), "valueField");
2418 lacks(!meta.labelField.empty(), "labelField");
2419 if (!missing.empty()) {
2420 throw IncompleteOptionsError{glz::name_v<Owner>, name, missing};
2421 }
2422 // NOLINTBEGIN(cppcoreguidelines-pro-bounds-avoid-unchecked-container-access) -- glaze DOM requires operator[]
2423 property["x-optionsAction"] = std::string{meta.optionsAction};
2424 property["x-optionValue"] = std::string{meta.valueField};
2425 property["x-optionLabel"] = std::string{meta.labelField};
2426 // NOLINTEND(cppcoreguidelines-pro-bounds-avoid-unchecked-container-access)
2427}
2428
2429/// @brief Applies the title/`FieldMeta`/`Quantity`/`Choice`/widget/
2430/// ranged-bounds annotations to one property node. Shared by
2431/// `mergeSchemaExtras`'s top-level pass and `annotateNestedAggregate`
2432/// below (the nested-aggregate recursion, to whatever depth the type
2433/// graph has) so both apply identical per-member rules -- this is the
2434/// single implementation of those rules; neither caller duplicates it.
2435///
2436/// Deliberately excludes computed-field annotations (`x-computed`/
2437/// `x-readonly`) and `x-order`: computed fields are not supported inside a
2438/// nested aggregate (see `annotateNestedAggregate`), and `x-order`'s source index
2439/// differs by caller, so each caller sets it itself.
2440/// @tparam Owner The type declaring @p name (drives `FieldMeta`/widget-override lookup).
2441/// @tparam Member The static type of the member itself (drives type-driven annotations).
2442/// @param property DOM node for this one property; annotations are merged in, not replacing.
2443/// @param name Wire (JSON) name of the member, for `FieldMeta`/widget-override lookup.
2444template <typename Owner, typename Member>
2445void annotateBasicMemberProperty(glz::generic_u64& property, std::string_view name) {
2446 // A plain `enum class` with no `glz::meta`/`glz::enumerate` fails glaze's
2447 // `glaze_enum_t` and falls through `to_json_schema<T>`'s final branch: a
2448 // six-way wildcard (`{"type": ["number","string","boolean","object",
2449 // "array","null"]}`) that states nothing, and that a renderer cannot draw
2450 // as a choice among values. `glz::glaze_enum_t` is the same trait glaze's own
2451 // enum schema specialisation gates on, so this fires exactly when that
2452 // specialisation would not have been reached -- a `static_assert` whose
2453 // condition depends on @p Member, so it only fires for the specific
2454 // offending member type, not every call.
2455 if constexpr (std::is_enum_v<Member>) {
2456 static_assert(glz::glaze_enum_t<Member>,
2457 "morph::forms: an enum class rendered as a form field must declare a glz::meta with "
2458 "glz::enumerate -- without one, schemaJson<A>() cannot describe its closed set of "
2459 "values (glaze emits a six-way wildcard type instead), which no renderer can draw as a "
2460 "choice. See docs/spec/forms/forms.md, \"Closed sets\", and examples/"
2461 "IMPLEMENTATION.md rule 3.");
2462 }
2463 const FieldMeta* fieldMeta = findFieldMeta<Owner>(name);
2464 std::string_view const declaredLabel = fieldMeta != nullptr ? fieldMeta->label : std::string_view{};
2465 property["title"] = declaredLabel.empty() ? inferTitle(name) : std::string{declaredLabel};
2466 if (fieldMeta != nullptr) {
2467 if (!fieldMeta->help.empty()) {
2468 property["description"] = std::string{fieldMeta->help};
2469 }
2470 if (!fieldMeta->placeholder.empty()) {
2471 property["x-placeholder"] = std::string{fieldMeta->placeholder};
2472 }
2473 if (fieldMeta->readOnly) {
2474 property["x-readonly"] = true;
2475 }
2476 if (fieldMeta->hidden) {
2477 property["x-hidden"] = true;
2478 }
2479 if (!fieldMeta->i18nKey.empty()) {
2480 property["x-i18nKey"] = std::string{fieldMeta->i18nKey};
2481 }
2482 annotateDeclaredBounds(property, *fieldMeta);
2483 annotateDisplayUnit<Member>(property, *fieldMeta);
2484 annotateMetaOptions<Owner, Member>(property, name, *fieldMeta);
2485 if constexpr (std::is_same_v<Member, std::string> || std::is_same_v<Member, std::optional<std::string>>) {
2486 if (fieldMeta->blankAs == BlankAs::Empty) {
2487 property["x-blankAs"] = std::string{"empty"};
2488 }
2489 }
2490 }
2491
2492 if constexpr (units::isQuantity<Member>) {
2493 property["x-decimalPlaces"] = std::uint64_t{Member::declaredDecimals};
2494 auto const alternatives = Member::unitAlternatives();
2495 if (!alternatives.empty()) {
2496 glz::generic_u64::array_t list{};
2497 for (auto const& alternative : alternatives) {
2498 auto const meta =
2499 units::UnitTraits<std::remove_const_t<decltype(Member::unit)>>::meta(alternative.unit);
2500 glz::generic_u64 entry{};
2501 entry["id"] = std::string{meta.id};
2502 entry["display"] = std::string{meta.display};
2503 entry["decimals"] = std::uint64_t{meta.defaultDecimals};
2504 entry["num"] = alternative.num;
2505 entry["den"] = alternative.den;
2506 list.emplace_back(std::move(entry));
2507 }
2508 property["x-unitAlternatives"] = list;
2509 }
2510 }
2511 if constexpr (isChoice<Member>) {
2512 property["x-optionsAction"] = std::string{Member::optionsAction()};
2513 property["x-optionValue"] = std::string{Member::valueField()};
2514 property["x-optionLabel"] = std::string{Member::labelField()};
2515 if constexpr (!Member::optionsDependsOn().empty()) {
2516 glz::generic_u64::array_t dependsOn{};
2517 for (auto const& parentName : Member::optionsDependsOn()) {
2518 dependsOn.emplace_back(std::string{parentName});
2519 }
2520 property["x-optionsDependsOn"] = dependsOn;
2521 }
2522 }
2523
2524 std::string_view widgetHint{};
2525 if constexpr (DeclaresWidget<Member>) {
2526 widgetHint = Member::widget();
2527 }
2528 if constexpr (HasFieldMetadataWidgets<Owner>) {
2529 if (auto const overrideWidget = widgetOverride<Owner>(name); !overrideWidget.empty()) {
2530 widgetHint = overrideWidget;
2531 }
2532 }
2533 if (!widgetHint.empty()) {
2534 property["x-widget"] = std::string{widgetHint};
2535 }
2536 annotateSchemaMarker<Member>(property);
2537 if constexpr (DeclaresRangedBounds<Member>) {
2538 using Bound = std::remove_cvref_t<decltype(Member::min())>;
2539 if constexpr (std::floating_point<Bound>) {
2540 property["x-min"] = static_cast<double>(Member::min());
2541 property["x-max"] = static_cast<double>(Member::max());
2542 property["x-step"] = static_cast<double>(Member::step());
2543 } else {
2544 property["x-min"] = static_cast<std::int64_t>(Member::min());
2545 property["x-max"] = static_cast<std::int64_t>(Member::max());
2546 property["x-step"] = static_cast<std::int64_t>(Member::step());
2547 }
2548 }
2549}
2550
2551/// @brief The member of @p node named @p key, or `nullptr` when there is none.
2552///
2553/// The checked read over a `glz::generic_u64` object node, and morph's own
2554/// because glaze has no such thing. `generic_json::at(key)` is defined as
2555/// `{ return operator[](key); }` for *both* overloads (glaze v7.4.0,
2556/// `glaze/json/generic.hpp:320` and `:322`), and the non-const `operator[]`
2557/// it forwards to **inserts** a default-constructed member for a missing key
2558/// (`generic.hpp:201-211`). So on the mutating DOM walks below, `at()` is not
2559/// a bounds-safe alternative to `operator[]` -- it is the same function, and
2560/// on a missing key it turns a read into a write to the schema being emitted.
2561/// The const `operator[]` does check, by calling `glaze_error("Key not
2562/// found.")`, i.e. by throwing. Which of the two a call gets is decided by the
2563/// constness of the DOM, not by the spelling, which is why the remedy
2564/// `cppcoreguidelines-pro-bounds-avoid-unchecked-container-access` suggests
2565/// cannot be adopted mechanically here.
2566///
2567/// This returns a pointer instead: absence is a value the caller branches on,
2568/// a read never grows the document, and nothing throws. It also replaces the
2569/// `contains(key)` + `operator[](key)` pair every read site used to spell,
2570/// which probed the same map twice for one answer.
2571///
2572/// Only *reads* belong here. A site that means to create the member --
2573/// `property["x-order"] = ...` building the schema -- wants `operator[]`'s
2574/// insert and keeps it; converting one of those to a null check would change
2575/// behaviour, not make it safe. See `docs/spec/forms/forms.md`, "Reading the
2576/// DOM with `findMember`".
2577///
2578/// The returned pointer is into @p node's own object storage and is
2579/// invalidated by any insertion into @p node, exactly as the reference
2580/// `operator[]` returns is.
2581///
2582/// @tparam Node `glz::generic_u64` or `const glz::generic_u64`, deduced from
2583/// the argument, so a const DOM yields a const member and no
2584/// `const_cast` is needed to offer both.
2585/// @param node The node to read. A node that is not an object -- including a
2586/// null one -- has no members and yields `nullptr`, rather than
2587/// being turned into an object as `operator[]` would.
2588/// @param key Wire name of the member to find.
2589/// @return A pointer to the member, or `nullptr` when @p node is not an object
2590/// or holds no such key.
2591template <typename Node>
2592 requires std::same_as<std::remove_const_t<Node>, glz::generic_u64>
2593[[nodiscard]] Node* findMember(Node& node, std::string_view key) {
2594 auto* const object = node.template get_if<glz::generic_u64::object_t>();
2595 if (object == nullptr) {
2596 return nullptr;
2597 }
2598 auto* const entry = object->find(key);
2599 return (entry == object->end()) ? nullptr : &entry->second;
2600}
2601
2602/// @brief The `$defs` keys already annotated during one `mergeSchemaExtras`
2603/// call, so a shared nested aggregate is annotated once instead of once
2604/// per route to it.
2605///
2606/// A nested aggregate's annotations are a function of its own type alone — no
2607/// per-route input reaches `annotateNestedAggregate` — so a `$defs` entry
2608/// reachable by several routes used to be rewritten with byte-identical content
2609/// once per route. This records which entries are done; the second and later
2610/// arrivals return immediately. Keys are owned `std::string`s rather than views
2611/// into the DOM: the DOM's object storage reallocates on insert, and a view into
2612/// a key it had moved would be read long after.
2613using NestedDefsVisited = std::unordered_set<std::string>;
2614
2615/// @brief The whole schema DOM, as a type distinct from a node inside it.
2616///
2617/// Each of the three mutually recursive walkers below takes both the whole DOM
2618/// (to resolve a `$ref` against `$defs`) and one node within it (the thing to
2619/// annotate). While both were plain `glz::generic_u64&` they were adjacent
2620/// parameters of identical type, so transposing them at a call site compiled
2621/// silently and annotated against the wrong root — a defect with no diagnostic
2622/// of any kind. `bugprone-easily-swappable-parameters` flags exactly that
2623/// shape, and it flagged this one. Giving the DOM its own type turns the
2624/// transposition into a compile error, which is the actual remedy rather than
2625/// a suppression of the warning about it.
2626///
2627/// This is a reference wrapper: it owns nothing, is passed by value, and must
2628/// not outlive the DOM it names. The handle is held as a pointer rather than a
2629/// reference member so that the type stays assignable (and so that it does not
2630/// itself trip `cppcoreguidelines-avoid-const-or-ref-data-members`); it is
2631/// constructed from a reference and is therefore never null.
2632class SchemaDomRef {
2633public:
2634 /// @brief Wraps a DOM root.
2635 /// @param dom The DOM root to refer to. Must outlive this handle.
2636 explicit SchemaDomRef(glz::generic_u64& dom) noexcept : _dom(&dom) {}
2637
2638 /// @brief The DOM root this refers to.
2639 /// @return A reference to the wrapped DOM root; never null.
2640 [[nodiscard]] glz::generic_u64& value() const noexcept { return *_dom; }
2641
2642private:
2643 glz::generic_u64* _dom;
2644};
2645
2646// annotateNestedAggregate, annotateNestedAggregateRef, and
2647// recurseIntoNestedAggregateIfAny are mutually recursive (each nested
2648// aggregate found while annotating one may itself contain another), so all
2649// three need forward declarations before any of their bodies can reference
2650// the others.
2651template <typename Sub>
2652void annotateNestedAggregate(SchemaDomRef dom, glz::generic_u64& node, NestedDefsVisited& visited);
2653
2654template <typename Sub>
2655void annotateNestedAggregateRef(SchemaDomRef dom, glz::generic_u64& propertyOrItems, NestedDefsVisited& visited);
2656
2657template <typename Member>
2658void recurseIntoNestedAggregateIfAny(SchemaDomRef dom, glz::generic_u64& property, NestedDefsVisited& visited);
2659
2660/// @brief Recurses into @p property's own object schema if @p Member (or, for
2661/// `std::vector<Sub>` and `std::optional<Sub>`, the `Sub`) is itself a
2662/// `ReflectableAggregate` -- the single decision point shared by
2663/// `mergeSchemaExtras`'s top-level loop and `annotateNestedAggregate`'s
2664/// own loop, so there is exactly one implementation of it.
2665///
2666/// **This function template carries no varying template argument, and that is
2667/// the whole termination argument.** `recurseIntoNestedAggregateIfAny<Member>`
2668/// reaches `annotateNestedAggregate<Sub>`, which reaches
2669/// `recurseIntoNestedAggregateIfAny<Member'>` for `Sub`'s own members. Every
2670/// specialisation is keyed on a *type* alone, the reachable type set of any
2671/// program is finite, and a specialisation already on the instantiation stack
2672/// is not instantiated again -- so a self-referential type
2673/// (`struct Node { std::vector<Node> children; };`) or a mutual reference
2674/// between two types costs one instantiation per type and stops, rather than
2675/// recursing forever (measured: see `docs/spec/forms/forms.md`, "Nested
2676/// aggregates (recursive, cycle-safe)").
2677///
2678/// Carrying *nothing* through the instantiation is what buys that, and the two
2679/// obvious alternatives both cost more. An ancestor *type list* makes every
2680/// distinct root-to-member route through the type graph its own instantiation,
2681/// so a domain model that is a DAG rather than a tree -- an `Address` under
2682/// both a `Customer` and a `Supplier`, a `Money` everywhere -- costs one
2683/// instantiation per route, and route count grows exponentially in the graph's
2684/// size (measured: a fixture whose route count is Fibonacci(n) reaches 86 s at
2685/// 2,584 routes). A depth counter cuts that to one instantiation per (type,
2686/// depth) pair, at the price of a depth cap and a `static_assert` that rejects
2687/// every cyclic type. Carrying nothing gives one instantiation per *type*, no
2688/// cap, and no rejection. The runtime recursion is stopped by @p visited, not
2689/// by the type system.
2690///
2691/// morph therefore imposes no depth limit, but the *compiler* does, and MSVC's
2692/// is low: 15 levels of nested aggregate initialisation inside an instantiated
2693/// template, past which `A probe{}` in `mergeSchemaExtras` is
2694/// `fatal error C1054`. See `docs/spec/forms/forms.md`, "Nesting depth in
2695/// practice", for the measurement on all three toolchains.
2696/// @tparam Member The static type of the member `annotateBasicMemberProperty`
2697/// was just applied to.
2698/// @param dom The whole schema DOM, wrapped (so a `$ref`'s `$defs` entry can be found);
2699/// see `SchemaDomRef`.
2700/// @param property The property node for this member (or, for `std::vector<Sub>`,
2701/// the property whose `"items"` node is the one to check).
2702/// @param visited `$defs` keys already annotated on this `mergeSchemaExtras`
2703/// call; see `NestedDefsVisited`.
2704template <typename Member>
2705void recurseIntoNestedAggregateIfAny(SchemaDomRef dom, glz::generic_u64& property, NestedDefsVisited& visited) {
2706 if constexpr (ReflectableAggregate<Member>) {
2707 annotateNestedAggregateRef<Member>(dom, property, visited);
2708 } else if constexpr (IsStdVector<Member>::value && ReflectableAggregate<typename IsStdVector<Member>::ValueType>) {
2709 using ItemType = typename IsStdVector<Member>::ValueType;
2710 if (auto* const items = findMember(property, "items")) {
2711 // A read, so it goes through findMember: a `std::vector<Sub>`
2712 // property glaze emitted without an `items` node is left alone
2713 // rather than given an empty one. The `contains` + `operator[]`
2714 // pair this replaces probed the same map twice and needed a
2715 // standing suppression to say why the subscript was safe.
2716 annotateNestedAggregateRef<ItemType>(dom, *items, visited);
2717 }
2718 } else if constexpr (isStdOptional<Member>) {
2719 // glaze spells `std::optional<T>` as `{"anyOf": [<T's schema>,
2720 // {"type": "null"}]}`, so an optional nested aggregate's object schema
2721 // sits in the non-null branch. Each branch goes through the same
2722 // decision for `T` (the null one matches nothing), which covers an
2723 // optional `std::vector<Sub>` as well.
2724 using ValueType = typename std::remove_cvref_t<Member>::value_type;
2725 if (auto* const branches = findMember(property, "anyOf")) {
2726 if (auto* const list = branches->get_if<glz::generic_u64::array_t>()) {
2727 for (auto& branch : *list) {
2728 recurseIntoNestedAggregateIfAny<ValueType>(dom, branch, visited);
2729 }
2730 }
2731 }
2732 }
2733}
2734
2735/// @brief Annotates @p node -- the object-schema DOM node for a
2736/// nested-aggregate member -- applying `required` and
2737/// `annotateBasicMemberProperty`'s rules to its own properties, then
2738/// recursing into any of *its* members that are themselves nested
2739/// aggregates (see `recurseIntoNestedAggregateIfAny`), to whatever
2740/// depth the type graph actually has.
2741///
2742/// @p node is @e which DOM node depends on how many places in the whole
2743/// schema reference `Sub`: glaze **inlines** the object schema directly into
2744/// the referencing property when `Sub` is used exactly once (so @p node
2745/// *is* that property node), but **deduplicates** via `$defs`/`$ref` when
2746/// `Sub` is used two or more times (so @p node is the shared `$defs` entry,
2747/// resolved by the caller). Both forms have the identical `{"properties":
2748/// {...}}` shape this function needs, so one implementation handles both --
2749/// see the call site in `mergeSchemaExtras` for how @p node is resolved.
2750///
2751/// Computed fields, `formLayout`/`fieldSpans`, and `formRules` stay
2752/// top-level-only regardless of depth; a nested `Sub` declaring any of those
2753/// has no effect here.
2754///
2755/// For a `Sub` that is reachable from itself, this is entered once: the
2756/// caller's @p visited set records the `$defs` key on first arrival, and a
2757/// cyclic type is always in `$defs` (glaze inlines only a type used exactly
2758/// once in the whole schema, which a self-reference is not).
2759///
2760/// @tparam Sub Nested aggregate type (default-constructible, glaze-reflectable
2761/// -- the same requirements the top-level action type already has).
2762/// @param dom The whole schema DOM, wrapped (so a deeper `$ref`'s `$defs` entry can
2763/// be found); see `SchemaDomRef`.
2764/// @param node The object-schema DOM node to annotate in place (see above).
2765/// @param visited `$defs` keys already annotated on this `mergeSchemaExtras`
2766/// call; see `NestedDefsVisited`.
2767template <typename Sub>
2768void annotateNestedAggregate(SchemaDomRef dom, glz::generic_u64& node, NestedDefsVisited& visited) {
2769 Sub probe{};
2770 glz::generic_u64::array_t requiredNames{};
2771 forEachNamedMember(probe, [&]<std::size_t I>(std::string_view name, const auto& member) {
2772 using Member = std::remove_cvref_t<decltype(member)>;
2773 if (!(isStdOptional<Member> || declaredOptional<Sub>(name))) {
2774 requiredNames.emplace_back(std::string{name});
2775 }
2776 auto& property = node["properties"][std::string{name}];
2777 property["x-order"] = std::uint64_t{I};
2778 annotateBasicMemberProperty<Sub, Member>(property, name);
2779 recurseIntoNestedAggregateIfAny<Member>(dom, property, visited);
2780 });
2781 // Idempotent if two members (or two actions sharing this schema call)
2782 // resolve to the same $defs entry: re-deriving the identical required
2783 // array is harmless. `visited` now stops the shared-$defs case from
2784 // arriving here twice in the first place, but the property is what makes
2785 // that skip safe, so it is stated rather than relied on silently.
2786 node["required"] = requiredNames;
2787}
2788
2789/// @brief Resolves the object-schema DOM node for a nested-aggregate member,
2790/// given the property (or array `items`) node glaze wrote for it, and
2791/// annotates it via `annotateNestedAggregate<Sub>`.
2792///
2793/// Handles both forms `Sub` can take in the schema (see
2794/// `annotateNestedAggregate`'s doc comment): a `$ref` into `$defs` (`Sub` used
2795/// 2+ times somewhere in the schema) resolves to that shared def; anything
2796/// else is assumed to be the inlined object schema itself (`Sub` used exactly
2797/// once). A property that is neither -- glaze emitted something other than an
2798/// object schema for a type this function's caller already confirmed is a
2799/// `ReflectableAggregate` -- is left untouched rather than guessed at.
2800///
2801/// The `$defs` form is also where @p visited earns its keep: one shared entry
2802/// can be `$ref`'d from many properties, and every one of them used to rewrite
2803/// it with byte-identical content. The first arrival annotates it and records
2804/// the key; later arrivals return without touching the DOM.
2805/// @tparam Sub Nested aggregate type, as `annotateNestedAggregate` requires.
2806/// @param dom The whole schema DOM, wrapped (so a `$ref`'s `$defs` entry can
2807/// be found); see `SchemaDomRef`.
2808/// @param propertyOrItems The property node itself (single nested member) or its
2809/// array `items` node (`std::vector<Sub>` member).
2810/// @param visited `$defs` keys already annotated on this `mergeSchemaExtras`
2811/// call; see `NestedDefsVisited`.
2812template <typename Sub>
2813void annotateNestedAggregateRef(SchemaDomRef dom, glz::generic_u64& propertyOrItems, NestedDefsVisited& visited) {
2814 constexpr std::string_view kDefsPrefix = "#/$defs/";
2815 if (auto* const refNode = findMember(propertyOrItems, "$ref")) {
2816 if (auto const* ref = refNode->get_if<std::string>()) {
2817 if (std::string_view{*ref}.starts_with(kDefsPrefix)) {
2818 auto const key = std::string{ref->substr(kDefsPrefix.size())};
2819 // Checked, not indexed-and-hope: glz::generic_u64's object
2820 // storage reallocates on insert, so indexing a missing key
2821 // here would both fabricate a bogus empty $defs entry AND --
2822 // now that annotateNestedAggregate recurses -- risk dangling
2823 // a `node` reference an enclosing frame still holds into this
2824 // same $defs map. Well-formed glaze output never names a
2825 // $defs key that doesn't exist, so this only changes behavior
2826 // for malformed input, which is left untouched instead.
2827 // findMember is what states that in the type system rather
2828 // than in a comment beside a subscript.
2829 auto* const defs = findMember(dom.value(), "$defs");
2830 auto* const entry = (defs == nullptr) ? nullptr : findMember(*defs, key);
2831 // `visited.insert(...).second` is the first-arrival test: it
2832 // reports whether this call is the one that inserted the key,
2833 // so exactly one of the routes that reach a shared $defs entry
2834 // annotates it. It is evaluated last, so a key that fails
2835 // the lookup above is never recorded as done.
2836 if (entry != nullptr && visited.insert(key).second) {
2837 annotateNestedAggregate<Sub>(dom, *entry, visited);
2838 }
2839 }
2840 }
2841 return;
2842 }
2843 // The inlined form needs no `visited` check: glaze inlines exactly when the
2844 // type is referenced once in the whole schema, so this node has one route.
2845 if (propertyOrItems.contains("properties")) {
2846 annotateNestedAggregate<Sub>(dom, propertyOrItems, visited);
2847 }
2848}
2849
2850/// @brief Whether an emitted rule's wire `"kind"` *caps* how many of the
2851/// fields it ranges over may be engaged at once.
2852///
2853/// This is the single place the capping kinds are named. A future rule kind
2854/// with a ceiling ("at most two of these") joins this list and is then covered
2855/// by `rejectUnsatisfiableRules` with no other change.
2856///
2857/// `atLeastOneOf` is deliberately absent: it is a *floor*, not a ceiling, and
2858/// is satisfied by engaging every field it names — so it can never contradict
2859/// `required`, and rejecting it would be a false positive. `requiredWhen` is
2860/// likewise absent: it only ever *adds* required-ness.
2861/// @param kind The rule node's emitted `"kind"` string.
2862/// @return `true` for `"exactlyOneOf"` and `"mutuallyExclusive"`.
2863[[nodiscard]] inline bool capsEngagedCount(std::string_view kind) noexcept {
2864 return kind == ruleKindName(RuleKind::ExactlyOneOf) || kind == ruleKindName(RuleKind::MutuallyExclusive);
2865}
2866
2867/// @brief One typed member of an emitted rule node, or `nullptr`.
2868///
2869/// Folds the find / end-compare / `get_if` triple into one call, which is what
2870/// keeps `findUnsatisfiableConjunct` below down to the branching its own
2871/// argument needs.
2872/// @tparam T Expected value type of the member.
2873/// @param node The rule/condition node object.
2874/// @param key Wire name of the member to read.
2875/// @return Pointer to the member's value, or `nullptr` when it is absent or
2876/// holds another type.
2877template <typename T>
2878[[nodiscard]] inline const T* ruleNodeMember(const glz::generic_u64::object_t& node, std::string_view key) {
2879 auto const* const entry = node.find(key);
2880 return (entry == node.end()) ? nullptr : entry->second.template get_if<T>();
2881}
2882
2883/// @brief The names in @p fields that @p requiredNames also demands, rendered
2884/// as one comma-separated list — but only when there are **two or
2885/// more**.
2886///
2887/// One required field inside a capping rule is satisfiable (engage that one,
2888/// leave the rest empty), so fewer than two is reported as "no contradiction"
2889/// rather than as an empty list.
2890/// @param fields The rule node's `fields` array.
2891/// @param requiredNames Wire names of every member that landed in `required`.
2892/// @return The offending names in declaration order, or `std::nullopt`.
2893[[nodiscard]] inline std::optional<std::string> requiredFieldsNamedBy(
2894 const glz::generic_u64::array_t& fields, const std::vector<std::string_view>& requiredNames) {
2895 std::string offenders{};
2896 std::size_t offenderCount = 0;
2897 for (auto const& fieldNode : fields) {
2898 auto const* fieldName = fieldNode.get_if<std::string>();
2899 // `std::ranges::find`, not `std::ranges::contains`: the latter is C++23
2900 // (P2302) and is absent from the libc++ the emscripten toolchain ships,
2901 // so it broke all three WASM TUs that include this header while
2902 // compiling fine under the libstdc++ the GCC leg uses. `ranges::find`
2903 // is C++20 and available on every toolchain this project builds on.
2904 if (fieldName == nullptr ||
2905 std::ranges::find(requiredNames, std::string_view{*fieldName}) == requiredNames.end()) {
2906 continue;
2907 }
2908 if (offenderCount > 0) {
2909 offenders += ", ";
2910 }
2911 ++offenderCount;
2912 offenders += *fieldName;
2913 }
2914 return (offenderCount >= 2) ? std::optional{std::move(offenders)} : std::nullopt;
2915}
2916
2917/// @brief The first capping node in a **conjunction** of emitted rule nodes
2918/// that ranges over two or more fields `required` also demands.
2919///
2920/// Recurses into the `conditions` of any `and` node, because a conjunction of
2921/// conjunctions is one conjunction — see `rejectUnsatisfiableRules` for why
2922/// `or` and `not` are deliberately left alone.
2923///
2924/// @param nodes Rule/condition nodes that must *all* hold.
2925/// @param requiredNames Wire names of every member that landed in `required`.
2926/// @return The offending node's `kind` and its comma-separated offending field
2927/// names, or `std::nullopt` when the conjunction is satisfiable.
2928// NOLINTNEXTLINE(misc-no-recursion) -- an `and` node's conditions are themselves a conjunction, so descending is the whole point
2929[[nodiscard]] inline std::optional<std::pair<std::string, std::string>> findUnsatisfiableConjunct(
2930 const glz::generic_u64::array_t& nodes, const std::vector<std::string_view>& requiredNames) {
2931 for (auto const& entry : nodes) {
2932 auto const* node = entry.get_if<glz::generic_u64::object_t>();
2933 if (node == nullptr) {
2934 continue;
2935 }
2936 auto const* kind = ruleNodeMember<std::string>(*node, "kind");
2937 if (kind == nullptr) {
2938 continue;
2939 }
2940
2941 if (*kind == ruleKindName(RuleKind::And)) {
2942 auto const* nested = ruleNodeMember<glz::generic_u64::array_t>(*node, "conditions");
2943 if (auto offender = (nested == nullptr) ? std::nullopt : findUnsatisfiableConjunct(*nested, requiredNames);
2944 offender.has_value()) {
2945 return offender;
2946 }
2947 continue;
2948 }
2949
2950 if (!capsEngagedCount(*kind)) {
2951 continue;
2952 }
2953 auto const* fields = ruleNodeMember<glz::generic_u64::array_t>(*node, "fields");
2954 if (fields == nullptr) {
2955 continue;
2956 }
2957 if (auto offenders = requiredFieldsNamedBy(*fields, requiredNames); offenders.has_value()) {
2958 return std::pair{*kind, *std::move(offenders)};
2959 }
2960 }
2961 return std::nullopt;
2962}
2963
2964/// @brief Throws when any emitted rule node caps engagement over two or more
2965/// fields that the emitted `required` array also demands.
2966///
2967/// Called from `mergeSchemaExtras`, which is the one place both halves are in
2968/// hand: `required` is derived from field required-ness and `x-rules` from
2969/// `A::formRules`, entirely independently, so until here neither half can see
2970/// that it contradicts the other. Checking the *emitted* nodes (rather than
2971/// the compile-time rule tuple) means the check reads exactly what a renderer
2972/// would read, and keys off the same wire names `required` does.
2973///
2974/// **Why this throws when the rest of schema generation never does.** A
2975/// `formLayout` entry naming a field the action does not have is silently
2976/// ignored, and that is right: the author loses one layout hint and still
2977/// gets a working form. This is different in kind — the result is a form
2978/// *nobody can submit*, on any client, with no error naming the reason. The
2979/// failure is already certain at generation time and belongs to the author's
2980/// own build, so it is raised there rather than left to surface as a user who
2981/// cannot press Save.
2982///
2983/// One required field inside a capping rule is fine and is left alone: the
2984/// author engages that one and leaves the rest empty, which satisfies both
2985/// `exactlyOneOf` and `mutuallyExclusive`. Only two or more conflict.
2986///
2987/// Note that `std::optional` members can never appear here: `isStdOptional`
2988/// keeps them out of `required` on sight. The reachable case is an
2989/// `EmptyCapableField` (a `Quantity`, a `Choice`, a strong id) — required by
2990/// default, and rangeable by a membership rule.
2991/// **The check descends through `and`, and only through `and`.** It reads its
2992/// argument as a *conjunction* — every element has to hold — which is what
2993/// makes a contradiction in any one element a contradiction of the whole. The
2994/// top-level `x-rules` array is such a conjunction (`allRulesSatisfied` folds
2995/// it with `&&`) and so are an `and` node's `conditions`, so
2996/// `ruleList(andOf(exactlyOneOf(&A::a, &A::b), engaged(&A::c)))` is exactly as
2997/// unsubmittable as the direct `ruleList(exactlyOneOf(&A::a, &A::b))`, so it is
2998/// rejected too. Descending matters because `and`/`or`/`not` emit
2999/// `conditions`/`condition` rather than `fields`: a loop that skipped any node
3000/// without a `fields` key would let the wrapped spelling through. The identical
3001/// contradiction being a hard build failure in one spelling and a silently
3002/// unsubmittable form in the other is worse than not checking at all, since the
3003/// check's existence is what an author trusts.
3004///
3005/// `or` and `not` are **not** descended, and that is not an omission:
3006///
3007/// - Under `or`, a contradictory operand only makes that branch dead. The
3008/// other branch still satisfies the rule, so rejecting would be a false
3009/// positive — and a false positive here is a hard build failure on a form
3010/// that works.
3011/// - Under `not`, the contradiction inverts into a requirement. With `a` and
3012/// `b` both required, `notOf(exactlyOneOf(&A::a, &A::b))` asks for *not*
3013/// exactly one of them engaged, which engaging both — precisely what
3014/// `required` already demands — satisfies.
3015///
3016/// @tparam A Action type (a reflectable aggregate), used only to name the
3017/// offending type in the diagnostic.
3018/// @param xRules The rule nodes just emitted from `A::formRules`.
3019/// @param requiredNames Wire names of every member that landed in `required`.
3020/// @throws UnsatisfiableFormError naming the first offending rule.
3021template <typename A>
3022void rejectUnsatisfiableRules(const glz::generic_u64::array_t& xRules,
3023 const std::vector<std::string_view>& requiredNames) {
3024 if (auto const offender = findUnsatisfiableConjunct(xRules, requiredNames); offender.has_value()) {
3025 throw UnsatisfiableFormError{glz::name_v<A>, offender->first, offender->second};
3026 }
3027}
3028
3029/// @brief Which declared bound `annotateExactBound` is to give an exact
3030/// companion -- and so both schema keys it touches.
3031///
3032/// One enumerator rather than the `(key, textKey)` pair of adjacent
3033/// `const std::string&`s this used to take. Those were transposable at a call
3034/// site with no diagnostic of any kind -- `annotateExactBound(node,
3035/// "x-exactMinimum", "minimum")` compiles and writes the bound into the
3036/// companion -- and `bugprone-easily-swappable-parameters` reported them the
3037/// moment the body stopped using the two in the same way (the read now goes
3038/// through `findMember`, the write still through `operator[]`). This is the
3039/// remedy `SchemaDomRef` above applies to the same defect shape: make the
3040/// transposition a compile error rather than suppress the warning about it.
3041/// It also leaves exactly one place where `minimum` is paired with
3042/// `x-exactMinimum`.
3043enum class ExactBoundKind : std::uint8_t {
3044 Minimum,
3045 Maximum,
3046};
3047
3048/// @brief Adds an exact decimal-string companion for one numeric bound, when
3049/// the bound is too large for a double to hold exactly.
3050///
3051/// `minimum`/`maximum` are standard JSON-Schema vocabulary stamped by glaze,
3052/// and `mergeSchemaExtras` reads the schema in u64 number mode precisely so
3053/// they are not rounded on the C++ side. They are rounded anyway the moment a
3054/// renderer does `JSON.parse(controller.schemasJson)`, which every shipped app
3055/// does -- `INT64_MAX` becomes `9223372036854775808`, and a client-side gate
3056/// comparing against it then admits `INT64_MAX + 1` as "not greater". The
3057/// exact digits travel as a string, which `JSON.parse` cannot round.
3058///
3059/// Emitted only above `kExactDoubleLimit`: an ordinary bound loses nothing to a
3060/// double, so schemas that do not need this are byte-for-byte unchanged.
3061///
3062/// @param node Schema node to annotate in place (a property or a `$defs` entry).
3063/// @param kind Which declared bound to give a companion; see `ExactBoundKind`.
3064// NOLINTBEGIN(cppcoreguidelines-pro-bounds-avoid-unchecked-container-access) -- the two writes below intend the insert
3065inline void annotateExactBound(glz::generic_u64& node, ExactBoundKind kind) {
3066 bool const isMinimum = (kind == ExactBoundKind::Minimum);
3067 std::string_view const key = isMinimum ? "minimum" : "maximum";
3068 std::string_view const textKey = isMinimum ? "x-exactMinimum" : "x-exactMaximum";
3069 // A read, so it is checked: findMember yields nullptr for a node with no
3070 // such bound instead of fabricating a null one. The two
3071 // `node[textKey] =` writes further down are the opposite case -- the
3072 // companion key is *meant* to be created -- and keep `operator[]`, which
3073 // is what the suppression above is still for.
3074 auto const* const bound = findMember(node, key);
3075 if (bound == nullptr) {
3076 return;
3077 }
3078 // std::cmp_* rather than a cast: the two bounds arrive in different
3079 // signednesses and the limit is unsigned, so a cast would be the very
3080 // sign-mismatch this comparison exists to get right.
3081 //
3082 // `bound` is read out into `value` before either write: inserting
3083 // `textKey` reallocates `node`'s object storage and invalidates it.
3084 if (bound->template holds<std::uint64_t>()) {
3085 auto const value = bound->template get<std::uint64_t>();
3086 if (std::cmp_greater(value, kExactDoubleLimit)) {
3087 node[textKey] = std::to_string(value);
3088 }
3089 } else if (bound->template holds<std::int64_t>()) {
3090 auto const value = bound->template get<std::int64_t>();
3091 if (std::cmp_greater(value, kExactDoubleLimit) || std::cmp_less(value, -kExactDoubleLimitSigned)) {
3092 node[textKey] = std::to_string(value);
3093 }
3094 }
3095}
3096
3097/// @brief Walks a schema DOM, adding `x-exactMinimum`/`x-exactMaximum` wherever a
3098/// bound is too large for a double.
3099///
3100/// Recursive over the whole document rather than over `properties` alone,
3101/// because the bounds that actually matter live in `$defs`: a `std::int64_t`
3102/// member is emitted as a `$ref` to `$defs/int64_t`, and that definition is
3103/// where `minimum`/`maximum` sit.
3104///
3105/// @param node Node to walk; objects and arrays recurse, scalars are left alone.
3106// NOLINTNEXTLINE(misc-no-recursion) -- walking a JSON tree is inherently recursive
3107inline void annotateExactNumericBounds(glz::generic_u64& node) {
3108 if (node.is_object()) {
3109 annotateExactBound(node, ExactBoundKind::Minimum);
3110 annotateExactBound(node, ExactBoundKind::Maximum);
3111 for (auto& [childKey, child] : node.get_object()) {
3112 annotateExactNumericBounds(child);
3113 }
3114 } else if (node.is_array()) {
3115 for (auto& child : node.get_array()) {
3116 annotateExactNumericBounds(child);
3117 }
3118 }
3119}
3120// NOLINTEND(cppcoreguidelines-pro-bounds-avoid-unchecked-container-access)
3121
3122/// @brief Stamps `x-submitMode` on @p dom when `A` opts out of auto-submit.
3123///
3124/// A top-level key sourced from a declaration on the action type, exactly as
3125/// `x-layout` is (docs/spec/forms/forms.md, "Explicit submit mode"). Emitted
3126/// only for `explicitSubmit = true`, so an action that declares nothing -- or
3127/// declares it `false` -- keeps the renderer's auto-submit default and its
3128/// schema is byte-for-byte unchanged.
3129///
3130/// A free function rather than a few lines inside `mergeSchemaExtras`: that
3131/// function is already at the edge of clang-tidy's cognitive-complexity
3132/// threshold, and every `if constexpr` added inline pushes it further.
3133/// @tparam A Action type whose schema is being annotated.
3134/// @param dom Schema DOM to stamp in place.
3135// NOLINTBEGIN(cppcoreguidelines-pro-bounds-avoid-unchecked-container-access) -- glaze DOM requires operator[]
3136template <typename A>
3137void annotateSubmitMode(glz::generic_u64& dom) {
3138 if constexpr (HasExplicitSubmit<A>) {
3139 if constexpr (A::explicitSubmit) {
3140 dom["x-submitMode"] = "explicit";
3141 }
3142 }
3143}
3144// NOLINTEND(cppcoreguidelines-pro-bounds-avoid-unchecked-container-access)
3145
3146/// @brief The DOM post-merge behind `schemaJson`: adds the derived `required`
3147/// array, `x-order`, `x-decimalPlaces`, and (for actions declaring
3148/// `computedFields`) `x-computed`/`x-readonly` to a glaze-produced schema.
3149///
3150/// Separated from `schemaJson` so the fallback path (malformed input passes
3151/// through unchanged) is directly testable.
3152///
3153/// Also the one place `required` and `x-rules` are both in hand, and so the
3154/// one place their mutual contradiction is visible: see
3155/// `rejectUnsatisfiableRules`, the sole path by which schema generation
3156/// throws.
3157// NOLINTBEGIN(cppcoreguidelines-pro-bounds-avoid-unchecked-container-access) — glaze DOM requires operator[]
3158template <typename A>
3159[[nodiscard]] std::string mergeSchemaExtras(std::string rawSchema) {
3160 // u64 number mode: the schema carries int64/uint64 bounds in $defs, which
3161 // the default double-only DOM would silently round.
3162 glz::generic_u64 dom{};
3163 if (glz::read_json(dom, rawSchema)) {
3164 return rawSchema;
3165 }
3166
3167 glz::generic_u64::array_t requiredNames{};
3168 // Wire keys of every reflected member, in declaration order — reused
3169 // below to silently ignore a formLayout/fieldSpans entry that names a
3170 // field the action does not actually have (a tolerated unknown field
3171 // still yields a working form, so it is never worth throwing over --
3172 // unlike the one case rejectUnsatisfiableRules catches below).
3173 std::vector<std::string_view> memberNames{};
3174 // The subset of `memberNames` that lands in `required`, kept as wire names
3175 // so `rejectUnsatisfiableRules` can match the emitted rule nodes' `fields`
3176 // against it without re-reading the DOM array it just built.
3177 std::vector<std::string_view> requiredMemberNames{};
3178 // The line MSVC reports as `fatal error C1054: compiler limit:
3179 // initializers nested too deeply` when A roots a chain of nested
3180 // aggregates more than 14 levels deep. That is cl's limit, not morph's
3181 // (measured: docs/spec/forms/forms.md, "Nesting depth in practice"); the
3182 // diagnostic names neither A nor the nesting, so the pointer lives here.
3183 A probe{};
3184
3185 // Shared across the whole member walk, not per member: a `$defs` entry two
3186 // different members reach is the same entry, and annotating it once is the
3187 // point (see NestedDefsVisited). Stays empty for an action with no nested
3188 // aggregate member, which is the common case.
3189 NestedDefsVisited nestedVisited{};
3190
3191 // Computed-field destination names -> their declared input wire names,
3192 // resolved once against the probe. Empty when A declares no
3193 // computedFields (the common case), so every lookup against it below is
3194 // then trivially false -- no schema change for actions that don't opt in.
3195 std::unordered_map<std::string_view, std::vector<std::string_view>> computedInputs{};
3196 if constexpr (HasComputedFields<A>) {
3197 std::apply([&](const auto&... field) { (collectComputedInputs(probe, field, computedInputs), ...); },
3198 A::computedFields.fields);
3199 }
3200
3201 forEachNamedMember(probe, [&]<std::size_t I>(std::string_view name, const auto& member) {
3202 using Member = std::remove_cvref_t<decltype(member)>;
3203 static_cast<void>(member);
3204 memberNames.push_back(name);
3205 const bool isComputed = computedInputs.contains(name);
3206 // A computed field is derived, not user-entered: exclude it from
3207 // `required` the same way an opted-out or std::optional field is.
3208 const bool isOptional = isStdOptional<Member> || declaredOptional<A>(name) || isComputed;
3209 if (!isOptional) {
3210 requiredNames.emplace_back(std::string{name});
3211 requiredMemberNames.push_back(name);
3212 }
3213 auto& property = dom["properties"][std::string{name}];
3214 property["x-order"] = std::uint64_t{I};
3215 if (isComputed) {
3216 property["x-readonly"] = true;
3217 glz::generic_u64 computedMeta{};
3218 glz::generic_u64::array_t inputsList{};
3219 for (auto const& inputName : computedInputs.at(name)) {
3220 inputsList.emplace_back(std::string{inputName});
3221 }
3222 computedMeta["inputs"] = inputsList;
3223 property["x-computed"] = computedMeta;
3224 }
3225
3226 // Label/title/FieldMeta/Quantity/Choice/widget/ranged-bounds: shared
3227 // with the nested-aggregate recursion's per-member pass so both apply
3228 // identical rules (see annotateBasicMemberProperty's doc comment).
3229 annotateBasicMemberProperty<A, Member>(property, name);
3230
3231 // Nested aggregates (recursive, cycle-safe -- docs/spec/forms/forms.md,
3232 // "Nested aggregates (recursive, cycle-safe)"): a member whose type
3233 // is itself a reflectable aggregate gets an object schema from glaze --
3234 // either inlined directly into this property (the type is used exactly
3235 // once in the whole schema) or shared via `$defs`/`$ref` (used 2+
3236 // times). `recurseIntoNestedAggregateIfAny` resolves whichever form it
3237 // is and recurses so that object schema's own members get
3238 // `x-order`/`required`/title/Quantity/Choice/widget annotations too,
3239 // however deep the type graph goes -- there is no depth limit, and a
3240 // cyclic type is described rather than rejected (see that function's
3241 // doc comment). `nestedVisited` is what stops the walk. Purely
3242 // additive: an action with no nested aggregate member has nothing here
3243 // to trigger on, so its schema is byte-for-byte unchanged.
3244 recurseIntoNestedAggregateIfAny<Member>(SchemaDomRef{dom}, property, nestedVisited);
3245 });
3246 // Always assign — an explicit empty array beats leaving whatever the
3247 // schema writer may have emitted (or omitted) for `required`.
3248 dom["required"] = requiredNames;
3249
3250 // Layout & grouping (docs/spec/forms/forms.md, "Layout & grouping"):
3251 // purely additive over the required/x-order pass above; a no-op unless
3252 // the action declares a static constexpr `formLayout`.
3253 if constexpr (detail::HasFormLayout<A>) {
3254 glz::generic_u64::array_t groupsJson{};
3255 // wire key -> 0-based index into A::formLayout; a field claimed by
3256 // two groups keeps the first (declaration order wins, silently —
3257 // the author still gets a working form, so this is not worth
3258 // throwing over).
3259 std::vector<std::pair<std::string_view, std::size_t>> sectionOf{};
3260 std::size_t groupIndex = 0;
3261 for (auto const& group : A::formLayout) {
3262 glz::generic_u64::array_t fieldsJson{};
3263 for (std::string_view fieldName : group.fields) {
3264 bool const isMember =
3265 std::find(memberNames.begin(), memberNames.end(), fieldName) != memberNames.end();
3266 if (!isMember) {
3267 continue; // names a field the action does not have: ignored, never thrown
3268 }
3269 bool alreadyPlaced = false;
3270 for (auto const& placed : sectionOf) {
3271 if (placed.first == fieldName) {
3272 alreadyPlaced = true;
3273 break;
3274 }
3275 }
3276 if (alreadyPlaced) {
3277 continue; // first group to claim a field wins
3278 }
3279 fieldsJson.emplace_back(std::string{fieldName});
3280 sectionOf.emplace_back(fieldName, groupIndex);
3281 }
3282 glz::generic_u64 groupJson{};
3283 groupJson["title"] = std::string{group.title};
3284 groupJson["kind"] = std::string{groupKindName(group.kind)};
3285 groupJson["fields"] = fieldsJson;
3286 groupsJson.emplace_back(std::move(groupJson));
3287 ++groupIndex;
3288 }
3289 dom["x-layout"]["groups"] = groupsJson;
3290
3291 for (auto const& placed : sectionOf) {
3292 auto& property = dom["properties"][std::string{placed.first}];
3293 property["x-group"] = std::string{A::formLayout[placed.second].title};
3294 property["x-section"] = std::uint64_t{placed.second};
3295 }
3296 }
3297
3298 // Column spans (docs/spec/forms/forms.md, "Layout & grouping"): a no-op
3299 // unless the action declares a static constexpr `fieldSpans`.
3300 if constexpr (detail::HasFieldSpans<A>) {
3301 for (auto const& span : A::fieldSpans) {
3302 if (span.colspan <= 1) {
3303 continue; // 1 is the default width; nothing to advertise
3304 }
3305 bool const isMember = std::find(memberNames.begin(), memberNames.end(), span.field) != memberNames.end();
3306 if (!isMember) {
3307 continue; // names a field the action does not have: ignored, never thrown
3308 }
3309 auto& property = dom["properties"][std::string{span.field}];
3310 property["x-colspan"] = span.colspan;
3311 }
3312 }
3313
3314 // Cross-field rules (docs/spec/forms/forms.md's `x-rules`): emitted only
3315 // when the action declares `formRules`, so an unannotated action's
3316 // schema is byte-identical to before this feature existed. Walks
3317 // whatever rule node types A::formRules holds -- every node type past
3318 // and future exposes the same emitNode() -> glz::generic_u64 shape, so
3319 // this loop needs no changes as new rule kinds are added. (Named
3320 // emitNode(), not emit(), because Qt's <QObject> headers `#define emit`
3321 // as an empty macro -- a bare `emit()` silently vanishes and fails to
3322 // parse in any translation unit that includes both Qt and this header.)
3323 if constexpr (HasFormRules<A>) {
3324 glz::generic_u64::array_t xRules{};
3325 std::apply([&](const auto&... rule) { (xRules.emplace_back(rule.emitNode()), ...); }, A::formRules.rules);
3326 // The one point where the two independently derived halves are both
3327 // in hand -- see rejectUnsatisfiableRules for why this is the one
3328 // author mistake schema generation refuses to tolerate silently.
3329 rejectUnsatisfiableRules<A>(xRules, requiredMemberNames);
3330 dom["x-rules"] = xRules;
3331 }
3332
3333 annotateSubmitMode<A>(dom);
3334
3335 // Exact companions for any bound a double cannot hold. Last, so it also
3336 // covers nodes added by the passes above.
3337 annotateExactNumericBounds(dom);
3338
3339 // value_or without a move: the copy is irrelevant (schemaJson memoises),
3340 // and keeping the fallback branch inside glaze's expected avoids an
3341 // untestable line here (write_json of a DOM we just built cannot fail).
3342 return glz::write_json(dom).value_or(rawSchema);
3343}
3344// NOLINTEND(cppcoreguidelines-pro-bounds-avoid-unchecked-container-access)
3345
3346} // namespace detail
3347
3348/// @brief Builds one computed-field declaration binding a destination member
3349/// to its declared input members and a pure derivation function.
3350///
3351/// @code{.cpp}
3352/// static constexpr auto computedFields = morph::forms::computeList(
3353/// morph::forms::computed<&LineItem::total, &LineItem::qty, &LineItem::price>(
3354/// [](const auto& s) { return s.qty * s.price; })); // auto: LineItem is incomplete here
3355/// @endcode
3356///
3357/// @tparam Dst Pointer-to-data-member of the derived (destination) field.
3358/// @tparam Inputs Pointer-to-data-members of the fields the derivation reads,
3359/// in declaration order.
3360/// @tparam Fn Deduced callable type: `ValueOfDst(const A&)`.
3361/// @param fn Pure function computing the destination value from the action.
3362/// Must have no side effects and read nothing beyond @p fn's own
3363/// argument -- the framework cannot check this; it is the author's
3364/// contract.
3365/// @return A `detail::ComputedField<Dst, Fn, Inputs...>` value.
3366template <auto Dst, auto... Inputs, typename Fn>
3367[[nodiscard]] consteval auto computed(Fn fn) noexcept {
3368 return detail::ComputedField<Dst, Fn, Inputs...>{fn};
3369}
3370
3371/// @brief Builds a `ComputeList` from one or more `computed(...)` declarations.
3372///
3373/// Assign the result to a `static constexpr auto computedFields` member on the
3374/// action type; `recomputeAll<A>` and `schemaJson<A>()` detect it via the
3375/// `detail::HasComputedFields<A>` concept.
3376/// @tparam Fields Deduced `detail::ComputedField<...>` types.
3377/// @param fields The computed-field declarations, in declaration order.
3378/// @return A `detail::ComputeList<Fields...>` value.
3379template <typename... Fields>
3380[[nodiscard]] consteval auto computeList(Fields... fields) noexcept {
3381 return detail::ComputeList<Fields...>{std::tuple<Fields...>{fields...}};
3382}
3383
3384/// @brief Recomputes every entry of `A::computedFields` in place on @p action.
3385///
3386/// A no-op for actions with no `computedFields` declaration -- backward
3387/// compatible with every existing action type. For an action that does
3388/// declare `computedFields`, every entry is evaluated in declaration order via
3389/// `detail::recomputeOne` (see that function for the per-entry semantics:
3390/// empty-input propagation and declared-precision retagging). Called from the
3391/// reactive `set<>` path (`bridge.hpp`, live/non-authoritative) and from every
3392/// dispatch site (`bridge.hpp`, `registry.hpp`, authoritative) so the value
3393/// the client displays and the value the server stores are derived from the
3394/// identical function over identically-reconciled inputs.
3395/// @tparam A Action type (a reflectable aggregate).
3396/// @param action Draft action whose computed members are overwritten in place.
3397template <typename A>
3398constexpr void recomputeAll(A& action) {
3399 if constexpr (detail::HasComputedFields<A>) {
3400 std::apply([&](const auto&... field) { (detail::recomputeOne(action, field), ...); },
3401 A::computedFields.fields);
3402 } else {
3403 static_cast<void>(action);
3404 }
3405}
3406
3407/// @brief Builds a `FieldMeta` for the member named by @p MemberPtr, so the
3408/// wire key is never restated as a string.
3409///
3410/// @warning Because this resolves @p MemberPtr via runtime reflection on a
3411/// probe instance of its *own* containing type, a `fieldMetadata` array built
3412/// from `describe<>()` cannot be a single in-class `static constexpr`
3413/// initializer (the type is still incomplete at that point, and glaze's
3414/// reflection for it is not `constexpr` either — see
3415/// docs/spec/forms/forms.md, "deriving the field name from the member", for the
3416/// two compile errors this produces). Declare the member in the class
3417/// and define it just after the closing brace instead:
3418/// @code{.cpp}
3419/// struct RecordMeasurement {
3420/// Choice<std::int64_t, "ListSamples"> sampleId;
3421/// Density density{};
3422/// Moisture moisture{};
3423///
3424/// static const std::array<morph::forms::FieldMeta, 2> fieldMetadata;
3425/// };
3426/// inline const std::array<morph::forms::FieldMeta, 2> RecordMeasurement::fieldMetadata{
3427/// morph::forms::describe<&RecordMeasurement::sampleId>("Sample", "Which logged sample…"),
3428/// morph::forms::describe<&RecordMeasurement::moisture>().withReadOnly(),
3429/// };
3430/// @endcode
3431/// The plain `FieldMeta{.field = "sampleId", ...}` literal form has no such
3432/// restriction and stays a single in-class `static constexpr` array.
3433/// @tparam MemberPtr Pointer to the member, e.g. `&RecordMeasurement::sampleId`.
3434/// @param label Display label; empty infers one from the member name.
3435/// @param help Help text; empty omits `description`.
3436/// @return A `FieldMeta` naming @p MemberPtr's wire key, with @p label and @p help set.
3437template <auto MemberPtr>
3438[[nodiscard]] FieldMeta describe(std::string_view label = {}, std::string_view help = {}) noexcept {
3439 return FieldMeta{.field = detail::memberWireName<MemberPtr>(), .label = label, .help = help};
3440}
3441
3442/// @brief Rounds every `Quantity` member of @p action to its **declared**
3443/// precision, so the stored value matches the precision the schema
3444/// advertises via `x-decimalPlaces`.
3445///
3446/// A wire payload carries each `Quantity` with its own runtime `dp`, which a
3447/// client may set to anything. Left alone, the field is stored at the client's
3448/// `dp`, silently contradicting the schema's `x-decimalPlaces` (which is the
3449/// field's compile-time *declared* precision, `Quantity<U, Dec>::declaredDecimals`).
3450/// Calling this on the decode path — right after `ActionTraits<A>::fromJson` and
3451/// before dispatch — rounds each `Quantity` to `declaredPrecision()` so the two
3452/// agree. `atDeclaredPrecision()` performs an **exact `Rational` re-rounding**
3453/// (half away from zero, the rule the decimal formatter uses), so the value a
3454/// handler stores is the value the form displays — not a finer one hidden behind
3455/// a coarser tag. An empty `Quantity` is left empty; non-`Quantity` members are
3456/// untouched.
3457///
3458/// This is the enforcement half of the `x-decimalPlaces` contract: the schema
3459/// advertises the declared precision and the dispatch path stores at that
3460/// precision, rather than honouring whatever `dp` the client sent. Precision
3461/// beyond the declared amount is therefore **discarded, not hidden** — that is
3462/// the point, and it is why the operation normalises rather than rejects: the
3463/// same call also lands on server-derived `Quantity` values (see `recomputeOne`),
3464/// which routinely carry more decimals than the destination field declares.
3465/// @tparam A Action type (a reflectable aggregate).
3466/// @param action Draft action whose `Quantity` members are rounded in place.
3467template <typename A>
3468constexpr void reconcileDeclaredPrecision(A& action) {
3469 using Plain = std::remove_cvref_t<A>;
3470 // Only actions glaze can reflect member-by-member (aggregates, or types with
3471 // a `glz::meta`) can be walked here. Actions with hand-written codecs and no
3472 // reflectable shape — and there is nothing to retag on them anyway — fall
3473 // through as a no-op so this stays safe to call for *every* registered
3474 // action from the dispatch path, not only form actions.
3475 if constexpr (glz::reflectable<Plain> || glz::glaze_object_t<Plain>) {
3476 constexpr auto memberCount = glz::reflect<Plain>::size;
3477 [&]<std::size_t... I>(std::index_sequence<I...>) {
3478 [[maybe_unused]] auto retag = [&]<std::size_t Idx>() {
3479 // A `glz::meta` entry that computes its value yields a value,
3480 // not a reference: there is no stored member behind it to round.
3481 [[maybe_unused]] decltype(auto) member = morph::detail::reflectedMember<Idx>(action);
3482 using Member = std::remove_cvref_t<decltype(member)>;
3483 if constexpr (units::isQuantity<Member> && std::is_lvalue_reference_v<decltype(member)>) {
3484 member = member.atDeclaredPrecision();
3485 }
3486 };
3487 (retag.template operator()<I>(), ...);
3488 }(std::make_index_sequence<memberCount>{});
3489 } else {
3490 static_cast<void>(action);
3491 }
3492}
3493
3494/// @brief Thrown when a decoded action has a `Quantity` field whose engaged
3495/// value falls outside its unit's declared bounds (`UnitTraits<E>::bounds`).
3496///
3497/// Distinct from `morph::model::ValidationError`: this is a **decode-level**
3498/// rejection — a wire payload that violates a physical/unit constraint baked
3499/// into the field's type, caught before an action's own `validate()` (a
3500/// business-rule check) ever runs. See docs/spec/forms/forms.md, "Pre-decode
3501/// wire validation — `checkQuantityBounds`".
3502struct QuantityDecodeError : std::runtime_error {
3503 /// @brief Constructs the error with a message naming the offending field.
3504 /// @param fieldName The wire (JSON) name of the out-of-bounds field.
3505 explicit QuantityDecodeError(std::string_view fieldName)
3506 : std::runtime_error("quantity field out of declared bounds: " + std::string{fieldName}) {}
3507};
3508
3509/// @brief Checks every `Quantity` member of @p action against its unit's
3510/// declared bounds (`morph::units::Quantity::withinDeclaredBounds`,
3511/// driven by the optional `UnitTraits<E>::bounds(E)` customisation
3512/// point).
3513///
3514/// This is the **pre-decode wire validation seam**: called on the decode path
3515/// — right after `ActionTraits<A>::fromJson` and `reconcileDeclaredPrecision`,
3516/// before `recomputeAll`/`ActionValidator<A>::ready` — so a wire payload
3517/// carrying a value outside a field's declared physical/unit bounds (e.g. a
3518/// percentage above 100, a mass below zero) is rejected uniformly at the
3519/// framework level, before an action's own `validate()` (a business-rule
3520/// check, not a decode-level one) ever runs. No-op — always returns
3521/// `std::nullopt` — for actions with no `Quantity` members, or whose
3522/// `Quantity` members' units declare no `bounds()`: zero behaviour change,
3523/// backward compatible, exactly like `reconcileDeclaredPrecision`.
3524/// @tparam A Action type (a reflectable aggregate).
3525/// @param action Decoded action to check.
3526/// @return The wire name of the first out-of-bounds `Quantity` member
3527/// encountered (in declaration order), or `std::nullopt` when every
3528/// `Quantity` member is within its declared bounds (or the unit
3529/// declares none).
3530template <typename A>
3531[[nodiscard]] inline std::optional<std::string> checkQuantityBounds(const A& action) {
3532 using Plain = std::remove_cvref_t<A>;
3533 std::optional<std::string> offender;
3534 if constexpr (glz::reflectable<Plain> || glz::glaze_object_t<Plain>) {
3535 detail::forEachNamedMember(action, [&]<std::size_t I>(std::string_view name, const auto& member) {
3536 static_cast<void>(I);
3537 if (offender.has_value()) {
3538 return;
3539 }
3540 using Member = std::remove_cvref_t<decltype(member)>;
3541 if constexpr (units::isQuantity<Member>) {
3542 if (!member.withinDeclaredBounds()) {
3543 offender = std::string{name};
3544 }
3545 }
3546 });
3547 } else {
3548 static_cast<void>(action);
3549 }
3550 return offender;
3551}
3552
3553/// @brief Runs `checkQuantityBounds<A>(action)` and throws `QuantityDecodeError`
3554/// naming the first out-of-bounds field, if any. The throwing counterpart used
3555/// directly on the decode path (registry.hpp/bridge.hpp call sites); a caller
3556/// that wants the field name without an exception uses `checkQuantityBounds`
3557/// itself.
3558/// @tparam A Action type (a reflectable aggregate).
3559/// @param action Decoded action to check.
3560/// @throws QuantityDecodeError if any `Quantity` member is outside its unit's
3561/// declared bounds.
3562template <typename A>
3563inline void enforceQuantityBounds(const A& action) {
3564 if (auto offender = checkQuantityBounds(action); offender.has_value()) {
3565 throw QuantityDecodeError{*offender};
3566 }
3567}
3568
3569/// @brief Whether every required empty-capable member of @p action is
3570/// engaged (has a value).
3571///
3572/// Empty-capable covers `Quantity`, `Choice`, `Timestamp`, and any user type
3573/// satisfying `EmptyCapableField`. Required means: not a `std::optional<...>`
3574/// member, not listed in `A::optionalFields`, and not the destination of a
3575/// `A::computedFields` entry (a computed field is never something the user
3576/// must fill -- see `morph::forms::recomputeAll`). Intended as the body of the
3577/// action's `validate()`.
3578///
3579/// The walk is flat: only @p action's own top-level members are inspected,
3580/// never a nested aggregate's, a `std::vector<Sub>`'s, or the value inside a
3581/// `std::optional<Sub>`. A member whose type has no `hasValue()` -- a
3582/// `std::string`, a plain integer, a nested struct -- is skipped entirely, so
3583/// an action needing "this string is non-empty" or "this nested value is
3584/// itself valid" writes that in its own `validate()` alongside this call.
3585/// @tparam A Action type (a reflectable aggregate).
3586/// @param action Draft whose fields are checked.
3587/// @return `true` when no required empty-capable field is empty.
3588template <typename A>
3589[[nodiscard]] constexpr bool allRequiredEngaged(const A& action) noexcept {
3590 bool allEngaged = true;
3591 detail::forEachNamedMember(action, [&]<std::size_t I>(std::string_view name, const auto& member) {
3592 using Member = std::remove_cvref_t<decltype(member)>;
3593 if constexpr (EmptyCapableField<Member>) {
3594 const bool isComputed =
3595 detail::isComputedDestinationMember(action, static_cast<const void*>(std::addressof(member)));
3596 if (!detail::declaredOptional<A>(name) && !isComputed && !member.hasValue()) {
3597 allEngaged = false;
3598 }
3599 } else {
3600 static_cast<void>(name);
3601 static_cast<void>(member);
3602 }
3603 });
3604 return allEngaged;
3605}
3606
3607/// @brief Whether every numeric member of @p action satisfies the
3608/// `minimum`/`maximum`/`multipleOf` its `A::fieldMetadata` entry
3609/// declares.
3610///
3611/// The evaluating half of the per-field bounds vocabulary: `schemaJson<A>()`
3612/// serves the same three declarations as JSON-Schema keys, so a client can
3613/// gate on them, and this is what the server checks — one declaration, two
3614/// consumers, no second source of truth (`examples/IMPLEMENTATION.md` rule 3).
3615/// Intended as (part of) the action's `validate()`, which puts it on every
3616/// dispatch path `ActionValidator<A>::ready` already guards.
3617///
3618/// **An unengaged `EmptyCapableField` is vacuously satisfied**, exactly as the
3619/// `formRules` comparison kinds are: a form still being filled in must not
3620/// fail a bound on a field that has no value yet, and whether the field has to
3621/// be filled at all is `required`/`allRequiredEngaged`'s question. A field
3622/// with no empty state is always checked.
3623///
3624/// Bounds are read from these member kinds and no others: `Quantity` (its
3625/// engaged `math::Rational`, in the canonical unit), a bare `math::Rational`,
3626/// and an integral member satisfying `detail::BoundCheckableInteger`. A
3627/// declaration on a member of any other type — a `std::string`, a `bool`, a
3628/// `std::uint64_t` — is inert here; the schema still advertises it.
3629/// @tparam A Action type (a reflectable aggregate).
3630/// @param action Draft whose fields are checked.
3631/// @return `true` when no declared bound is violated, and trivially `true`
3632/// for an action that declares no `fieldMetadata`.
3633template <typename A>
3634[[nodiscard]] bool allFieldBoundsSatisfied(const A& action) noexcept {
3635 if constexpr (!detail::HasFieldMetadata<A>) {
3636 static_cast<void>(action);
3637 return true;
3638 } else {
3639 bool satisfied = true;
3640 detail::forEachNamedMember(action, [&]<std::size_t I>(std::string_view name, const auto& member) {
3641 using Member = std::remove_cvref_t<decltype(member)>;
3642 const FieldMeta* meta = detail::findFieldMeta<A>(name);
3643 if (meta == nullptr || !detail::declaresAnyBound(*meta)) {
3644 return;
3645 }
3646 if constexpr (units::isQuantity<Member>) {
3647 if (member.hasValue() && !detail::satisfiesDeclaredBounds(*meta, *member)) {
3648 satisfied = false;
3649 }
3650 } else if constexpr (std::same_as<Member, ::morph::math::Rational>) {
3651 if (!detail::satisfiesDeclaredBounds(*meta, member)) {
3652 satisfied = false;
3653 }
3654 } else if constexpr (detail::BoundCheckableInteger<Member>) {
3655 const ::morph::math::Rational value{static_cast<std::int64_t>(member),
3657 if (!detail::satisfiesDeclaredBounds(*meta, value)) {
3658 satisfied = false;
3659 }
3660 }
3661 });
3662 return satisfied;
3663 }
3664}
3665
3666/// @brief Generates the JSON Schema for action type @p A, ready for a
3667/// client-side form renderer.
3668///
3669/// glaze's `write_json_schema<A>()` output, post-processed with:
3670/// - a top-level `required` array (see file docs for the rule),
3671/// - `x-decimalPlaces` on every `Quantity` property (the field's declared
3672/// precision — see `reconcileDeclaredPrecision`),
3673/// - `x-order` (declaration index) on every property.
3674///
3675/// The result is fixed per type, so it is computed once and cached. On any
3676/// internal failure the unmerged glaze schema (or an empty string if even
3677/// that failed) is returned rather than throwing — schema generation is a
3678/// description facility, never worth crashing a server over.
3679///
3680/// The single exception is a **self-contradicting declaration**. When an
3681/// `A::formRules` entry caps engagement over two or more fields that `A` also
3682/// makes `required`, the pair describes a form no submission can satisfy, and
3683/// `UnsatisfiableFormError` is thrown. That is an author error in `A`, decidable
3684/// at generation time, and every client would otherwise inherit an
3685/// unsubmittable form (see `detail::rejectUnsatisfiableRules`). Because the
3686/// cache is a function-local `static`, the throw leaves it uninitialised and a
3687/// later call re-runs the check rather than serving a half-built schema.
3688///
3689/// Returned **by reference**, like `model::payloadFingerprint<A>()` and
3690/// `model::payloadShapeString<A>()` (`core/payload_schema.hpp`), which cache the
3691/// same way: the cached string is created once per type per process, is never
3692/// mutated afterwards, and lives until the process exits, so the reference stays
3693/// valid for as long as any caller could hold it. Returning it by value made
3694/// every call — including the per-request ones on a server's descriptor path —
3695/// an allocation plus a copy of the whole schema for a string the caller almost
3696/// always only reads. A caller that genuinely needs its own mutable copy asks
3697/// for one (`std::string mine = schemaJson<A>();`), which is what the by-value
3698/// signature used to do unconditionally.
3699/// @tparam A Action type (a reflectable aggregate).
3700/// @return Reference to the process-lifetime merged schema JSON for `A`.
3701/// @throws UnsatisfiableFormError if `A::formRules` declares a capping rule
3702/// (`exactlyOneOf` / `mutuallyExclusive`) over two or more fields that
3703/// are also in `A`'s derived `required` array.
3704template <typename A>
3705[[nodiscard]] const std::string& schemaJson() {
3706 static const std::string cached =
3707 detail::mergeSchemaExtras<A>(glz::write_json_schema<A>().value_or(std::string{}));
3708 return cached;
3709}
3710
3711} // namespace morph::forms
AnyHandle list(std::vector< AnyHandle > const &items)
{"list": [...]}: a list built from expressions.
Definition author.hpp:1123
Handle< T > ref(std::string const &path)
A named read, {"ref": "name.path"}: a param, state, let, query value, element value or bound name.
Definition author.hpp:1137
Command set(AnyHandle const &target, AnyHandle const &value)
set: writes a state, an input's value or an app name.
Definition author.hpp:2724
morph::forms::BlankAs: what a blank string field submits.
BlankAs
What a renderer submits for a string field the user left blank (FieldMeta::blankAs).
Definition blank_as.hpp:17
@ Omit
Blank is "no value": the member is left out of the payload (the default).
Definition blank_as.hpp:19
Fields whose value is picked from a list served by another action.
Constraint: a field of type V can be compared against a literal of type L at all.
Definition forms.hpp:1290
Concept: Cond may be used as a nested condition — inside an andOf/orOf/notOf tree,...
Definition forms.hpp:909
Concept: a field type with an internal empty state (Quantity, Choice, Timestamp, or any user type exp...
Definition forms.hpp:431
Broader than EmptyCapableField: also covers a plain std::optional<T> member (e.g. std::optional<std::...
Definition forms.hpp:871
Concept: action A declares a static constexpr bool explicitSubmit, opting its form out of auto-submit...
Definition forms.hpp:1948
Concept: action A declares a static constexpr formRules member (a RuleList<...>). Mirrors detail::Has...
Definition forms.hpp:1926
Constraint: literal types equals(...) accepts — the closed, JSON-representable scalar set a field can...
Definition forms.hpp:1248
Shared compile-time, NTTP-capable fixed string.
consteval auto computed(Fn fn) noexcept
Builds one computed-field declaration binding a destination member to its declared input members and ...
Definition forms.hpp:3367
constexpr auto engaged(V A::*field)
Builds an Engaged<V, A> condition testing whether field is engaged.
Definition forms.hpp:954
consteval auto computeList(Fields... fields) noexcept
Builds a ComputeList from one or more computed(...) declarations.
Definition forms.hpp:3380
constexpr auto andOf(Cond0 condition0, Conds... conditions)
Builds an And<A, Conds...> condition: every listed condition must hold.
Definition forms.hpp:1797
constexpr auto atLeastOneOf(Vs A::*... fields)
Builds an AtLeastOneOf<A, Vs...> rule over fields.
Definition forms.hpp:1573
constexpr std::int64_t kExactDoubleLimitSigned
Signed spelling of kExactDoubleLimit, for the negative bound.
Definition forms.hpp:1305
constexpr bool allRequiredEngaged(const A &action) noexcept
Whether every required empty-capable member of action is engaged (has a value).
Definition forms.hpp:3589
constexpr auto greater(V A::*lhs, V A::*rhs)
Builds a Greater<V, A> rule/condition: *lhs > *rhs.
Definition forms.hpp:1061
const std::string & schemaJson()
Generates the JSON Schema for action type A, ready for a client-side form renderer.
Definition forms.hpp:3705
void enforceQuantityBounds(const A &action)
Runs checkQuantityBounds<A>(action) and throws QuantityDecodeError naming the first out-of-bounds fie...
Definition forms.hpp:3563
constexpr auto ruleList(Rules... rules)
Composes rules into the RuleList an action assigns to its static constexpr formRules member.
Definition forms.hpp:1919
constexpr auto lessOrEqual(V A::*lhs, V A::*rhs)
Builds a LessOrEqual<V, A> rule/condition: *lhs <= *rhs.
Definition forms.hpp:1238
FieldMeta describe(std::string_view label={}, std::string_view help={}) noexcept
Builds a FieldMeta for the member named by MemberPtr, so the wire key is never restated as a string.
Definition forms.hpp:3438
constexpr auto less(V A::*lhs, V A::*rhs)
Builds a Less<V, A> rule/condition: *lhs < *rhs.
Definition forms.hpp:1179
constexpr auto requiredWhen(V A::*field, Cond when)
Builds a RequiredWhen<V, A, Cond> rule: field becomes required exactly when when holds.
Definition forms.hpp:1471
constexpr void reconcileDeclaredPrecision(A &action)
Rounds every Quantity member of action to its declared precision, so the stored value matches the pre...
Definition forms.hpp:3468
constexpr auto visibleWhen(V A::*field, Cond when)
Builds a VisibleWhen<V, A, Cond> presentation rule: field is shown only while when holds.
Definition forms.hpp:1680
constexpr auto notOf(Cond condition)
Builds a Not<A, Cond> condition: negates condition.
Definition forms.hpp:1898
constexpr auto equals(V A::*field, L literal)
Builds an Equals<V, A, L> condition: field's engaged value equals literal.
Definition forms.hpp:1393
constexpr auto mutuallyExclusive(Vs A::*... fields)
Builds a MutuallyExclusive<A, Vs...> rule over fields.
Definition forms.hpp:1624
std::optional< std::string > checkQuantityBounds(const A &action)
Checks every Quantity member of action against its unit's declared bounds (morph::units::Quantity::wi...
Definition forms.hpp:3531
constexpr auto orOf(Cond0 condition0, Conds... conditions)
Builds an Or<A, Conds...> condition: at least one listed condition must hold.
Definition forms.hpp:1852
bool allFieldBoundsSatisfied(const A &action) noexcept
Whether every numeric member of action satisfies the minimum/maximum/multipleOf its A::fieldMetadata ...
Definition forms.hpp:3634
constexpr void recomputeAll(A &action)
Recomputes every entry of A::computedFields in place on action.
Definition forms.hpp:3398
constexpr std::uint64_t kExactDoubleLimit
The largest N such that every integer in [0, N] is exactly representable as an IEEE-754 double: 2^53.
Definition forms.hpp:1302
constexpr bool allRulesSatisfied(const A &action) noexcept
Whether every validation rule in A::formRules holds for action. Presentation rules (visibleWhen / rea...
Definition forms.hpp:1979
constexpr auto notEngaged(V A::*field)
Builds a NotEngaged<V, A> condition testing whether field is not engaged.
Definition forms.hpp:998
constexpr auto exactlyOneOf(Vs A::*... fields)
Builds an ExactlyOneOf<A, Vs...> rule over fields.
Definition forms.hpp:1522
constexpr auto readonlyWhen(V A::*field, Cond when)
Builds a ReadonlyWhen<V, A, Cond> presentation rule: field is editable only while when does not hold.
Definition forms.hpp:1735
constexpr auto greaterOrEqual(V A::*lhs, V A::*rhs)
Builds a GreaterOrEqual<V, A> rule/condition: *lhs >= *rhs.
Definition forms.hpp:1120
Visual structure (sections, tabs, accordions, column spans) layered over an action's flat field list.
constexpr std::string_view groupKindName(GroupKind kind) noexcept
Maps a GroupKind to the string mergeSchemaExtras emits for it in x-layout.groups[]....
Definition layout.hpp:45
Unit-tagged, optionally-empty exact values with a derivation trail.
constexpr bool isQuantity
true when T (cvref-stripped) is a morph::units::Quantity.
Definition quantity.hpp:950
Exact rational arithmetic for morph model/action values.
The value glaze reads and writes under a reflected key.
constexpr decltype(auto) reflectedMember(T &object)
The value glaze reads and writes under glz::reflect<T>::keys[I].
Compound condition: all of Conds... hold. Nests to any depth — each Cond may itself be a leaf (Engage...
Definition forms.hpp:1749
static constexpr bool isCondition
Usable as a nested condition: this node's test() is a function of the action, so composing it into an...
Definition forms.hpp:1757
std::tuple< Conds... > conditions
The nested conditions, in declaration order.
Definition forms.hpp:1751
static constexpr detail::RuleKind kind
The wire "kind" this node emits: "and".
Definition forms.hpp:1753
static constexpr bool isPresentation
Validation rule (not presentation): participates in the gate when used as a top-level formRules entry...
Definition forms.hpp:1760
constexpr bool test(const A &action) const noexcept
Evaluates the condition against action.
Definition forms.hpp:1765
glz::generic_u64 emitNode() const
Emits this condition's x-rules JSON node.
Definition forms.hpp:1771
Rule: at least one of the listed fields is engaged.
Definition forms.hpp:1531
static constexpr bool isCondition
Usable as a nested condition: this node's test() is a function of the action, so composing it into an...
Definition forms.hpp:1539
static constexpr bool isPresentation
Validation rule (not presentation): participates in the gate.
Definition forms.hpp:1541
static constexpr detail::RuleKind kind
The wire "kind" this rule emits: "atLeastOneOf".
Definition forms.hpp:1535
glz::generic_u64 emitNode() const
Emits this rule's x-rules JSON node.
Definition forms.hpp:1555
constexpr bool test(const A &action) const noexcept
Evaluates the rule against action.
Definition forms.hpp:1546
std::tuple< Vs A::*... > fields
Pointers to the member fields this rule ranges over.
Definition forms.hpp:1533
ConflictingOptionsError(std::string_view actionName, std::string_view field)
Constructs the error naming the action type and the member.
Definition forms.hpp:2035
Condition: field is engaged (has a value). One of the closed condition kinds a requiredWhen / visible...
Definition forms.hpp:919
glz::generic_u64 emitNode() const
Emits this condition's x-rules JSON node.
Definition forms.hpp:936
static constexpr bool isCondition
Usable as a nested condition: this node's test() is a function of the action, so composing it into an...
Definition forms.hpp:927
V A::* field
Pointer to the member this condition inspects.
Definition forms.hpp:921
static constexpr detail::RuleKind kind
The wire "kind" this condition emits: "engaged".
Definition forms.hpp:923
constexpr bool test(const A &action) const noexcept
Evaluates the condition against action.
Definition forms.hpp:932
Condition: field's engaged value equals literal. An unengaged field is not vacuously satisfied here (...
Definition forms.hpp:1315
glz::generic_u64 emitNode() const
Emits this condition's x-rules JSON node.
Definition forms.hpp:1347
V A::* field
Pointer to the member this condition inspects.
Definition forms.hpp:1317
constexpr bool test(const A &action) const noexcept
Evaluates the condition against action.
Definition forms.hpp:1331
static constexpr bool isCondition
Usable as a nested condition: this node's test() is a function of the action, so composing it into an...
Definition forms.hpp:1325
L literal
The literal to compare the field's engaged value against.
Definition forms.hpp:1319
static constexpr detail::RuleKind kind
The wire "kind" this condition emits: "equals".
Definition forms.hpp:1321
Rule: exactly one of the listed fields is engaged.
Definition forms.hpp:1480
static constexpr bool isPresentation
Validation rule (not presentation): participates in the gate.
Definition forms.hpp:1490
glz::generic_u64 emitNode() const
Emits this rule's x-rules JSON node.
Definition forms.hpp:1504
constexpr bool test(const A &action) const noexcept
Evaluates the rule against action.
Definition forms.hpp:1495
static constexpr bool isCondition
Usable as a nested condition: this node's test() is a function of the action, so composing it into an...
Definition forms.hpp:1488
std::tuple< Vs A::*... > fields
Pointers to the member fields this rule ranges over.
Definition forms.hpp:1482
static constexpr detail::RuleKind kind
The wire "kind" this rule emits: "exactlyOneOf".
Definition forms.hpp:1484
Per-field presentation overrides and scalar bounds: label, help, placeholder, read-only,...
Definition forms.hpp:225
std::string_view placeholder
In-control placeholder hint; empty omits x-placeholder.
Definition forms.hpp:233
constexpr FieldMeta withHidden() const noexcept
Returns a copy with hidden set to true.
Definition forms.hpp:360
std::string_view label
Display label; empty infers a title-cased name from field.
Definition forms.hpp:229
std::optional<::morph::math::Rational > minimum
Inclusive lower bound on the field's numeric value; emitted as the standard JSON-Schema minimum....
Definition forms.hpp:266
std::string_view optionsAction
Type id of the registered action whose result rows are this member's options, for a member that is no...
Definition forms.hpp:333
bool readOnly
Displayed but not editable when true; emits x-readonly.
Definition forms.hpp:254
std::optional<::morph::math::Rational > multipleOf
The field's value must be an exact integer multiple of this; emitted as the standard JSON-Schema mult...
Definition forms.hpp:285
std::string_view labelField
Field of each options row shown to the user (x-optionLabel); set with optionsAction.
Definition forms.hpp:339
std::string_view widget
Widget-selection override; empty means "use the field type's own `widget()`,...
Definition forms.hpp:252
std::string_view i18nKey
Explicit message-key stem override for this field's i18n catalog keys; empty means "derive the stem f...
Definition forms.hpp:245
constexpr FieldMeta withMinimum(::morph::math::Rational bound) const noexcept
Returns a copy with minimum set to bound.
Definition forms.hpp:369
constexpr FieldMeta withDecimals(::morph::math::DecimalPlaces places) const noexcept
Returns a copy with decimals set to places.
Definition forms.hpp:406
constexpr FieldMeta withPlaceholder(std::string_view text) const noexcept
Returns a copy with placeholder set to text.
Definition forms.hpp:344
std::string_view help
Help text; empty omits description.
Definition forms.hpp:231
constexpr FieldMeta withMultipleOf(::morph::math::Rational step) const noexcept
Returns a copy with multipleOf set to step.
Definition forms.hpp:388
constexpr FieldMeta withMaximum(::morph::math::Rational bound) const noexcept
Returns a copy with maximum set to bound.
Definition forms.hpp:378
std::string_view unit
Display unit for a member whose C++ type carries none (a plain double, float or integral member); emi...
Definition forms.hpp:298
bool hidden
Not shown at all when true; emits x-hidden. The field still travels in the payload (see docs/spec/for...
Definition forms.hpp:258
constexpr FieldMeta withUnit(std::string_view text) const noexcept
Returns a copy with unit set to text.
Definition forms.hpp:397
constexpr FieldMeta withBlankAs(BlankAs mode) const noexcept
Returns a copy with blankAs set to mode.
Definition forms.hpp:415
std::string_view field
Wire key of the member this entry describes.
Definition forms.hpp:227
constexpr FieldMeta withReadOnly() const noexcept
Returns a copy with readOnly set to true.
Definition forms.hpp:352
std::optional<::morph::math::Rational > maximum
Inclusive upper bound on the field's numeric value; emitted as the standard JSON-Schema maximum....
Definition forms.hpp:271
BlankAs blankAs
What a blank control submits for a std::string / std::optional<std::string> member; BlankAs::Empty em...
Definition forms.hpp:326
std::optional<::morph::math::DecimalPlaces > decimals
Display and entry precision: for a plain double/float member emitted as x-displayDecimals,...
Definition forms.hpp:314
std::string_view valueField
Field of each options row submitted as the value (x-optionValue); set with optionsAction.
Definition forms.hpp:336
Rule/condition: *lhs >= *rhs when both operands are engaged; vacuously satisfied when either is uneng...
Definition forms.hpp:1072
V A::* rhs
Pointer to the right-hand member.
Definition forms.hpp:1076
static constexpr detail::RuleKind kind
The wire "kind" this node emits: "greaterOrEqual".
Definition forms.hpp:1078
static constexpr bool isPresentation
Validation rule (not presentation): participates in the gate.
Definition forms.hpp:1084
static constexpr bool isCondition
Usable as a nested condition: this node's test() is a function of the action, so composing it into an...
Definition forms.hpp:1082
V A::* lhs
Pointer to the left-hand member.
Definition forms.hpp:1074
constexpr bool test(const A &action) const noexcept
Evaluates the comparison against action.
Definition forms.hpp:1089
glz::generic_u64 emitNode() const
Emits this rule's x-rules JSON node.
Definition forms.hpp:1100
Rule/condition: *lhs > *rhs when both operands are engaged; vacuously satisfied when either is unenga...
Definition forms.hpp:1013
V A::* rhs
Pointer to the right-hand member.
Definition forms.hpp:1017
glz::generic_u64 emitNode() const
Emits this rule's x-rules JSON node.
Definition forms.hpp:1041
V A::* lhs
Pointer to the left-hand member.
Definition forms.hpp:1015
static constexpr bool isPresentation
Validation rule (not presentation): participates in the gate.
Definition forms.hpp:1025
static constexpr bool isCondition
Usable as a nested condition: this node's test() is a function of the action, so composing it into an...
Definition forms.hpp:1023
static constexpr detail::RuleKind kind
The wire "kind" this node emits: "greater".
Definition forms.hpp:1019
constexpr bool test(const A &action) const noexcept
Evaluates the comparison against action.
Definition forms.hpp:1030
IncompleteOptionsError(std::string_view actionName, std::string_view field, std::string_view missing)
Constructs the error naming the action type, the member and what it lacks.
Definition forms.hpp:2053
Rule/condition: *lhs <= *rhs when both operands are engaged; vacuously satisfied when either is uneng...
Definition forms.hpp:1190
V A::* lhs
Pointer to the left-hand member.
Definition forms.hpp:1192
glz::generic_u64 emitNode() const
Emits this rule's x-rules JSON node.
Definition forms.hpp:1218
static constexpr bool isCondition
Usable as a nested condition: this node's test() is a function of the action, so composing it into an...
Definition forms.hpp:1200
V A::* rhs
Pointer to the right-hand member.
Definition forms.hpp:1194
static constexpr detail::RuleKind kind
The wire "kind" this node emits: "lessOrEqual".
Definition forms.hpp:1196
static constexpr bool isPresentation
Validation rule (not presentation): participates in the gate.
Definition forms.hpp:1202
constexpr bool test(const A &action) const noexcept
Evaluates the comparison against action.
Definition forms.hpp:1207
Rule/condition: *lhs < *rhs when both operands are engaged; vacuously satisfied when either is unenga...
Definition forms.hpp:1131
constexpr bool test(const A &action) const noexcept
Evaluates the comparison against action.
Definition forms.hpp:1148
static constexpr bool isCondition
Usable as a nested condition: this node's test() is a function of the action, so composing it into an...
Definition forms.hpp:1141
static constexpr bool isPresentation
Validation rule (not presentation): participates in the gate.
Definition forms.hpp:1143
V A::* rhs
Pointer to the right-hand member.
Definition forms.hpp:1135
static constexpr detail::RuleKind kind
The wire "kind" this node emits: "less".
Definition forms.hpp:1137
glz::generic_u64 emitNode() const
Emits this rule's x-rules JSON node.
Definition forms.hpp:1159
V A::* lhs
Pointer to the left-hand member.
Definition forms.hpp:1133
Rule: at most one of the listed fields is engaged.
Definition forms.hpp:1582
constexpr bool test(const A &action) const noexcept
Evaluates the rule against action.
Definition forms.hpp:1597
static constexpr bool isCondition
Usable as a nested condition: this node's test() is a function of the action, so composing it into an...
Definition forms.hpp:1590
static constexpr detail::RuleKind kind
The wire "kind" this rule emits: "mutuallyExclusive".
Definition forms.hpp:1586
glz::generic_u64 emitNode() const
Emits this rule's x-rules JSON node.
Definition forms.hpp:1606
static constexpr bool isPresentation
Validation rule (not presentation): participates in the gate.
Definition forms.hpp:1592
std::tuple< Vs A::*... > fields
Pointers to the member fields this rule ranges over.
Definition forms.hpp:1584
Condition: field is not engaged. The complement of engaged.
Definition forms.hpp:963
V A::* field
Pointer to the member this condition inspects.
Definition forms.hpp:965
static constexpr detail::RuleKind kind
The wire "kind" this condition emits: "notEngaged".
Definition forms.hpp:967
static constexpr bool isCondition
Usable as a nested condition: this node's test() is a function of the action, so composing it into an...
Definition forms.hpp:971
glz::generic_u64 emitNode() const
Emits this condition's x-rules JSON node.
Definition forms.hpp:980
constexpr bool test(const A &action) const noexcept
Evaluates the condition against action.
Definition forms.hpp:976
Compound condition: the nested condition does not hold. Nests to any depth, and is usable directly as...
Definition forms.hpp:1864
static constexpr bool isPresentation
Validation rule (not presentation): participates in the gate when used as a top-level formRules entry...
Definition forms.hpp:1875
constexpr bool test(const A &action) const noexcept
Evaluates the condition against action.
Definition forms.hpp:1880
static constexpr bool isCondition
Usable as a nested condition: this node's test() is a function of the action, so composing it into an...
Definition forms.hpp:1872
glz::generic_u64 emitNode() const
Emits this condition's x-rules JSON node.
Definition forms.hpp:1884
static constexpr detail::RuleKind kind
The wire "kind" this node emits: "not".
Definition forms.hpp:1868
Cond condition
The negated condition.
Definition forms.hpp:1866
Compound condition: at least one of Conds... holds. Nests to any depth, and is usable directly as a t...
Definition forms.hpp:1809
static constexpr bool isPresentation
Validation rule (not presentation): participates in the gate when used as a top-level formRules entry...
Definition forms.hpp:1820
static constexpr bool isCondition
Usable as a nested condition: this node's test() is a function of the action, so composing it into an...
Definition forms.hpp:1817
std::tuple< Conds... > conditions
The nested conditions, in declaration order.
Definition forms.hpp:1811
static constexpr detail::RuleKind kind
The wire "kind" this node emits: "or".
Definition forms.hpp:1813
constexpr bool test(const A &action) const noexcept
Evaluates the condition against action.
Definition forms.hpp:1825
glz::generic_u64 emitNode() const
Emits this condition's x-rules JSON node.
Definition forms.hpp:1831
Thrown when a decoded action has a Quantity field whose engaged value falls outside its unit's declar...
Definition forms.hpp:3502
QuantityDecodeError(std::string_view fieldName)
Constructs the error with a message naming the offending field.
Definition forms.hpp:3505
Presentation rule: the listed field is editable only while Cond does not hold (the field is read-only...
Definition forms.hpp:1691
constexpr bool test(const A &action) const noexcept
Always true: presentation rules never gate submission. A renderer inspects when directly (not this me...
Definition forms.hpp:1707
glz::generic_u64 emitNode() const
Emits this rule's x-rules JSON node.
Definition forms.hpp:1714
static constexpr bool isPresentation
Presentation rule: never participates in the gate.
Definition forms.hpp:1699
Cond when
The condition that, when true, makes field read-only.
Definition forms.hpp:1695
static constexpr detail::RuleKind kind
The wire "kind" this rule emits: "readonlyWhen".
Definition forms.hpp:1697
V A::* field
Pointer to the member whose editability this rule controls.
Definition forms.hpp:1693
Rule: field must be engaged whenever Cond holds; vacuously satisfied (not required) while the conditi...
Definition forms.hpp:1427
glz::generic_u64 emitNode() const
Emits this rule's x-rules JSON node.
Definition forms.hpp:1449
Cond when
The condition that, when true, makes field required.
Definition forms.hpp:1431
V A::* field
Pointer to the member this rule may require.
Definition forms.hpp:1429
constexpr bool test(const A &action) const noexcept
Evaluates the rule against action.
Definition forms.hpp:1440
static constexpr detail::RuleKind kind
The wire "kind" this rule emits: "requiredWhen".
Definition forms.hpp:1433
static constexpr bool isPresentation
Validation rule (not presentation): participates in the gate.
Definition forms.hpp:1435
Composed list of an action's declared cross-field rules — the value of A::formRules....
Definition forms.hpp:1907
std::tuple< Rules... > rules
The declared rules, in declaration order.
Definition forms.hpp:1909
UnsatisfiableFormError(std::string_view actionName, std::string_view ruleKind, std::string_view requiredFields)
Constructs the error with a message naming the action type, the offending rule kind,...
Definition forms.hpp:2015
Presentation rule: the listed field is shown only while Cond holds. Never gates allRulesSatisfied — w...
Definition forms.hpp:1636
static constexpr bool isPresentation
Presentation rule: never participates in the gate.
Definition forms.hpp:1644
glz::generic_u64 emitNode() const
Emits this rule's x-rules JSON node.
Definition forms.hpp:1659
Cond when
The condition that, when true, makes field visible.
Definition forms.hpp:1640
static constexpr detail::RuleKind kind
The wire "kind" this rule emits: "visibleWhen".
Definition forms.hpp:1642
constexpr bool test(const A &action) const noexcept
Always true: presentation rules never gate submission. A renderer inspects when directly (not this me...
Definition forms.hpp:1652
V A::* field
Pointer to the member whose visibility this rule controls.
Definition forms.hpp:1638
Strong type for a decimal-precision count.
Definition rational.hpp:121
Exact rational number with a runtime decimal-precision tag.
Definition rational.hpp:375
std::shared_ptr< NodeData const > Node
A view-tree node: immutable, shared data.
Definition view.hpp:403