Skip to main content

max / makeover-webview

0.59.0: the vocabulary's unruled half, written down module by module names() held four deliberate omissions and the emitters had fifteen, so cell-fill, form-group, form-label and a dozen others reached documents the set said were impossible. An app checking its stylesheet against it reads those rules as dead. One class list per module that emits markup, beside its emitters, and a corpus guard that renders every emitter and scrapes what came out rather than reading the source: eleven of the fifteen are built at runtime and are literals nowhere.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-22 22:05 UTC
Signed with PGP, not checked
Commit: 64b4639f414e7ef2bf62e73a0682ca6c0d64c5c5
Parent: bca43ee
9 files changed, +486 insertions, -6 deletions
M Cargo.toml +1 -1
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makeover-webview"
3 - version = "0.58.0"
3 + version = "0.59.0"
4 4 edition = "2024"
5 5 # One copy of this renderer per dependency graph, enforced by cargo rather than
6 6 # by remembering. Two versions means the generated stylesheet and the emitted
@@ -29,6 +29,19 @@
29 29 use makeover_layout::{Figure, Intent, Tone};
30 30 use std::fmt::Write as _;
31 31
32 + /// Every class this module can put in markup.
33 + ///
34 + /// [`crate::facet::FACET_CLASSES`]' obligation. `figures` is the strip around
35 + /// them and carries no rule of its own -- how tiles sit in a row is the app's
36 + /// layout -- so a scraped set cannot see it.
37 + pub const FIGURE_CLASSES: &[&str] = &[
38 + "figures",
39 + "figure",
40 + "figure-value",
41 + "figure-caption",
42 + "figure-change",
43 + ];
44 +
32 45 /// The accessible name for a figure: the noun, then the number.
33 46 ///
34 47 /// Built here rather than carried, for the reason [`meter_text`] is: a strip
M src/form.rs +48
@@ -44,6 +44,54 @@
44 44 use makeover_layout::{Choice, Depth, Field, FieldKind, Selector};
45 45 use std::fmt::Write as _;
46 46
47 + /// Every class this module can put in markup.
48 + ///
49 + /// [`crate::facet::FACET_CLASSES`]' obligation, and the module where it was
50 + /// missing longest. Most of these carry no rule and never will: `.form-group`,
51 + /// `.form-label`, `.form-hint` and `.form-error` are the apps' own names, kept
52 + /// so adoption deletes goingson's `renderFormField` rather than restyling
53 + /// anything, and phase A emits only what it can generate from the description.
54 + /// A class with no rule is invisible to [`crate::vocabulary::vocabulary`],
55 + /// which reads the generated sheet, so the unruled half of a renderer's
56 + /// vocabulary can only be written down.
57 + ///
58 + /// What went wrong without it: an app checking its stylesheet against
59 + /// [`crate::vocabulary::names`] concluded that its live `.form-group` and
60 + /// `.form-label` rules matched nothing and were safe to delete. quasi-webview
61 + /// carried them in a `MAKEOVER_UNLISTED` constant of its own until 0.59.0
62 + /// rather than let that happen.
63 + pub const FIELD_CLASSES: &[&str] = &[
64 + "field",
65 + "form-checkbox-label",
66 + "form-editor-modes",
67 + "form-editor-preview",
68 + "form-error",
69 + "form-group",
70 + "form-hint",
71 + "form-interval",
72 + "form-label",
73 + "form-option-reason",
74 + "form-radio-group",
75 + "form-radio-label",
76 + "form-suggestion",
77 + "form-suggestion-detail",
78 + "form-suggestions",
79 + "form-unit",
80 + ];
81 +
82 + /// The state classes a field carries, which take no prefix.
83 + ///
84 + /// `chosen` and `latched`'s convention, stated in
85 + /// [`crate::vocabulary::vocabulary`]: a state qualifies a prefixed component
86 + /// (`.mk-form-group.has-error`) rather than standing on its own, so a prefix
87 + /// moves the thing and not its state.
88 + ///
89 + /// `has-error` marks the group and `visible` marks the message, which is
90 + /// [`makeover_layout::Field::invalid`]'s own reasoning: a renderer with no
91 + /// descendant selectors cannot find the group from the message, so both are
92 + /// told.
93 + pub const FIELD_STATE_CLASSES: &[&str] = &["has-error", "visible"];
94 +
47 95 /// A string that is already markup, and is emitted without escaping.
48 96 ///
49 97 /// The one hole in the escaping, and it has to be named to be used. goingson
M src/lib.rs +7
@@ -386,6 +386,13 @@
386 386 pub mod placeholder;
387 387 pub mod vocabulary;
388 388
389 + /// A render of every emitter, scraped for the classes it wrote.
390 + ///
391 + /// Test-only, and the guard behind [`vocabulary::names`]. See the module's own
392 + /// header for why the check renders rather than reads the source.
393 + #[cfg(test)]
394 + mod corpus;
395 +
389 396 use crate::list::{cell_part_class, part_class};
390 397 use makeover_geometry::{Density, SizeClass};
391 398 // Re-exported rather than redefined. An app assembling its own stylesheet out
M src/list.rs +43
@@ -383,6 +383,24 @@
383 383 }
384 384 }
385 385
386 + /// Every class [`width_class`](fn@width_class) can return, including the
387 + /// fallback.
388 + ///
389 + /// See [`ROW_PART_CLASSES`] for why it is written out. Only two of the three
390 + /// carry a rule -- a fill is what a cell does when the sheet says nothing --
391 + /// which is exactly why the list is here rather than being read off the
392 + /// generated CSS: `cell-fill` reached every table in the tree and the
393 + /// vocabulary named it nowhere.
394 + pub const CELL_WIDTH_CLASSES: &[&str] = &["cell-content", "cell-fixed", "cell-fill"];
395 +
396 + /// Every class [`drop_class`](fn@drop_class) can return, including the
397 + /// fallback.
398 + ///
399 + /// [`CELL_WIDTH_CLASSES`]' argument, one column property over: `cell-keeps` is
400 + /// the tier the narrowing never hides, so the sheet writes no rule for it and
401 + /// a scraped set cannot see it.
402 + pub const CELL_DROP_CLASSES: &[&str] = &["cell-drops-first", "cell-drops-next", "cell-keeps"];
403 +
386 404 /// Every class [`cell_part_class`] can return, including the fallback.
387 405 ///
388 406 /// See [`ROW_PART_CLASSES`] for why it is written out.
@@ -483,6 +501,31 @@
483 501 mod tests {
484 502 use super::*;
485 503
504 + #[test]
505 + fn every_width_and_drop_class_is_one_the_vocabulary_wrote_down() {
506 + // The obligation ROW_PART_CLASSES carries. Both matches have a wildcard
507 + // arm, so a member added upstream lands on a class that is already in
508 + // the list; what this catches is a new arm returning a new name, which
509 + // would otherwise narrow what a checker believes this crate emits
510 + // without narrowing what it writes.
511 + for width in [Width::Content, Width::Fixed, Width::Fill] {
512 + assert!(
513 + CELL_WIDTH_CLASSES.contains(&width_class(width)),
514 + "{width:?} is missing from CELL_WIDTH_CLASSES"
515 + );
516 + }
517 + for priority in [Priority::Optional, Priority::Secondary, Priority::Essential] {
518 + assert!(
519 + CELL_DROP_CLASSES.contains(&drop_class(priority)),
520 + "{priority:?} is missing from CELL_DROP_CLASSES"
521 + );
522 + }
523 + let names = crate::vocabulary::names(&Emit::default());
524 + for name in CELL_WIDTH_CLASSES.iter().chain(CELL_DROP_CLASSES) {
525 + assert!(names.contains(*name), "{name} is not in the vocabulary");
526 + }
527 + }
528 +
486 529 #[test]
487 530 fn a_column_name_cannot_break_out_of_the_class_attribute() {
488 531 // Until 0.41.0 the name went in raw, so this emitted
@@ -25,6 +25,13 @@
25 25 use makeover_layout::{Intent, Meter, Tone};
26 26 use std::fmt::Write as _;
27 27
28 + /// Every class this module can put in markup.
29 + ///
30 + /// [`crate::facet::FACET_CLASSES`]' obligation. Both carry rules, so the
31 + /// scraped vocabulary already holds them; the list is what keeps that true if
32 + /// a rule goes away.
33 + pub const METER_CLASSES: &[&str] = &["progress", "progress-fill"];
34 +
28 35 /// The accessible name for a meter: the two numbers, and the noun if it has one.
29 36 ///
30 37 /// The description carries the noun alone, so the sentence is built here. That
@@ -29,6 +29,13 @@
29 29 use makeover_layout::{Intent, Readiness, Tone};
30 30 use std::fmt::Write as _;
31 31
32 + /// Every class this module can put in markup.
33 + ///
34 + /// [`crate::facet::FACET_CLASSES`]' obligation. `placeholder-action` is
35 + /// unruled: what a way out of an empty state looks like is the button inside
36 + /// it, and the wrapper only says where it goes.
37 + pub const PLACEHOLDER_CLASSES: &[&str] = &["placeholder", "placeholder-text", "placeholder-action"];
38 +
32 39 /// A region's stand-in, or nothing at all when the region has its content.
33 40 ///
34 41 /// ```
@@ -34,7 +34,14 @@
34 34 //! from, and a class added to an emitter enters the vocabulary in the same
35 35 //! commit that adds it.
36 36
37 - use crate::list::{CELL_PART_CLASSES, FLOW_CLASSES, ROW_PART_CLASSES};
37 + use crate::facet::FACET_CLASSES;
38 + use crate::figure::FIGURE_CLASSES;
39 + use crate::form::{FIELD_CLASSES, FIELD_STATE_CLASSES};
40 + use crate::list::{
41 + CELL_DROP_CLASSES, CELL_PART_CLASSES, CELL_WIDTH_CLASSES, FLOW_CLASSES, ROW_PART_CLASSES,
42 + };
43 + use crate::meter::METER_CLASSES;
44 + use crate::placeholder::PLACEHOLDER_CLASSES;
38 45 use crate::{Emit, option_class};
39 46 use makeover_layout::Selector;
40 47 use std::collections::{BTreeMap, BTreeSet};
@@ -53,10 +60,27 @@
53 60
54 61 /// Every class this crate can put in markup or in a rule.
55 62 ///
56 - /// [`vocabulary`] plus the part classes it deliberately leaves unruled. This is
57 - /// the set to check a renderer's emitted markup against: a class outside it is
58 - /// a name that renderer invented, which is how quasi-webview came to spell
59 - /// `tabs`, `segmented` and `option` and render every described selector flat.
63 + /// [`vocabulary`] plus every class an emitter here writes without the sheet
64 + /// ruling it. This is the set to check a renderer's emitted markup against: a
65 + /// class outside it is a name that renderer invented, which is how
66 + /// quasi-webview came to spell `tabs`, `segmented` and `option` and render
67 + /// every described selector flat.
68 + ///
69 + /// # The unruled half is written down, module by module
70 + ///
71 + /// One list per module that emits markup, each beside its emitters, and this
72 + /// is their union. That shape is the fix for how the set was wrong until
73 + /// 0.59.0: it held four deliberate omissions and the emitters had fifteen, so
74 + /// `cell-fill`, `form-group`, `form-label` and a dozen others came out in
75 + /// documents that this function said were impossible. An app reading it
76 + /// concluded its live rules for them were dead and would have deleted them,
77 + /// which is why quasi-webview carried a `MAKEOVER_UNLISTED` constant of its own
78 + /// to put them back.
79 + ///
80 + /// [`crate::corpus`] is what keeps the union honest, and it renders rather than
81 + /// reading the source: a width class, a drop class and a state appended to an
82 + /// open attribute are literals nowhere, which is what a reading of the
83 + /// emitters missed for eleven of the fifteen.
60 84 #[must_use]
61 85 pub fn names(opts: &Emit) -> BTreeSet<String> {
62 86 let mut all = vocabulary(opts);
@@ -64,8 +88,15 @@
64 88 ROW_PART_CLASSES
65 89 .iter()
66 90 .chain(CELL_PART_CLASSES)
91 + .chain(CELL_WIDTH_CLASSES)
92 + .chain(CELL_DROP_CLASSES)
67 93 .chain(FLOW_CLASSES)
68 94 .chain(crate::RUN_CLASSES)
95 + .chain(FACET_CLASSES)
96 + .chain(FIELD_CLASSES)
97 + .chain(FIGURE_CLASSES)
98 + .chain(METER_CLASSES)
99 + .chain(PLACEHOLDER_CLASSES)
69 100 .map(|name| crate::class(name, opts)),
70 101 );
71 102 all.extend(
@@ -73,6 +104,10 @@
73 104 .into_iter()
74 105 .map(|s| crate::class(option_class(s), opts)),
75 106 );
107 + // Unprefixed, deliberately, exactly as the `chosen` and `latched` the
108 + // scraped half brings in: a state qualifies a prefixed component rather
109 + // than standing on its own.
110 + all.extend(FIELD_STATE_CLASSES.iter().map(|name| (*name).to_owned()));
76 111 all
77 112 }
78 113
A src/corpus.rs +320
@@ -1,0 +1,320 @@
1 + //! A render of everything this crate can emit, scraped for class names.
2 + //!
3 + //! [`vocabulary::names`](crate::vocabulary::names) claims to hold every class
4 + //! this crate can put in markup, and until 0.59.0 nothing checked the claim. It
5 + //! was wrong by a whole family: `quasi-webview`'s own corpus guard found
6 + //! `cell-fill`, `form-group` and `form-label` coming out in rendered documents
7 + //! and had to carry them in a `MAKEOVER_UNLISTED` constant of its own, because
8 + //! an app checking its stylesheet against our set alone concludes that its
9 + //! rules for them are dead and deletes live styling.
10 + //!
11 + //! What that guard recorded is also why this is a render and not a scan of the
12 + //! emitters: a careful read of `quasi-webview`'s emitters produced 35 names and
13 + //! its corpus found 14 more. A class assembled at runtime -- a width class, a
14 + //! drop class, a state appended to an attribute already open -- is a literal
15 + //! nowhere in this source, and that is the shape of every name that was
16 + //! missing here.
17 + //!
18 + //! # What it is not
19 + //!
20 + //! Not a rendering test. Nothing here asserts what an emitter produced, only
21 + //! which classes came out, so it stays quiet when markup changes and speaks
22 + //! when the vocabulary does.
23 +
24 + use crate::form::{Filling, Markup, Value, field_html};
25 + use crate::list::{Cell, cells_html};
26 + use crate::{Emit, facet::facet_html, figure::figures_html};
27 + use crate::{meter::meter_html, placeholder::placeholder_html};
28 + use makeover_layout::{
29 + Accepted, CellPart, Choice, Column, Facet, FacetValue, Field, FieldKind, Figure, Meter,
30 + Priority, Readiness, Selecting, Sort, Standing, Tone, Width,
31 + };
32 + use std::collections::BTreeSet;
33 +
34 + /// Every class the corpus puts in a document, unprefixed.
35 + ///
36 + /// Scraped from `class="..."` rather than predicted, which is the point.
37 + pub(crate) fn emitted() -> BTreeSet<String> {
38 + emitted_with(&Emit::default())
39 + }
40 +
41 + /// [`emitted`], with the emit options a host would set.
42 + fn emitted_with(opts: &Emit) -> BTreeSet<String> {
43 + let mut found = BTreeSet::new();
44 + for html in documents(opts) {
45 + let mut rest = html.as_str();
46 + while let Some(at) = rest.find("class=\"") {
47 + rest = &rest[at + "class=\"".len()..];
48 + let end = rest.find('"').expect("an attribute closes");
49 + for name in rest[..end].split_whitespace() {
50 + found.insert(name.to_owned());
51 + }
52 + rest = &rest[end..];
53 + }
54 + }
55 + found
56 + }
57 +
58 + /// One document per emitter, over every input that changes what it writes.
59 + ///
60 + /// Every markup emitter this crate has is called here. A new one that is not
61 + /// added is the one hole this guard has, which is why the list is short enough
62 + /// to read: `placeholder`, `form`, `figure`, `facet`, `meter` and `list` are
63 + /// the whole of what emits markup, and `lib.rs` writes rules rather than
64 + /// documents.
65 + fn documents(opts: &Emit) -> Vec<String> {
66 + let mut out = vec![placeholders(opts), fields(opts), rows(opts)];
67 + out.push(figures_html(
68 + &[
69 + Figure::new("42", "Tasks"),
70 + Figure::new("12.5%", "Growth")
71 + .change("+3")
72 + .tone(Tone::Success),
73 + ],
74 + opts,
75 + ));
76 + for mode in [
77 + Selecting::OneOf,
78 + Selecting::AnyOf,
79 + Selecting::Range,
80 + Selecting::Text,
81 + Selecting::Subtree,
82 + ] {
83 + let values = [
84 + FacetValue::new("music", "Music")
85 + .standing(Standing::Taken)
86 + .counted(128)
87 + .at(0, true),
88 + FacetValue::new("music/synths", "Synths")
89 + .standing(Standing::Inherited)
90 + .at(1, false),
91 + FacetValue::new("music/drums", "Drums")
92 + .standing(Standing::Pruned)
93 + .at(1, false),
94 + FacetValue::new("talk", "Talk").at(0, false),
95 + ];
96 + out.push(facet_html(&Facet::new("Tag", mode, &values), opts));
97 + }
98 + // Toned, untoned, and over its total, which is the one state that adds an
99 + // attribute of its own.
100 + for meter in [
101 + Meter::new(3, 7),
102 + Meter::new(3, 7).tone(Tone::Success).label("subtasks"),
103 + Meter::new(9, 7).tone(Tone::Danger),
104 + ] {
105 + out.push(meter_html(&meter, opts));
106 + }
107 + out
108 + }
109 +
110 + /// Every readiness, with and without the action a stand-in can carry.
111 + fn placeholders(opts: &Emit) -> String {
112 + let mut html = String::new();
113 + for state in [
114 + Readiness::Ready,
115 + Readiness::Pending,
116 + Readiness::Empty,
117 + Readiness::Failed,
118 + ] {
119 + html.push_str(&placeholder_html(state, "Nothing here", None, opts));
120 + html.push_str(&placeholder_html(
121 + state,
122 + "Nothing here",
123 + Some(Markup("<button>Add one</button>")),
124 + opts,
125 + ));
126 + }
127 + html
128 + }
129 +
130 + /// Every field kind, twice: plain, and carrying everything a group can hold.
131 + ///
132 + /// The second pass is where the vocabulary lives. A hint, a unit and an error
133 + /// each add a class, and an error adds two -- one on the message and one on the
134 + /// group, which is `Field::invalid`'s own reasoning about a renderer that
135 + /// cannot find the group from the message.
136 + fn fields(opts: &Emit) -> String {
137 + const OPTIONS: &[Choice<'_>] = &[
138 + Choice::new("a", "The first"),
139 + Choice::new("b", "The second").unless("Not while the first is running"),
140 + ];
141 + const ACCEPT: &[Accepted<'_>] = &[Accepted::Type("image/png"), Accepted::Suffix(".zip")];
142 +
143 + let mut html = String::new();
144 + for kind in [
145 + FieldKind::Text,
146 + FieldKind::Secret,
147 + FieldKind::Number,
148 + FieldKind::Range,
149 + FieldKind::Interval,
150 + FieldKind::Email,
151 + FieldKind::Url,
152 + FieldKind::Tel,
153 + FieldKind::Date,
154 + FieldKind::DateTime,
155 + FieldKind::Textarea,
156 + FieldKind::Rich,
157 + FieldKind::Select,
158 + FieldKind::Radio,
159 + FieldKind::Checkbox,
160 + FieldKind::File,
161 + FieldKind::Hidden,
162 + ] {
163 + let plain = Field {
164 + options: OPTIONS,
165 + accept: ACCEPT,
166 + upper_name: Some("upper"),
167 + min: Some("0"),
168 + max: Some("10"),
169 + ..Field::new(kind, "name", "Label")
170 + };
171 + let dressed = Field {
172 + hint: Some("What it is for"),
173 + error: Some("That will not do"),
174 + unit: Some("minutes"),
175 + required: true,
176 + ..plain
177 + };
178 + for field in [plain, dressed] {
179 + for value in [
180 + Value::Absent,
181 + Value::Text("a"),
182 + Value::On(true),
183 + Value::Between {
184 + lower: "1",
185 + upper: "9",
186 + },
187 + ] {
188 + html.push_str(&field_html(&field, &Filling::of(value), opts));
189 + }
190 + }
191 + }
192 + html
193 + }
194 +
195 + /// A cell of every width and every priority, and every part a cell can be.
196 + ///
197 + /// The width and the drop are the classes no source literal carries: they are
198 + /// chosen from the column and pushed, which is how `cell-fill` came to be
199 + /// emitted by every table in the tree and named by nothing.
200 + fn rows(opts: &Emit) -> String {
201 + let mut columns = Vec::new();
202 + for (index, width) in [Width::Content, Width::Fixed, Width::Fill]
203 + .into_iter()
204 + .enumerate()
205 + {
206 + for (rank, priority) in [Priority::Optional, Priority::Secondary, Priority::Essential]
207 + .into_iter()
208 + .enumerate()
209 + {
210 + columns.push(Column {
211 + width,
212 + priority,
213 + sortable: true,
214 + sorted: Some(if rank % 2 == 0 {
215 + Sort::Ascending
216 + } else {
217 + Sort::Descending
218 + }),
219 + ..Column::new(NAMES[index * 3 + rank])
220 + });
221 + }
222 + }
223 +
224 + let parts = [
225 + None,
226 + Some(CellPart::Value),
227 + Some(CellPart::Tokens),
228 + Some(CellPart::Actions),
229 + Some(CellPart::Link),
230 + ];
231 + let mut html = String::new();
232 + for part in parts {
233 + let cells: Vec<Cell<'_>> = columns
234 + .iter()
235 + .map(|column| Cell {
236 + column: column.name,
237 + part,
238 + content: Markup("<span>x</span>"),
239 + })
240 + .collect();
241 + html.push_str(&cells_html(&columns, &cells, opts));
242 + }
243 + html
244 + }
245 +
246 + /// A name per column, so the nine are nine columns rather than one repeated.
247 + const NAMES: [&str; 9] = ["a", "b", "c", "d", "e", "f", "g", "h", "i"];
248 +
249 + #[cfg(test)]
250 + mod tests {
251 + use super::*;
252 +
253 + #[test]
254 + fn every_class_this_crate_emits_is_one_its_vocabulary_names() {
255 + // The guard `MAKEOVER_UNLISTED` in quasi-webview was standing in for.
256 + // A name emitted and not written down shrinks the set an app checks
257 + // against, so the app concludes its live rules are dead -- silently,
258 + // and in the direction that deletes styling rather than keeping too
259 + // much of it.
260 + let opts = Emit::default();
261 + let names = crate::vocabulary::names(&opts);
262 + let emitted = emitted();
263 + assert!(
264 + emitted.len() > 30,
265 + "the corpus rendered {} classes, which reads as the corpus having \
266 + stopped calling the emitters rather than the crate having shrunk",
267 + emitted.len()
268 + );
269 +
270 + let stranger: Vec<&String> = emitted
271 + .iter()
272 + // The one open family, and it is this crate's: a cell carries a
273 + // class built from its column's name, which is app data and which
274 + // no set can hold. See `list::push_column_class`.
275 + .filter(|class| !class.starts_with("col-"))
276 + .filter(|class| !names.contains(*class))
277 + .collect();
278 + assert!(
279 + stranger.is_empty(),
280 + "{} class(es) emitted that `vocabulary::names` does not hold. Give \
281 + them a rule, or add them to the unruled list `names` reads:\n{}",
282 + stranger.len(),
283 + stranger
284 + .iter()
285 + .map(|c| format!(" .{c}"))
286 + .collect::<Vec<_>>()
287 + .join("\n")
288 + );
289 + }
290 +
291 + #[test]
292 + fn a_class_prefix_reaches_every_class_but_the_states() {
293 + // The same guard with a prefix set, which is a different question: a
294 + // name that reached markup without going through `class` is prefixed
295 + // nowhere and would pass the check above, then fail in the one app that
296 + // sets a prefix. `names` answers for both, because it prefixes the
297 + // written-down half and leaves the states alone.
298 + let opts = Emit {
299 + class_prefix: "mk-",
300 + ..Emit::default()
301 + };
302 + let names = crate::vocabulary::names(&opts);
303 + let stranger: Vec<String> = emitted_with(&opts)
304 + .into_iter()
305 + .filter(|class| !class.starts_with("mk-col-"))
306 + .filter(|class| !names.contains(class))
307 + .collect();
308 + assert!(
309 + stranger.is_empty(),
310 + "{} class(es) a prefixed render emits that `vocabulary::names` does \
311 + not hold:\n{}",
312 + stranger.len(),
313 + stranger
314 + .iter()
315 + .map(|c| format!(" .{c}"))
316 + .collect::<Vec<_>>()
317 + .join("\n")
318 + );
319 + }
320 + }