Skip to main content

kui_core/
diag.rs

1//! Diagnostics as data. The misconfigurations that fail silently — a grow
2//! weight with nothing to split against, a transition on a positional key
3//! under a sibling list that changes, two nodes sharing one key — all look
4//! like "the feature is broken" from the outside. The core can see them,
5//! so it reports them the way it reports everything else: as plain data a
6//! driver drains ([`crate::Core::take_warnings`]). The windowed runners
7//! print them; a headless test asserts on them, or on their absence.
8//!
9//! Each distinct (code, node) pair is reported once per core, so a
10//! condition that persists across frames costs one line, not a stream.
11//! The checks walk the frame's tree (about 10 ns per node, measured), so
12//! they run on the first two frames and then every [`CHECK_EVERY`] frames:
13//! a misconfiguration persists, so it still surfaces within that many
14//! frames, and the steady-state cost rounds to nothing. A bare `Core` runs
15//! them (headless tests are development by definition); the drivers decide
16//! for shipped apps — the Rust runner and the Node loops turn them on in
17//! development builds only, a standalone C context starts with them off —
18//! through [`crate::Core::set_diagnostics`].
19//!
20//! Several codes do not come from the tree walk. A prop name no table claims
21//! ([`UNKNOWN_PROP`]) is gone by the time the frame is a tree, so the
22//! binding that dropped it raises it through [`crate::Core::warn`], behind
23//! the same gate and the same dedup — and a window kind no build has
24//! ([`UNKNOWN_WINDOW_KIND`]) reaches the same door from `kui-ffi`, since
25//! only C can name one. The two about declared windows
26//! ([`DUPLICATE_WINDOW_CONFIG`], [`WINDOW_DECLARED_WHILE_CLOSED`]) come
27//! from the core's diff of the declared set, which has no node to hang
28//! them on: they are keyed by the window's name, the way `unknown-prop` is
29//! keyed by element and prop, and the way the kind is keyed by the window
30//! and the number. And a resource handle from another session
31//! ([`FOREIGN_RESOURCE`]) is noticed wherever a handle resolves — under a
32//! shaping closure, in the emitter, in the driver's audio backend — so the
33//! session's registry keeps the hits and `take_warnings` raises them,
34//! keyed by kind and handle.
35//!
36//! Two codes come from further out still, and both are about a sound.
37//! [`TRUNCATED_PLAYBACK`] is about a sound that was still playing when the
38//! core stopped it, and whether it was is the one thing the core cannot
39//! see: it queues the `stop` as data and a device applies it. So the core
40//! remembers which stops could have cut a one-shot off, the driver answers
41//! `crate::Core::audio_truncated` for the ones its handle found still
42//! running, and the warning lands on the node from there.
43//! [`PLAYBACK_REFUSED`] is the one the core cannot see at all: only the
44//! driver knows the device said no, so it reports the playback back
45//! (`Core::audio_refused`) and the core keys the warning on the node that
46//! asked for the sound. A headless core never hears either answer and so
47//! never raises either — which is right, since nothing played.
48
49use rustc_hash::{FxHashMap, FxHashSet};
50
51use crate::access::{self, Role};
52use crate::edit::EditStore;
53use crate::key::Key;
54use crate::resources::Foreign;
55use crate::schema;
56use crate::spec::{Dir, Sizing};
57use crate::text::TextSystem;
58use crate::tree::{NIL, Tree};
59
60/// A silent misconfiguration the core noticed while finishing a frame.
61#[derive(Clone, Debug, PartialEq, Eq)]
62pub struct Warning {
63    /// A stable identifier for the kind of problem: one of the constants
64    /// in this module. Match on it; the message is for people.
65    pub code: &'static str,
66    /// The node the warning is about — the parent, for
67    /// [`TRANSITION_AUTO_KEY`]; the shared key, for [`DUPLICATE_KEY`].
68    pub key: Key,
69    pub message: String,
70}
71
72impl Warning {
73    /// `{code, key, message}`, the key spelled by `h`.
74    pub fn to_value(&self, h: crate::value::Handles) -> crate::value::Value {
75        use crate::value::Value;
76        Value::map([
77            ("code", Value::str(self.code)),
78            ("key", (h.key)(self.key)),
79            ("message", Value::Str(self.message.clone())),
80        ])
81    }
82}
83
84/// One warning code and what it means, for the tables the bindings
85/// generate from the core (`docs/props.md`, the Node `WarningCode` union).
86/// `doc` is the const's own doc comment, so there is one text to edit.
87pub struct WarningDef {
88    pub code: &'static str,
89    pub doc: &'static str,
90}
91
92/// Declares the codes as the `pub const`s they have always been *and* the
93/// [`WARNINGS`] table from the same tokens: the doc comment on each const
94/// is the table's `doc`. A code added outside this block compiles, but
95/// `every_code_is_in_the_table_and_vice_versa` fails, so the two cannot
96/// drift.
97macro_rules! warnings {
98    ($( $(#[doc = $doc:literal])+ pub const $name:ident: &str = $code:literal; )*) => {
99        $( $(#[doc = $doc])+ pub const $name: &str = $code; )*
100        /// Every warning code with its description, in declaration order.
101        pub const WARNINGS: &[WarningDef] = &[
102            $( WarningDef { code: $code, doc: concat!($($doc, "\n"),+) }, )*
103        ];
104    };
105}
106
107warnings! {
108    /// A grow weight other than 1 on the only grow child of its parent (the
109    /// weight splits space between grow siblings, so alone it changes nothing)
110    /// or across the parent's main axis (cross-axis grow fills the parent
111    /// whatever its weight).
112    pub const GROW_WEIGHT_IGNORED: &str = "grow-weight-ignored";
113    /// The child count of a node changed while one of its children carries a
114    /// transition under an auto-assigned key. Auto keys are sibling positions,
115    /// so the children that shifted became new nodes and snapped instead of
116    /// easing. Give list items a key.
117    pub const TRANSITION_AUTO_KEY: &str = "transition-auto-key";
118    /// Two nodes in one frame share a key: everything retained per key
119    /// (transitions, scroll offsets, editors, layout events, hover state) is
120    /// mixed between them. Siblings need distinct keys.
121    pub const DUPLICATE_KEY: &str = "duplicate-key";
122    /// A label resolved by name (`focus("beta")` in Node, `env.set_focus("beta")`
123    /// in Lua, `kui_key_of` in C) is declared by more than one node in the
124    /// frame, under different parents, so they have distinct keys and the name
125    /// picked the first in tree order. Labels are unique among siblings, not
126    /// across a tree. An extension asking from inside its fill is answered
127    /// from the nodes it opened and no one else's, and the host from its own
128    /// first — so this is a clash among the asker's own. Give the node meant
129    /// a label nothing else declares, or pass the hex key an event carried.
130    /// Two nodes with the *same* key are `duplicate-key`.
131    pub const AMBIGUOUS_KEY: &str = "ambiguous-key";
132    /// A `focusRegion(name)` (`Core::focus_region`, `env.focus_region`,
133    /// `kui_focus_region`) named a node the frame after it did not declare
134    /// as a `focusRegion` — no node under the label, or a node without the
135    /// row — so nothing was entered and focus stayed where it was. The call
136    /// is resolved against the frame it lands on, so an `update` that
137    /// toggles a dock on and enters it in one go is fine; this is that
138    /// call with the view half missing, with a name the view spells
139    /// differently, or naming a node that is not a region
140    /// (`docs/adr/0022-focus-regions.md`, decision 4).
141    pub const FOCUS_REGION_WITHOUT_NODE: &str = "focus-region-without-node";
142    /// A `reveal` or `setScroll` by label (`env.reveal("rows")`,
143    /// `win.reveal("rows")`, `Core::reveal_label`) named a label the frame
144    /// it resolved against did not declare, so nothing moved. A label is
145    /// resolved when the frame finishes, so a view may name a node it is
146    /// declaring right now, or one the next frame declares; this is the
147    /// name spelled differently from the `key` that declares it, or the
148    /// node not declared at all (backlog DX15).
149    pub const LABEL_WITHOUT_NODE: &str = "label-without-node";
150    /// A text's `family` named a family no installed or loaded font has
151    /// (ADR 0037), so it shaped as sans. `sans`, `serif` and `mono` are
152    /// kui's own; any other name is matched as `addSystemFont` matches it,
153    /// and `systemFonts()` lists the names a machine has.
154    pub const UNKNOWN_FAMILY: &str = "unknown-family";
155    /// A `selectable` node inside another `selectable` node. Selection
156    /// scopes do not nest: the innermost one owns every run under it, so
157    /// the outer scope selects only the text outside the inner one — and
158    /// a drag that crosses the boundary stops there, which reads as a
159    /// selection that will not extend. Declare the scope once, on the
160    /// container whose text should select as one.
161    pub const NESTED_SELECTION_SCOPE: &str = "nested-selection-scope";
162    /// An image with no `label`: assistive technology has nothing to say
163    /// for it. Decorative images take `role="none"`.
164    pub const IMAGE_WITHOUT_LABEL: &str = "image-without-label";
165    /// A `slider` whose `valueNow` lies outside its own `valueMin` /
166    /// `valueMax`, or whose `valueMin` is above its `valueMax`. The row is
167    /// advertised verbatim, so a screen reader reads a value the range
168    /// says is impossible; the app that clamps in its own `update` keeps the
169    /// range in two places with nothing tying them, and this is the tie.
170    /// Declare the range the value is really held to, or clamp where the
171    /// view declares it.
172    pub const SLIDER_VALUE_OUT_OF_RANGE: &str = "slider-value-out-of-range";
173    /// `Core::add_fragment` was given WGSL that does not compile, so no
174    /// handle was minted and nothing will draw. The message carries naga's
175    /// own error with the line numbers moved into the app's source
176    /// (`docs/adr/0015-a-fragment-element-and-the-painter-it-is-not.md`,
177    /// decision 1). The source is rejected here rather than at the first
178    /// frame that shows it, so a headless test sees it too.
179    pub const FRAGMENT_REJECTED: &str = "fragment-rejected";
180    /// A `fragment` node declared more than sixteen `params`. The shader
181    /// takes four `vec4<f32>` and no more, so the extra numbers were
182    /// dropped; pass fewer, or pack what the fragment needs into the
183    /// sixteen it has.
184    pub const FRAGMENT_PARAMS_TRUNCATED: &str = "fragment-params-truncated";
185    /// A `polygon` declared more than eight points: the stock fragment
186    /// takes eight vertices in the sixteen params it has, so the rest
187    /// were dropped. Two polygons, or the path primitive kui does not have
188    /// (`docs/adr/0025-the-image-is-the-canvas.md`, decision 6).
189    pub const POLYGON_POINTS_TRUNCATED: &str = "polygon-points-truncated";
190    /// The frame's modal surface is not in a float, and content painted after
191    /// it is drawn on top of it: everything the user can see over the modal is
192    /// inert, which looks like inert-behind is broken. A modal that has to
193    /// cover the app is a float (`float="viewport"`); see
194    /// `docs/adr/0003-modal-surfaces.md`. Also raised for a modal that *is*
195    /// a float when another float from outside its scope stacks over it
196    /// (`docs/adr/0023-layers-stack-in-the-order-they-open.md`): a HUD
197    /// opened after the dialog is the same inert surface over it.
198    pub const MODAL_BEHIND_CONTENT: &str = "modal-behind-content";
199    /// A control (a button, link, tab, checkbox, slider, editor) with no
200    /// computable name: no `label`, and no text inside it. Icon buttons and
201    /// editors need a `label`.
202    pub const CONTROL_WITHOUT_NAME: &str = "control-without-name";
203    /// A focusable node inside a composite's *item* — a button inside a list
204    /// row, a link inside a tab. The item is one roving stop of a composite
205    /// (`docs/adr/0007-composite-keyboard-patterns.md`), so the Tab ring
206    /// stops at the item and nothing reaches what is inside it: declared, and
207    /// impossible, which is what `modal-behind-content` set the precedent
208    /// for. A focusable node inside the *container* but outside every item —
209    /// a "+" at the end of a tab bar — is reachable and is not reported.
210    pub const FOCUSABLE_INSIDE_ITEM: &str = "focusable-inside-item";
211    /// A `radio` with no `radioGroup` above it, or a `tab` with no
212    /// `tabList` — the stock `<radio>` included. Outside its container an
213    /// item is no composite's (`docs/adr/0007-composite-keyboard-patterns.md`):
214    /// each one is a Tab stop of its own, the arrows, Home and End do not
215    /// move the choice, and a screen reader announces no "2 of 3". Wrap the
216    /// set in the container, labelled with what the choice is. A `menuItem`
217    /// or `listItem` on its own is not reported: the menus build their own
218    /// container, and a row outside a list is only a looser reading.
219    pub const ITEM_OUTSIDE_CONTAINER: &str = "item-outside-container";
220    /// A `modal` surface with no `label`. A dialog is not named by the text
221    /// inside it (it is not one of ARIA's name-from-content roles), so a
222    /// screen reader announces it as an unnamed dialog — the same silent
223    /// defect `control-without-name` catches, on the node that just took
224    /// the user's focus. Only the derived dialog role is checked: a modal
225    /// that says what it is with an explicit `role` says it with a `label`
226    /// too, or means something naming works differently for.
227    pub const MODAL_WITHOUT_NAME: &str = "modal-without-name";
228    /// A node declares `live` but carries no `label` and holds no text, so
229    /// nothing it ever does can be announced: every platform derives the
230    /// spoken string from a name, and there is none to derive. The same
231    /// silent defect `image-without-label` catches, on the node that was
232    /// meant to speak (see
233    /// `docs/adr/0008-live-regions-and-announcements.md`).
234    pub const LIVE_REGION_WITHOUT_NAME: &str = "live-region-without-name";
235    /// The same announcement text was queued on two consecutive frames.
236    /// That is what an unguarded `announce` in a frame builder looks like
237    /// — a view runs every frame, so the message is said every frame —
238    /// and it is never what an app means: a message genuinely repeated is
239    /// repeated across frames the user did something in between. The
240    /// announcement still goes through; this names the builder that is
241    /// shouting.
242    pub const ANNOUNCEMENT_REPEATED: &str = "announcement-repeated";
243    /// `wrapChildren` on a container that cannot break lines: a column, a
244    /// row whose main axis scrolls, or a row of a table, whose children are
245    /// the table's columns. All lay out exactly as if the flag were absent,
246    /// which reads as "wrapping is broken"; see `LayoutSpec::wrap` for why
247    /// a column cannot have it.
248    pub const WRAP_IGNORED: &str = "wrap-ignored";
249    /// An alignment declared where it means nothing (backlog C13): a spread
250    /// (`spaceBetween` / `spaceAround` / `spaceEvenly`) on `crossAlign`,
251    /// `baseline` on `mainAlign` or on a column's `crossAlign`, or either
252    /// as a float's attach point. Each lays out as `start` — the two
253    /// centring spreads as `center` — which reads as "the value is
254    /// broken" when it is the axis that is wrong.
255    pub const ALIGN_IGNORED: &str = "align-ignored";
256    /// An `aspectRatio` with nothing it can set (backlog C14): both axes are
257    /// declared, or the width is `fit` under a `grow` or percent height,
258    /// which is resolved only after every width is. The ratio sizes a fit
259    /// height from the width, or a fit width from a fixed height.
260    pub const ASPECT_IGNORED: &str = "aspect-ignored";
261    /// A text node sits more than four levels below the `line` row above
262    /// it, which is as far as a text's place remembers its ancestors — so
263    /// `textHit` / `caretRect` asked by that row's key cannot find the run,
264    /// and a press inside it reports `byte: 0`. Flatten the wrappers between
265    /// the row and its text, or ask by a nearer key.
266    pub const TEXT_BEYOND_LINE: &str = "text-beyond-line";
267    /// One frame removed more nodes declaring `exit` than the exit store
268    /// will hold (4096, `depart::MAX_NODES`), so none of that frame's removal
269    /// animated: every departing node of it vanished at once, as a node with
270    /// no `exit` does, rather than some sliding out and the rest blinking
271    /// (`docs/adr/0012-the-exit-budget.md`, decision 2). Correct, and
272    /// invisible from the outside, which is the whole reason it is a line
273    /// here: a list that drops a thousand rows wants `exit` on the list, not
274    /// on every row. A removal that fits the budget but finds earlier exits
275    /// still in flight evicts those, oldest first, and is not this warning.
276    pub const EXIT_BUDGET: &str = "exit-budget";
277    /// A prop name nothing claims: not a schema row, not a composite, not one of
278    /// the element's own props (see `schema::known_prop`). The binding threw the
279    /// declaration away — `hoverBg` in a Lua table, `onclick` in JSX — so unlike
280    /// every other code here this one is raised by the frontend that saw it,
281    /// through `Core::warn`: by the time a frame is a tree the name is
282    /// gone. The message names the likely spelling. Also raised for a key
283    /// a menu row map carried that no row reads — `disabled` on a select's
284    /// option, where the key is `enabled` — by the binding that read the
285    /// row (backlog RG10).
286    pub const UNKNOWN_PROP: &str = "unknown-prop";
287    /// The process has spelled 65 536 distinct size expressions — the most
288    /// the table every window shares keeps, and never lets go of — and one
289    /// more was declared: that prop is left at its default (a `width` is
290    /// fit, a clamp none) instead of failing the frame, and so is every new
291    /// expression after it; the ones kept still resolve. A view that makes
292    /// a new expression per frame reaches it — a `{ max: [dragX, { percent:
293    /// 30 }] }` fed a splitter's fractional drag, a `format!` of the
294    /// pointer — where one expression per layout, with the moving part a px
295    /// size beside it, would not. Raised once per core; the message names
296    /// the last expression refused (backlog RG93).
297    pub const SIZE_EXPRESSIONS_FULL: &str = "size-expressions-full";
298    /// One name declared with two different window configs on the frame it
299    /// opened. The config is read on the opening edge only, and on that edge
300    /// the lowest declaring window wins (the first declaration within one
301    /// frame), so the pick is deterministic — but two places in the app
302    /// disagree about what `"palette"` is, and only one of them is right. See
303    /// `docs/adr/0004-multi-window.md`, decision 4.
304    pub const DUPLICATE_WINDOW_CONFIG: &str = "duplicate-window-config";
305    /// A window the user closed is still declared, so it stays closed: a
306    /// declaration reopens a window only when it *starts*, and this one never
307    /// stopped. The first version of every multi-window app does this — it
308    /// declares the window unconditionally — and from outside it looks like
309    /// `windows` being ignored. Handle the `{kind:"window", phase:"closed"}`
310    /// event, stop declaring the name, and declare it again to reopen. See
311    /// `docs/adr/0004-multi-window.md`, decision 6.
312    pub const WINDOW_DECLARED_WHILE_CLOSED: &str = "window-declared-while-closed";
313    /// A window declared with a `KUI_WINDOW_KIND_*` this build does not
314    /// have — `KUI_WINDOW_KIND_NORMAL` and `KUI_WINDOW_KIND_POPUP` are the
315    /// two there are. Only a C host can reach this: JSX and Lua name a kind
316    /// by string, so an unknown one is refused where it is written rather
317    /// than reported a frame later. The window still opens, as a normal
318    /// one, so a host built against a later header degrades to a window
319    /// rather than to nothing; this line is what keeps that from being
320    /// silent. See `docs/adr/0004-multi-window.md`, decision 9.
321    pub const UNKNOWN_WINDOW_KIND: &str = "unknown-window-kind";
322
323    /// A `setEditText` (`Core::set_edit_text`, `kui_edit_set_text`) named a
324    /// key or a label, the text was held for the frame that would declare
325    /// it, and the frame after the call declared no editor under that name
326    /// — so nothing was ever seeded and the text is dropped. The call is
327    /// meant to run from an `update` that also opens the editor, one frame
328    /// ahead of the view that declares it; this is the same call with the
329    /// view half missing, or with a name the view spells differently. Pass
330    /// the label the editor's `key` prop declares — the spelling that
331    /// needs nothing to exist yet — or the hex key an event carried.
332    /// `keyOf`/`kui_key_of` turns a label into that key, but only for an
333    /// editor some frame declared (Lua's verbs take the label itself). An editor that already
334    /// exists takes the text where the call is made and never reaches
335    /// this.
336    pub const EDIT_TEXT_WITHOUT_EDITOR: &str = "edit-text-without-editor";
337
338    /// A one-shot `audio` node went away — or changed its `src` — while
339    /// the sound it started was still playing, so the user heard it cut
340    /// off. Almost always a duration guessed short: the view keeps the
341    /// node declared for a constant it picked, and the asset is longer.
342    /// Ask for the sound instead of the guess —
343    /// `finish` (`AudioSpec::finish`, `finish` in JSX and Lua) releases the
344    /// playback on removal so it plays itself out, and a `tag` reports
345    /// `{kind:"sound", phase:"ended"}` when it gets there. A view that
346    /// means to cut the sound off says so by stopping what it started
347    /// (`Core::stop`), which is not reported, and a `looped` playback
348    /// never is: it has no end to be short of.
349    ///
350    /// Only a driver with a real device raises it, because only a device
351    /// knows the sound was still running: the core queues the `stop` and
352    /// the driver answers `Core::audio_truncated` for the ones its handle
353    /// found still playing. A headless `Ctx` therefore never raises it —
354    /// nothing plays — and the assertion point there is the other end of
355    /// the same fact: `audioCommands()` holding a `stop` for the node,
356    /// where the suite expected none.
357    pub const TRUNCATED_PLAYBACK: &str = "truncated-playback";
358
359    /// A `FontId` / `ImageId` / `SoundId` registered in one `Session` and used
360    /// through a core of another. Handles are unique to the process, so it
361    /// cannot resolve to somebody else's resource; it behaves as a removed
362    /// handle does (draws nothing, shapes as sans-serif, plays nothing), which
363    /// from outside looks like the resource never registered. Two
364    /// `Core::new()`s are two sessions; windows that share resources are built
365    /// with `Core::new_in` against one `Session`.
366    pub const FOREIGN_RESOURCE: &str = "foreign-resource";
367
368    /// An extension names a slot no host declared this frame, so it drew
369    /// nothing. A slot is a position the host declares in its own view by
370    /// full name, `ui.slot("ns/name")` — the namespace the host gave the
371    /// extension, then the name the extension lists; one listing none fills
372    /// `"ns/root"` after the host's view. Declare the slot, or drop the name
373    /// from the extension's list. See
374    /// `docs/adr/0014-slots-an-extension-fills-in-place.md`, decision 5.
375    pub const UNKNOWN_SLOT: &str = "unknown-slot";
376    /// A colour or length prop named a token — `bg = "$peach"` — that
377    /// nothing declared and that is no theme or metrics role, or named one
378    /// of the other kind (a length in a colour slot). The slot is left at
379    /// the row's default, as if the prop had not been written — no `bg`,
380    /// a fit width, the theme's foreground for a text's `color` — never an
381    /// explicit transparent or zero, which would hide the node a typo was
382    /// on; the same in every binding and in every place a `$name` can go,
383    /// a keyframe stop and an entrance included (backlog AR14), and what
384    /// `ui.token_color` / `token_length` answer `None` for in Rust. Raised
385    /// by the binding that lowered the reference, through
386    /// `Core::warn_unknown_token`, once per name, since the name is gone
387    /// by the time the frame is a tree. Also
388    /// raised at the declaration for a derived token whose source — the
389    /// `from`, or the colour a `mix` or `readable` names — is no colour
390    /// token declared before it and no theme role: that token is dropped,
391    /// the message names both, and the rest of the table lands. See
392    /// `docs/adr/0027-tokens-beside-the-theme.md`, decision 4, and
393    /// `docs/adr/0028-derived-tokens.md`.
394    pub const UNKNOWN_TOKEN: &str = "unknown-token";
395    /// A declared token took a theme or metrics role's name (`surface`,
396    /// `radius`) and was dropped: the roles are the corpus's contract and
397    /// `$surface` always means the theme's, so an app cannot shadow one.
398    /// Rename the token. See `docs/adr/0027-tokens-beside-the-theme.md`,
399    /// decision 6.
400    pub const RESERVED_TOKEN: &str = "reserved-token";
401    /// A slot name declared twice in one frame. The second declaration was
402    /// ignored: a fill is keyed by the slot's full name, so two fills of one
403    /// name would share every key. Two places for one extension are two
404    /// names. See ADR 0014, decision 5.
405    pub const DUPLICATE_SLOT: &str = "duplicate-slot";
406    /// An extension returned from `view` with nodes still open. The core
407    /// closed them at the depth the fill began, so the host's tree is what
408    /// the host declared; outside the guard, the rest of the host's view
409    /// would have landed inside the extension's last open node. The
410    /// extension has an `open` without its `close`. See ADR 0014,
411    /// decision 5.
412    pub const UNBALANCED_EXTENSION: &str = "unbalanced-extension";
413    /// An extension's `view` returned an error. The message is drawn in
414    /// red where the fill would have been, and reported here once per
415    /// extension and slot rather than once per frame.
416    pub const EXTENSION_VIEW_ERROR: &str = "extension-view-error";
417    /// An extension declared one of its *own* slots while it was drawing,
418    /// so filling it would have meant calling it inside itself. The slot
419    /// is left empty. An extension may host extensions (`Fill::add`), and
420    /// may declare their slots — what it cannot do is be its own guest.
421    /// See ADR 0014, decision 5.
422    pub const RECURSIVE_SLOT: &str = "recursive-slot";
423
424    /// The device refused a play: its voices are all held, or the sound
425    /// did not decode. A released playback (`finish`) holds one of the
426    /// device's 128 voices until its file ends, so a view that releases
427    /// faster than its sounds finish reaches the limit and the 129th play
428    /// is refused. The refusal is not left silent because the playback
429    /// never starts and so never ends: a `tag`ged node waiting for
430    /// `ended` would wait forever. It gets
431    /// `{kind:"sound", phase:"refused"}` instead, and this line says why.
432    /// Stop what the view no longer needs rather than releasing it, or
433    /// release shorter sounds.
434    pub const PLAYBACK_REFUSED: &str = "playback-refused";
435    /// A devtools tab name declared twice in one frame (ADR 0032,
436    /// decision 1): two `devtools_tab` / `devtools_tab_with` calls, a host
437    /// form and an extension form of one name, or an extension declaring
438    /// from its fill under a name the host took. The first declaration
439    /// stands and the second is ignored; give the second tab its own name.
440    pub const DUPLICATE_TAB: &str = "duplicate-tab";
441    /// A `devtoolsTab` declaration a binding could not read as either
442    /// form (ADR 0032, decision 1): a child that is not a function, both a
443    /// `slot` and a child, or a `view` that is not a function in Lua. The
444    /// tab was not declared. A tab names a slot for an extension to fill,
445    /// or carries a function the binding calls only when the tab is shown.
446    pub const BAD_DEVTOOLS_TAB: &str = "bad-devtools-tab";
447    /// A select's `current` names no option the field can show: an index
448    /// past its options, or a separator's. The field is drawn as if none
449    /// were in force — an empty description, no row checked — rather than
450    /// blank with a check on a divider; the options are drawn as declared.
451    /// A `current` the view computes from a list it also filters is how
452    /// this happens; the index is into the options as passed, separators
453    /// counted (backlog RG10). Raised once per field.
454    pub const SELECT_CURRENT_IGNORED: &str = "select-current-ignored";
455}
456
457/// The [`DUPLICATE_TAB`] warning for one name. Keyed by the name, the way
458/// `duplicate_slot` is: a tab is not a node.
459pub fn duplicate_tab(name: &str) -> Warning {
460    Warning {
461        code: DUPLICATE_TAB,
462        key: Key::ROOT.str(DUPLICATE_TAB).str(name),
463        message: format!(
464            "devtools tab {name:?} was declared twice in one frame; the second was ignored — \
465             give it its own name"
466        ),
467    }
468}
469
470/// The [`BAD_DEVTOOLS_TAB`] warning for one declaration, with what was
471/// wrong with it.
472pub fn bad_devtools_tab(name: &str, why: &str) -> Warning {
473    Warning {
474        code: BAD_DEVTOOLS_TAB,
475        key: Key::ROOT.str(BAD_DEVTOOLS_TAB).str(name),
476        message: format!("devtools tab {name:?} was not declared: {why}"),
477    }
478}
479
480/// The [`TEXT_BEYOND_LINE`] warning for one text node under `line`
481/// (backlog AR30). Keyed by the text's node.
482pub fn text_beyond_line(text: Key, line: Key, reach: usize) -> Warning {
483    Warning {
484        code: TEXT_BEYOND_LINE,
485        key: text,
486        message: format!(
487            "the text ({:016x}) is more than {reach} levels below its `line` row ({:016x}), further than a              place remembers, so a hit asked by the row's key answers byte 0; flatten the wrappers              between them, or ask by a nearer key",
488            text.0, line.0
489        ),
490    }
491}
492
493/// The [`UNKNOWN_SLOT`] warning for one extension and slot. Keyed by the
494/// full name: there is no node, and one line per slot is the useful
495/// count however many frames repeat it.
496pub fn unknown_slot(extension: &str, namespace: &str, slot: &str) -> Warning {
497    let full = crate::slot::full_name(namespace, slot);
498    Warning {
499        code: UNKNOWN_SLOT,
500        key: Key::ROOT.str(UNKNOWN_SLOT).str(&full),
501        message: format!(
502            "extension `{extension}` (namespace `{namespace}`) fills slot {slot:?}, which no view \
503             declared this frame, so it drew nothing; declare `ui.slot({full:?})` where it \
504             should go, or drop the name from the extension's `slots`"
505        ),
506    }
507}
508
509/// The [`DUPLICATE_SLOT`] warning for one name. Keyed by the name: a slot
510/// is not a node, and the conflict is between two declarations of it.
511pub fn duplicate_slot(slot: &str) -> Warning {
512    Warning {
513        code: DUPLICATE_SLOT,
514        key: Key::ROOT.str(DUPLICATE_SLOT).str(slot),
515        message: format!(
516            "slot {slot:?} was declared twice in one frame; the second was ignored, since a \
517             fill is keyed by the slot's name — two places want two names"
518        ),
519    }
520}
521
522/// The [`UNBALANCED_EXTENSION`] warning for one fill. Keyed by the slot,
523/// which names the extension through its namespace, so a plugin that
524/// never closes costs one line.
525pub fn unbalanced_extension(slot: &str, slot_key: Key, open: usize) -> Warning {
526    Warning {
527        code: UNBALANCED_EXTENSION,
528        key: slot_key.str(UNBALANCED_EXTENSION),
529        message: format!(
530            "the extension filling slot {slot:?} returned from view with {open} node{} still \
531             open; they were closed for it — it has an `open` without its `close`",
532            if open == 1 { "" } else { "s" }
533        ),
534    }
535}
536
537/// The [`EXTENSION_VIEW_ERROR`] warning for one extension in one slot.
538/// Keyed by both, so an error that repeats every frame costs one line.
539pub fn extension_view_error(extension: &str, slot: &str, slot_key: Key, err: &str) -> Warning {
540    Warning {
541        code: EXTENSION_VIEW_ERROR,
542        key: slot_key.str(extension).str(EXTENSION_VIEW_ERROR),
543        message: format!("extension `{extension}` failed to build slot {slot:?}: {err}"),
544    }
545}
546
547/// The [`RECURSIVE_SLOT`] warning for one fill. Keyed by the slot, the
548/// same way `unbalanced-extension` is: the conflict is a name, and one
549/// line per name is the useful count however many frames repeat it.
550pub fn recursive_slot(extension: &str, slot: &str, slot_key: Key) -> Warning {
551    Warning {
552        code: RECURSIVE_SLOT,
553        key: slot_key.str(RECURSIVE_SLOT),
554        message: format!(
555            "extension `{extension}` declared slot {slot:?} while it was drawing, which is its \
556             own; it was left empty, since filling it would mean calling `{extension}` inside \
557             itself. An extension may declare the slots of extensions it loaded — not its own"
558        ),
559    }
560}
561
562/// The [`FOREIGN_RESOURCE`] warning for one handle. Keyed by kind and
563/// handle: there is no node — an image node, a text style and a `play`
564/// call can all carry the same handle — and one line per handle is the
565/// useful count however many frames repeat it.
566pub fn foreign_resource(f: &Foreign) -> Warning {
567    Warning {
568        code: FOREIGN_RESOURCE,
569        key: Key::ROOT
570            .str(FOREIGN_RESOURCE)
571            .str(f.kind.name())
572            .index(f.raw),
573        message: f.message(),
574    }
575}
576
577/// The [`SIZE_EXPRESSIONS_FULL`] warning, if the table has refused an
578/// expression: one key for the process's one table, so a core says it
579/// once.
580pub fn size_expressions_full() -> Option<Warning> {
581    let (n, last) = crate::calc::refused();
582    (n > 0).then(|| Warning {
583        code: SIZE_EXPRESSIONS_FULL,
584        key: Key::ROOT.str(SIZE_EXPRESSIONS_FULL),
585        message: format!(
586            "{} size expressions are kept, and {n} more were refused, the last \"{last}\": each \
587             is laid out as if undeclared — declare one per layout, not one per frame",
588            crate::calc::MAX_CALCS
589        ),
590    })
591}
592
593/// The [`EDIT_TEXT_WITHOUT_EDITOR`] warning for one label. Keyed by
594/// `Key::ROOT.str(label)` — not a node's key, since no node took the
595/// name, but one key per label all the same, so two unclaimed labels in
596/// a frame are two lines under the once-per-(code, key) dedup.
597pub fn edit_text_without_editor_label(label: &str) -> Warning {
598    Warning {
599        code: EDIT_TEXT_WITHOUT_EDITOR,
600        key: Key::ROOT.str(label),
601        message: format!(
602            "`set_edit_text` named label {label:?}, and the frame after it declared no editor \
603             under that name, so the text was dropped; the label is the one an editor's `key` \
604             prop declares, and the call is held for one frame — for the view that draws the \
605             editor the same `update` opened — not longer"
606        ),
607    }
608}
609
610/// The [`PLAYBACK_REFUSED`] warning for one playback. Keyed by the node
611/// that asked for the sound — the tagged node, the `audio` element, or
612/// the origin's root for an imperative `play` — so a view that keeps
613/// asking past the device's limit costs one line rather than one per
614/// refusal, which is the whole point of the dedup.
615pub fn playback_refused(key: crate::key::Key, playback: crate::audio::PlaybackId) -> Warning {
616    Warning {
617        code: PLAYBACK_REFUSED,
618        key,
619        message: format!(
620            "the audio device refused playback {} — its voices are all held, or the sound did \
621             not decode; a released (`finish`) playback holds one of the device's 128 voices \
622             until its file ends, so releasing faster than the sounds finish reaches the limit. \
623             The playback never started and will never report `ended`; a tagged one is told so \
624             with `phase: \"refused\"`",
625            playback.0
626        ),
627    }
628}
629
630/// The [`EDIT_TEXT_WITHOUT_EDITOR`] warning for one key. Keyed by the
631/// editor's own key, so a view that never declares it reports once, the
632/// way every node-shaped code does.
633/// [`MODAL_BEHIND_CONTENT`], the float-stack case: raised from emission,
634/// where the stack exists, rather than from the tree walk.
635pub(crate) fn modal_under_layer(key: Key) -> Warning {
636    Warning {
637        code: MODAL_BEHIND_CONTENT,
638        key,
639        message: "a float from outside this modal's scope opened after it and paints on top \
640                  of it: everything drawn over a modal is inert, which reads as a broken \
641                  dialog (declare it inside the modal, or close it while the modal is up)"
642            .to_string(),
643    }
644}
645
646pub fn edit_text_without_editor(key: Key) -> Warning {
647    Warning {
648        code: EDIT_TEXT_WITHOUT_EDITOR,
649        key,
650        message: format!(
651            "`set_edit_text` named key {:#x}, and the frame after it declared no editor under \
652             that key, so the text was dropped; the call is held for one frame — for the view \
653             that draws the editor the same `update` opened — not longer",
654            key.0
655        ),
656    }
657}
658
659/// The [`TRUNCATED_PLAYBACK`] warning for one cut-off playback. Keyed by
660/// the `audio` node, so a view that truncates the same chime every time it
661/// runs costs one line; `at` is where the playback was, in seconds, which
662/// is the half of "how much was lost" a device can actually report.
663pub fn truncated_playback(key: Key, why: crate::audio::Why, at: f64) -> Warning {
664    Warning {
665        code: TRUNCATED_PLAYBACK,
666        key,
667        message: format!(
668            "the `audio` node at key {:#x} was {} {:.2}s into its sound, cutting it off; keep \
669             the node declared until its `ended` event, or add `finish` so the playback is \
670             released to play itself out",
671            key.0,
672            why.verb(),
673            at
674        ),
675    }
676}
677
678/// The [`ANNOUNCEMENT_REPEATED`] warning for one text. Keyed by the text:
679/// there is no node behind an announcement, and one line per repeated
680/// message is the useful count however many frames repeat it.
681pub fn announcement_repeated(text: &str) -> Warning {
682    Warning {
683        code: ANNOUNCEMENT_REPEATED,
684        key: Key::ROOT.str(ANNOUNCEMENT_REPEATED).str(text),
685        message: format!(
686            "the announcement {text:?} was queued on two consecutive frames: `announce` says \
687             something once, and a view runs every frame, so a call made from a frame builder \
688             needs a guard the app clears (announce from the event handler, or keep a field the \
689             handler sets and the view clears)"
690        ),
691    }
692}
693
694/// The [`DUPLICATE_WINDOW_CONFIG`] warning for one window name. Keyed by
695/// the name: there is no node, and the conflict is between declarations,
696/// however many frames repeat it.
697pub fn duplicate_window_config(name: &str) -> Warning {
698    Warning {
699        code: DUPLICATE_WINDOW_CONFIG,
700        key: Key::ROOT.str(DUPLICATE_WINDOW_CONFIG).str(name),
701        message: format!(
702            "window `{name}` was declared with two different configs on the frame it opened; \
703             the lowest declaring window's first declaration won, and a live window's config is \
704             never re-read, so the other one never applies — make them agree"
705        ),
706    }
707}
708
709/// The [`WINDOW_DECLARED_WHILE_CLOSED`] warning for one window name. Keyed
710/// by the name, and raised on the core whose frame declared it, so an app
711/// that keeps asking every frame reads one line.
712pub fn window_declared_while_closed(name: &str) -> Warning {
713    Warning {
714        code: WINDOW_DECLARED_WHILE_CLOSED,
715        key: Key::ROOT.str(WINDOW_DECLARED_WHILE_CLOSED).str(name),
716        message: format!(
717            "window `{name}` is still declared after the user closed it, so it stays closed: a \
718             declaration reopens a window only when it starts — handle the \
719             `{{kind:\"window\", phase:\"closed\"}}` event, stop declaring `{name}`, and declare \
720             it again to reopen"
721        ),
722    }
723}
724
725/// The [`UNKNOWN_WINDOW_KIND`] warning for one declaration. Keyed by the
726/// window name and the kind, the way the other two window codes are keyed by
727/// a name and not a node: a declaration is not a node, and one line per
728/// (window, kind) is the useful count however many frames repeat it.
729pub fn unknown_window_kind(name: &str, kind: u32) -> Warning {
730    Warning {
731        code: UNKNOWN_WINDOW_KIND,
732        key: Key::ROOT
733            .str(UNKNOWN_WINDOW_KIND)
734            .str(name)
735            .index(kind as u64),
736        message: format!(
737            "window `{name}` was declared with kind {kind}, which this build does not have; \
738             `KUI_WINDOW_KIND_NORMAL` (0) and `KUI_WINDOW_KIND_POPUP` (1) are the ones there \
739             are, so it opened as a normal window"
740        ),
741    }
742}
743
744/// The [`FOCUS_REGION_WITHOUT_NODE`] warning for a `focus_region` the frame
745/// could not resolve. Keyed by the key or the label's hash, so a call
746/// repeated every frame costs one line.
747pub(crate) fn focus_region_without_node(target: &crate::runtime::RegionTarget) -> Warning {
748    use crate::runtime::RegionTarget;
749    let (key, named) = match target {
750        RegionTarget::Main => (Key::ROOT, "the main ring".to_string()),
751        RegionTarget::Key(k) => (*k, format!("key {:016x}", k.0)),
752        RegionTarget::Label(label) => (Key::ROOT.str(label), format!("label {label:?}")),
753    };
754    Warning {
755        code: FOCUS_REGION_WITHOUT_NODE,
756        key,
757        message: format!(
758            "`focus_region` named {named}, and the frame after it declared no `focusRegion` node \
759             there, so nothing was entered; the name is the label the region's `key` prop \
760             declares, on a node that carries the `focusRegion` row"
761        ),
762    }
763}
764
765/// The [`LABEL_WITHOUT_NODE`] warning for a deferred `verb` by `label`.
766/// Keyed by the verb and the label, so a call repeated every frame costs
767/// one line, and a `reveal` and a `set_scroll` of one typo are two.
768pub(crate) fn label_without_node(verb: &str, label: &str) -> Warning {
769    Warning {
770        code: LABEL_WITHOUT_NODE,
771        key: Key::ROOT.str(verb).str(label),
772        message: format!(
773            "`{verb}` named the label {label:?}, and the frame it resolved against declared no              node under it, so nothing moved; the name is the label a node's `key` declares"
774        ),
775    }
776}
777
778/// The [`AMBIGUOUS_KEY`] warning for one label `Core::key_of` found `count`
779/// nodes under. Keyed by the node the name resolved to, so a label asked
780/// for every frame costs one line.
781pub fn ambiguous_key(label: &str, first: Key, count: usize) -> Warning {
782    Warning {
783        code: AMBIGUOUS_KEY,
784        key: first,
785        message: format!(
786            "{count} nodes are keyed {label:?} under different parents; the first in tree order \
787             ({:016x}) was used — give the one meant a label nothing else declares, or pass its \
788             hex key",
789            first.0
790        ),
791    }
792}
793
794/// The [`SELECT_CURRENT_IGNORED`] warning for one field: `current` was
795/// `index` over `count` options and `separator` says whether it landed on
796/// one rather than past the end. Keyed by the field, so a view that draws
797/// it that way every frame costs one line.
798pub fn select_current_ignored(
799    key: Key,
800    label: &str,
801    index: usize,
802    count: usize,
803    separator: bool,
804) -> Warning {
805    let why = if separator {
806        "which is a separator".to_string()
807    } else {
808        format!(
809            "and the field has {count} option{}",
810            if count == 1 { "" } else { "s" }
811        )
812    };
813    Warning {
814        code: SELECT_CURRENT_IGNORED,
815        key,
816        message: format!(
817            "`current` of select {label:?} names option {index} counted from 0, {why}, so the \
818             field shows no choice and no row is checked — pass an index of an option, or none"
819        ),
820    }
821}
822
823/// The [`UNKNOWN_PROP`] warning for a key a menu row map carried that
824/// [`crate::MenuItem::from_value`] does not read — `disabled` for
825/// `enabled: false` — so the row was built without it (backlog RG10).
826/// Keyed by the name, as [`unknown_prop`]'s are: one line per spelling.
827pub fn unknown_menu_item_key(name: &str) -> Warning {
828    let keys = crate::MenuItem::KEYS
829        .iter()
830        .map(|k| format!("`{k}`"))
831        .collect::<Vec<_>>()
832        .join(", ");
833    // The one misspelling with a meaning of its own gets the value it was
834    // after; the rest the nearest key by letters, as `schema::suggest` does.
835    let squash = |s: &str| s.replace('_', "").to_ascii_lowercase();
836    let hint = if name == "disabled" {
837        " (did you mean `enabled: false`?)".to_string()
838    } else {
839        match crate::MenuItem::KEYS
840            .iter()
841            .find(|k| squash(k) == squash(name))
842        {
843            Some(near) => format!(" (did you mean `{near}`?)"),
844            None => String::new(),
845        }
846    };
847    Warning {
848        code: UNKNOWN_PROP,
849        key: Key::ROOT
850            .str(UNKNOWN_PROP)
851            .str(crate::MenuItem::NAME)
852            .str(name),
853        message: format!(
854            "`{name}` is not a key of a menu item: a row takes {keys}, so this declaration is \
855             dropped{hint}"
856        ),
857    }
858}
859
860/// The [`UNKNOWN_PROP`] warning for one dropped name, with the nearest
861/// legitimate spelling when there is an obvious one. The key is derived from
862/// the element and the name rather than from a node, so a misspelling costs
863/// one line however many nodes carry it and however many frames draw them.
864/// `element` [`crate::MenuItem::NAME`] is a menu row's key rather than a
865/// node's prop, and takes [`unknown_menu_item_key`]'s wording.
866pub fn unknown_prop(element: &str, name: &str, spelling: schema::Spelling) -> Warning {
867    if element == crate::MenuItem::NAME {
868        return unknown_menu_item_key(name);
869    }
870    // A real row on an element that reads only some of them is not a
871    // misspelling, and the nearest spelling would be the row itself; the
872    // warning says which rows the element does read instead.
873    let message = match schema::element_rows(element, spelling) {
874        Some(rows) if schema::shared_prop(name, spelling) => {
875            let takes = if rows.is_empty() {
876                "none of them".to_string()
877            } else {
878                format!(
879                    "only {}",
880                    rows.iter()
881                        .map(|r| format!("`{r}`"))
882                        .collect::<Vec<_>>()
883                        .join(", ")
884                )
885            };
886            format!(
887                "`{name}` is a prop, but not one {element} reads: its look is its own, and it \
888                 takes {takes}, so this declaration is dropped — a box with `role` set takes \
889                 every row"
890            )
891        }
892        _ => {
893            let hint = match schema::suggest(element, name, spelling) {
894                Some(near) => format!(" (did you mean `{near}`?)"),
895                None => String::new(),
896            };
897            format!(
898                "`{name}` is not a prop of {element}: no binding reads it, so this declaration \
899                 is dropped{hint}"
900            )
901        }
902    };
903    Warning {
904        code: UNKNOWN_PROP,
905        key: Key::ROOT.str(UNKNOWN_PROP).str(element).str(name),
906        message,
907    }
908}
909
910/// Pending warnings are capped so a host that never drains them cannot
911/// grow the queue without bound.
912const MAX_PENDING: usize = 256;
913/// The checks run on the first two frames and every this many after.
914pub(crate) const CHECK_EVERY: u64 = 16;
915
916pub(crate) struct Diagnostics {
917    pub(crate) enabled: bool,
918    pending: Vec<Warning>,
919    /// Everything that ever reached `pending`, kept after the drain: the
920    /// warnings of a core's whole life, for a reader that is not the
921    /// driver (a dev overlay showing what the runner printed). Bounded
922    /// the way `warned` is, since each (code, key) lands here once.
923    log: Vec<Warning>,
924    warned: FxHashSet<(&'static str, Key)>,
925    /// Child count at the last check, for parents with an auto-keyed child
926    /// that carries a transition.
927    child_counts: FxHashMap<Key, u32>,
928    scratch: Vec<u64>,
929}
930
931impl Default for Diagnostics {
932    fn default() -> Self {
933        Self {
934            enabled: true,
935            pending: Vec::new(),
936            log: Vec::new(),
937            warned: FxHashSet::default(),
938            child_counts: FxHashMap::default(),
939            scratch: Vec::new(),
940        }
941    }
942}
943
944impl Diagnostics {
945    pub(crate) fn take(&mut self) -> Vec<Warning> {
946        std::mem::take(&mut self.pending)
947    }
948
949    /// Every warning raised so far, drained or not, oldest first.
950    pub(crate) fn raised(&self) -> &[Warning] {
951        &self.log
952    }
953
954    /// A warning built elsewhere — by a binding, for what it saw before the
955    /// tree existed. Same gate and same once-per-(code, key) dedup as the
956    /// checks below, so a frontend can raise one per node per frame and the
957    /// host still reads one line.
958    pub(crate) fn raise(&mut self, w: Warning) {
959        if !self.enabled
960            || self.pending.len() >= MAX_PENDING
961            || !self.warned.insert((w.code, w.key))
962        {
963            return;
964        }
965        self.log.push(w.clone());
966        self.pending.push(w);
967    }
968
969    fn warn(&mut self, code: &'static str, key: Key, message: impl FnOnce() -> String) {
970        if self.pending.len() >= MAX_PENDING || !self.warned.insert((code, key)) {
971            return;
972        }
973        let w = Warning {
974            code,
975            key,
976            message: message(),
977        };
978        self.log.push(w.clone());
979        self.pending.push(w);
980    }
981
982    /// Runs every check over the finished frame's tree, on the frames the
983    /// cadence picks (see the module docs).
984    pub(crate) fn check(
985        &mut self,
986        tree: &Tree,
987        text: &TextSystem,
988        edit: &EditStore,
989        frame_no: u64,
990    ) {
991        if !self.enabled || tree.is_empty() {
992            return;
993        }
994        if frame_no > 2 && !frame_no.is_multiple_of(CHECK_EVERY) {
995            return;
996        }
997        self.check_grow_weights(tree);
998        self.check_wrap(tree);
999        self.check_align(tree);
1000        self.check_auto_keyed_transitions(tree);
1001        self.check_duplicate_keys(tree);
1002        self.check_modal(tree);
1003        self.check_selection_scopes(tree);
1004        self.check_composites(tree);
1005        self.check_lone_items(tree);
1006        self.check_access(tree, text, edit);
1007        self.check_live_regions(tree, text);
1008    }
1009
1010    /// A modal painted under content it makes inert. Paint order is
1011    /// preorder with floating subtrees last, so anything after the modal's
1012    /// subtree — or any float outside it — draws over it; a modal that is
1013    /// itself inside a float is already on top of both.
1014    /// A selection scope inside another one (ADR 0017). Cheap to skip:
1015    /// the tree says whether any node declared one at all.
1016    fn check_selection_scopes(&mut self, tree: &Tree) {
1017        if !tree.any_selectable {
1018            return;
1019        }
1020        // Parents precede children, so one forward pass carries the
1021        // nearest enclosing scope down without a stack.
1022        self.scratch.clear();
1023        self.scratch.resize(tree.len(), 0);
1024        for i in 0..tree.len() {
1025            let inside = match tree.parent[i] {
1026                NIL => 0,
1027                p => self.scratch[p as usize],
1028            };
1029            let here = tree.specs[i].interact().selectable;
1030            if here && inside == 1 {
1031                self.warn(NESTED_SELECTION_SCOPE, tree.keys[i], || {
1032                    "a `selectable` node inside another one: selection scopes do not nest, so the inner one owns the text under it and the outer selects only what is outside it".to_string()
1033                });
1034            }
1035            self.scratch[i] = u64::from(here || inside == 1);
1036        }
1037    }
1038
1039    fn check_modal(&mut self, tree: &Tree) {
1040        let Some(i) = (0..tree.len())
1041            .rev()
1042            .find(|&i| tree.specs[i].events().modal.is_some())
1043        else {
1044            return;
1045        };
1046        let end = tree.subtree_end(i);
1047        let floating = |mut j: usize| {
1048            loop {
1049                if tree.specs[j].layout.float.is_some() {
1050                    return true;
1051                }
1052                match tree.parent[j] {
1053                    NIL => return false,
1054                    p => j = p as usize,
1055                }
1056            }
1057        };
1058        if floating(i) {
1059            return;
1060        }
1061        let over = (0..tree.len())
1062            .filter(|j| !(i..end).contains(j))
1063            .any(|j| j >= end || floating(j));
1064        if !over {
1065            return;
1066        }
1067        self.warn(MODAL_BEHIND_CONTENT, tree.keys[i], || {
1068            "this modal is not in a float, and content declared after it paints on top of it: \
1069             everything drawn over a modal is inert, which reads as a broken dialog (float it \
1070             with `float=\"viewport\"`)"
1071                .to_string()
1072        });
1073    }
1074
1075    /// Focusable nodes buried inside a composite's items, which nothing
1076    /// can reach (see [`FOCUSABLE_INSIDE_ITEM`]). One walk per composite,
1077    /// on the same cadence as every other check here.
1078    fn check_composites(&mut self, tree: &Tree) {
1079        let mut items: Vec<usize> = Vec::new();
1080        for c in 0..tree.len() {
1081            let Some(item) = tree.specs[c]
1082                .access()
1083                .role
1084                .and_then(crate::composite::item_role)
1085            else {
1086                continue;
1087            };
1088            crate::composite::items(tree, c, item, &mut items);
1089            if !crate::composite::is_composite(tree, c, &items) {
1090                continue;
1091            }
1092            for &i in &items {
1093                let end = tree.subtree_end(i);
1094                for j in i + 1..end {
1095                    if !access::focusable(tree, j) {
1096                        continue;
1097                    }
1098                    let what = item.name();
1099                    self.warn(FOCUSABLE_INSIDE_ITEM, tree.keys[j], || {
1100                        format!(
1101                            "this node is focusable but sits inside a `{what}`, which is one \
1102                             roving Tab stop of a composite: the ring stops at the item, so \
1103                             nothing reaches this node (move it outside the item, or drop its \
1104                             focusable behaviour)"
1105                        )
1106                    });
1107                }
1108            }
1109        }
1110    }
1111
1112    /// A `radio` or `tab` with no container of its pair above it (see
1113    /// [`ITEM_OUTSIDE_CONTAINER`]). The pairs are `composite::PAIRS`, less
1114    /// the menu's and the list's. Parents precede children, so one forward
1115    /// pass carries down a bit per pair for the containers above each node,
1116    /// as the selection-scope check carries its one.
1117    fn check_lone_items(&mut self, tree: &Tree) {
1118        use crate::composite::PAIRS;
1119        const CHECKED: [Role; 2] = [Role::Radio, Role::Tab];
1120        self.scratch.clear();
1121        self.scratch.resize(tree.len(), 0);
1122        for i in 0..tree.len() {
1123            let above = match tree.parent[i] {
1124                NIL => 0,
1125                p => self.scratch[p as usize],
1126            };
1127            let mut here = above;
1128            if let Some(role) = tree.specs[i].access().role {
1129                for (bit, (container, item)) in PAIRS.iter().enumerate() {
1130                    if role == *container {
1131                        here |= 1 << bit;
1132                    } else if role == *item && CHECKED.contains(item) && above & (1 << bit) == 0 {
1133                        let (item, container) = (item.name(), container.name());
1134                        self.warn(ITEM_OUTSIDE_CONTAINER, tree.keys[i], || {
1135                            format!(
1136                                "this `{item}` has no `{container}` above it, so it is a Tab \
1137                                 stop of its own: the arrows do not move the choice and a \
1138                                 screen reader announces no position in the set (wrap the \
1139                                 set in a `{container}` with a `label`)"
1140                            )
1141                        });
1142                    }
1143                }
1144            }
1145            self.scratch[i] = here;
1146        }
1147    }
1148
1149    /// Images without a label, controls without a computable name and
1150    /// modals without one, by the same derivation the access tree uses
1151    /// (see `access::semantic`).
1152    fn check_access(&mut self, tree: &Tree, text: &TextSystem, edit: &EditStore) {
1153        let mut skip_until = 0usize;
1154        for i in 0..tree.len() {
1155            if i < skip_until {
1156                continue;
1157            }
1158            let Some(sem) = access::semantic(tree, text, edit, None, i) else {
1159                continue;
1160            };
1161            if sem.role == Role::None || sem.presentational {
1162                skip_until = tree.subtree_end(i);
1163            }
1164            if sem.role == Role::Slider {
1165                self.check_slider_range(tree, i);
1166            }
1167            if sem.name.is_some() || sem.role == Role::None {
1168                continue;
1169            }
1170            let key = tree.keys[i];
1171            if sem.role == Role::Image {
1172                self.warn(IMAGE_WITHOUT_LABEL, key, || {
1173                    "this image has no label: assistive technology has nothing to say for it \
1174                     (give it a `label`, or `role=\"none\"` if it is decoration)"
1175                        .to_string()
1176                });
1177            } else if sem.role == Role::Dialog && tree.specs[i].events().modal.is_some() {
1178                self.warn(MODAL_WITHOUT_NAME, key, || {
1179                    "this modal has no accessible name: a dialog is named by its `label`, never \
1180                     by the text inside it — a screen reader announces an unnamed dialog to the \
1181                     user it has just moved focus to (give it a `label`)"
1182                        .to_string()
1183                });
1184            } else if sem.role.is_control() {
1185                let what = sem.role.name();
1186                self.warn(CONTROL_WITHOUT_NAME, key, || {
1187                    format!(
1188                        "this {what} has no accessible name: no `label`, and no text inside it \
1189                         — a screen reader announces an unnamed {what} (give it a `label`)"
1190                    )
1191                });
1192            }
1193        }
1194    }
1195
1196    /// A slider's declared value against its declared range (see
1197    /// [`SLIDER_VALUE_OUT_OF_RANGE`]). Only the rows it declares are
1198    /// compared: a slider with no `valueMin` has no floor to fall under.
1199    fn check_slider_range(&mut self, tree: &Tree, i: usize) {
1200        let ax = tree.specs[i].access();
1201        let (now, min, max) = (ax.value_now, ax.value_min, ax.value_max);
1202        let reason = match (now, min, max) {
1203            (_, Some(lo), Some(hi)) if lo > hi => {
1204                format!("valueMin {lo} is above valueMax {hi}, so no value is in range")
1205            }
1206            (Some(v), Some(lo), _) if v < lo => {
1207                format!("valueNow {v} is below valueMin {lo}")
1208            }
1209            (Some(v), _, Some(hi)) if v > hi => {
1210                format!("valueNow {v} is above valueMax {hi}")
1211            }
1212            _ => return,
1213        };
1214        self.warn(SLIDER_VALUE_OUT_OF_RANGE, tree.keys[i], || {
1215            format!(
1216                "this slider's value and its declared range disagree: {reason} — the rows are \
1217                 read to a screen reader exactly as declared, so a value the app clamps \
1218                 somewhere else is announced unclamped (declare the range the value is really \
1219                 held to, or clamp where the view declares it)"
1220            )
1221        });
1222    }
1223
1224    /// Live regions that can never say anything: `live` declared with no
1225    /// `label` and no text inside. Its own walk rather than a branch in
1226    /// [`Self::check_access`], because that one skips the subtree of a
1227    /// presentational role and a live region can sit inside one.
1228    fn check_live_regions(&mut self, tree: &Tree, text: &TextSystem) {
1229        for i in 0..tree.len() {
1230            let spec = &tree.specs[i];
1231            if spec.access().live == access::Live::Off
1232                || spec.access().role == Some(Role::None)
1233                || access::live_region_speaks(tree, text, i)
1234            {
1235                continue;
1236            }
1237            self.warn(LIVE_REGION_WITHOUT_NAME, tree.keys[i], || {
1238                "this node is a live region but has no accessible name: no `label`, and no text \
1239                 inside it — every platform reads a live region by its name, so nothing this \
1240                 node ever does can be announced (put `live` on the node that holds the message)"
1241                    .to_string()
1242            });
1243        }
1244    }
1245
1246    fn check_grow_weights(&mut self, tree: &Tree) {
1247        for p in 0..tree.len() {
1248            if tree.first_child[p] == NIL {
1249                continue;
1250            }
1251            let row = tree.specs[p].layout.dir == Dir::Row;
1252            let mut grow_children = 0u32;
1253            let mut lone: Option<(u32, f32)> = None;
1254            for c in tree.children(p as u32) {
1255                let layout = tree.specs[c as usize].layout;
1256                if layout.float.is_some() {
1257                    continue;
1258                }
1259                let (main, cross) = if row {
1260                    (layout.width, layout.height)
1261                } else {
1262                    (layout.height, layout.width)
1263                };
1264                if let Sizing::Grow(f) = main {
1265                    grow_children += 1;
1266                    lone = Some((c, f));
1267                }
1268                if let Sizing::Grow(f) = cross
1269                    && f != 1.0
1270                {
1271                    let axis = if row { "height" } else { "width" };
1272                    let parent_axis = if row { "row" } else { "column" };
1273                    self.warn(GROW_WEIGHT_IGNORED, tree.keys[c as usize], || {
1274                        format!(
1275                            "{axis} grow {f} has no effect: across a {parent_axis}'s main axis a \
1276                             grow child fills the parent whatever its weight (use maxWidth / \
1277                             maxHeight to cap it)"
1278                        )
1279                    });
1280                }
1281            }
1282            if grow_children == 1
1283                && let Some((c, f)) = lone
1284                && f != 1.0
1285            {
1286                let axis = if row { "width" } else { "height" };
1287                self.warn(GROW_WEIGHT_IGNORED, tree.keys[c as usize], || {
1288                    format!(
1289                        "{axis} grow {f} has no effect: it is the only grow child of its parent, \
1290                         and a weight only splits free space between grow siblings — alone it \
1291                         takes all of it (cap it with max{}, or give a sibling a grow too)",
1292                        if row { "Width" } else { "Height" }
1293                    )
1294                });
1295            }
1296        }
1297    }
1298
1299    /// Alignments and ratios with nothing to act on: see
1300    /// [`ALIGN_IGNORED`] and [`ASPECT_IGNORED`].
1301    fn check_align(&mut self, tree: &Tree) {
1302        use crate::spec::Align;
1303        let spread = |a: Align| {
1304            matches!(
1305                a,
1306                Align::SpaceBetween | Align::SpaceAround | Align::SpaceEvenly
1307            )
1308        };
1309        let odd = |a: Align| spread(a) || a == Align::Baseline;
1310        for i in 0..tree.len() {
1311            let l = &tree.specs[i].layout;
1312            let reason = if l.main_align == Align::Baseline {
1313                Some("mainAlign baseline: a baseline lines children up across a row, not along it (use crossAlign)".to_string())
1314            } else if spread(l.cross_align) {
1315                Some(format!(
1316                    "crossAlign {}: a spread deals free space out between children, and there is one child per line across the axis (use mainAlign)",
1317                    l.cross_align.name()
1318                ))
1319            } else if l.cross_align == Align::Baseline && l.dir == Dir::Column {
1320                Some("crossAlign baseline on a column: a column's cross axis is horizontal, where a baseline is not a line (lay the text out in a row)".to_string())
1321            } else {
1322                l.float.and_then(|f| {
1323                    let pts = [
1324                        f.anchor_point.0,
1325                        f.anchor_point.1,
1326                        f.self_point.0,
1327                        f.self_point.1,
1328                    ];
1329                    pts.into_iter().find(|&a| odd(a)).map(|a| {
1330                        format!(
1331                            "a float attaches at start, center or end, not {} (it lays out as {})",
1332                            a.name(),
1333                            if matches!(a, Align::SpaceAround | Align::SpaceEvenly) {
1334                                "center"
1335                            } else {
1336                                "start"
1337                            }
1338                        )
1339                    })
1340                })
1341            };
1342            if let Some(reason) = reason {
1343                self.warn(ALIGN_IGNORED, tree.keys[i], || {
1344                    format!("{reason}; it has no effect here")
1345                });
1346            }
1347            if l.aspect > 0.0 && !l.aspect_height() && l.aspect_width().is_none() {
1348                let why = if l.width == Sizing::Fit {
1349                    "the width is fit, and a grow or percent height is resolved only after every width is (give the height a fixed size, or let the height be the fit axis)"
1350                } else {
1351                    "both axes are declared, so there is no fit axis for the ratio to size (leave one of them fit)"
1352                };
1353                self.warn(ASPECT_IGNORED, tree.keys[i], || {
1354                    format!("aspectRatio {} has no effect: {why}", l.aspect)
1355                });
1356            }
1357        }
1358    }
1359
1360    /// `wrapChildren` where nothing can break: see [`WRAP_IGNORED`].
1361    fn check_wrap(&mut self, tree: &Tree) {
1362        for i in 0..tree.len() {
1363            let layout = tree.specs[i].layout;
1364            if !layout.wrap {
1365                continue;
1366            }
1367            let reason = if layout.dir != Dir::Row {
1368                "a column's main size is not resolved until after the pass that would have to \
1369                 sum the lines, so only a row wraps (turn the container into a row, or give the \
1370                 items a fixed size and lay them out yourself)"
1371            } else if layout.scroll_x {
1372                "a scrollX row's main axis is unbounded, and an axis with no bound has nothing \
1373                 to break against (drop scrollX, or drop wrapChildren and let it scroll)"
1374            } else if layout.float.is_none()
1375                && tree.parent[i] != NIL
1376                && tree.specs[tree.parent[i] as usize].layout.is_table()
1377            {
1378                "a table's row cannot wrap: its children are the table's columns, one each \
1379                 (put the wrapping row inside a cell)"
1380            } else {
1381                continue;
1382            };
1383            self.warn(WRAP_IGNORED, tree.keys[i], || {
1384                format!("wrapChildren has no effect here: {reason}")
1385            });
1386        }
1387    }
1388
1389    fn check_auto_keyed_transitions(&mut self, tree: &Tree) {
1390        let mut counts: FxHashMap<Key, u32> = FxHashMap::default();
1391        for p in 0..tree.len() {
1392            if tree.first_child[p] == NIL {
1393                continue;
1394            }
1395            let parent_key = tree.keys[p];
1396            let mut n = 0u32;
1397            let mut auto_keyed_transition = false;
1398            for (i, c) in tree.children(p as u32).enumerate() {
1399                n += 1;
1400                // An auto key is the parent's key mixed with the sibling
1401                // index; a labeled key never collides with one.
1402                if tree.specs[c as usize].transition.is_some()
1403                    && tree.keys[c as usize] == parent_key.index(i as u64)
1404                {
1405                    auto_keyed_transition = true;
1406                }
1407            }
1408            if auto_keyed_transition {
1409                counts.insert(parent_key, n);
1410            }
1411        }
1412        for (&parent, &n) in &counts {
1413            if let Some(&prev) = self.child_counts.get(&parent)
1414                && prev != n
1415            {
1416                self.warn(TRANSITION_AUTO_KEY, parent, || {
1417                    format!(
1418                        "this node went from {prev} to {n} children while a child without a key \
1419                         carries a transition: an auto key is the child's position, so the \
1420                         children that shifted became new nodes and snapped instead of easing \
1421                         (and inserting before them will again) — give them a key"
1422                    )
1423                });
1424            }
1425        }
1426        self.child_counts = counts;
1427    }
1428
1429    fn check_duplicate_keys(&mut self, tree: &Tree) {
1430        let mut keys = std::mem::take(&mut self.scratch);
1431        keys.clear();
1432        keys.extend(tree.keys.iter().map(|k| k.0));
1433        keys.sort_unstable();
1434        for w in keys.windows(2) {
1435            if w[0] == w[1] {
1436                self.warn(DUPLICATE_KEY, Key(w[0]), || {
1437                    "two nodes share this key in one frame: state retained per key (transitions, \
1438                     scroll offsets, editors, layout events, hover) is mixed between them — \
1439                     siblings need distinct labels"
1440                        .to_string()
1441                });
1442            }
1443        }
1444        self.scratch = keys;
1445    }
1446}
1447
1448#[cfg(test)]
1449mod tests {
1450    use super::*;
1451
1452    /// The drain hands a warning to the driver once; the log keeps it for
1453    /// anyone else, and a repeat of the same (code, key) is neither.
1454    #[test]
1455    fn the_log_outlives_the_drain() {
1456        let mut d = Diagnostics::default();
1457        let w = Warning {
1458            code: "test-code",
1459            key: Key::ROOT,
1460            message: "once".into(),
1461        };
1462        d.raise(w.clone());
1463        d.raise(w.clone());
1464        assert_eq!(d.take(), vec![w.clone()]);
1465        assert!(d.take().is_empty(), "drained");
1466        assert_eq!(d.raised(), &[w]);
1467    }
1468
1469    /// The `pub const NAME: &str = "code";` lines of this file, read back
1470    /// from the source: a code declared outside the `warnings!` block would
1471    /// compile and be missing from the table, and this is what notices.
1472    fn codes_in_source() -> Vec<&'static str> {
1473        include_str!("diag.rs")
1474            .lines()
1475            .filter_map(|line| {
1476                let rest = line.trim_start().strip_prefix("pub const ")?;
1477                let (_name, rest) = rest.split_once(": &str = \"")?;
1478                let (code, tail) = rest.split_once('"')?;
1479                (tail == ";").then_some(code)
1480            })
1481            .collect()
1482    }
1483
1484    #[test]
1485    fn every_code_is_in_the_table_and_vice_versa() {
1486        let in_source = codes_in_source();
1487        let in_table: Vec<&str> = WARNINGS.iter().map(|w| w.code).collect();
1488        assert!(
1489            in_source.len() >= 13,
1490            "the scan missed the consts: {in_source:?}"
1491        );
1492        assert_eq!(in_source, in_table);
1493        for (i, code) in in_table.iter().enumerate() {
1494            assert!(!in_table[i + 1..].contains(code), "duplicate code {code}");
1495            assert!(
1496                code.bytes().all(|b| b == b'-' || b.is_ascii_lowercase()),
1497                "{code}: codes are kebab-case"
1498            );
1499        }
1500    }
1501
1502    /// A menu row's dropped key takes the row's wording, whichever door
1503    /// raises it: `unknown_prop` under `MENU_ITEM` is `unknown_menu_item_key`
1504    /// (backlog RG10). One hint for the key with a meaning of its own, the
1505    /// letters' nearest for the rest, none for anything fuzzier.
1506    #[test]
1507    fn a_menu_rows_unknown_key_is_named_as_one() {
1508        let name = crate::MenuItem::NAME;
1509        let w = unknown_prop(name, "disabled", schema::Spelling::Camel);
1510        assert_eq!(w, unknown_menu_item_key("disabled"));
1511        assert_eq!(w.code, UNKNOWN_PROP);
1512        assert_eq!(w.key, Key::ROOT.str(UNKNOWN_PROP).str(name).str("disabled"));
1513        assert!(
1514            w.message.contains("`disabled` is not a key of a menu item")
1515                && w.message
1516                    .contains("`label`, `role`, `enabled`, `checked`, `id`, `accel`")
1517                && w.message.ends_with("(did you mean `enabled: false`?)"),
1518            "{}",
1519            w.message
1520        );
1521        assert!(
1522            unknown_menu_item_key("Label")
1523                .message
1524                .ends_with("(did you mean `label`?)")
1525        );
1526        assert!(unknown_menu_item_key("lable").message.ends_with("dropped"));
1527        assert!(
1528            unknown_prop("box", "disabled", schema::Spelling::Camel)
1529                .message
1530                .starts_with("`disabled` is"),
1531            "an element's is the element's"
1532        );
1533    }
1534
1535    #[test]
1536    fn every_row_has_a_doc() {
1537        for w in WARNINGS {
1538            assert!(
1539                w.doc.split_whitespace().count() > 5,
1540                "{}: no description",
1541                w.code
1542            );
1543            assert!(
1544                !w.doc.contains("[`"),
1545                "{}: rustdoc link syntax leaks into the bindings' docs",
1546                w.code
1547            );
1548        }
1549    }
1550}