Skip to main content

polydat_nodes/
string.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! String generation and transformation nodes.
5
6// =================================================================
7// Combinations: mixed-radix character set mapping
8// =================================================================
9
10/// Map a u64 to a formatted string via mixed-radix indexing into
11/// character sets.
12///
13/// Signature: `combinations(input: u64, pattern: &str) -> (String)`
14///
15/// The pattern is a semicolon-delimited list of character set specs.
16/// Each spec is a character range (`A-Z`), literal characters, or
17/// both. A single literal character (like `-`) is emitted as-is
18/// without consuming a radix digit.
19///
20/// Use for generating structured identifiers with fixed character
21/// classes per position. Examples: phone numbers
22/// (`"0-9;0-9;0-9;-;0-9;0-9;0-9;-;0-9;0-9;0-9;0-9"` yields
23/// `"372-841-9205"`), license plates (`"A-Z;A-Z;A-Z;-;0-9;0-9;0-9"`),
24/// or hex tokens (`"0-9a-f;0-9a-f;0-9a-f;0-9a-f"`). Input wraps at
25/// `cardinality()`, so every value in the cycle space maps to a valid
26/// string.
27///
28/// JIT level: no u64 kit (String output); lowered through its slot
29/// kit, called from native code.
30/// Derived state for `combinations`. Computed once per node
31/// instance via `from_pattern`; the macro stores the instance in a
32/// struct field and hands the eval body a `&ParsedCombinations`
33/// borrow each call.
34pub struct ParsedCombinations {
35    /// The segments, in output order.
36    pub segments: Vec<Segment>,
37    /// The product of the charset sizes: the distinct combinations.
38    pub modulus: u64,
39}
40
41impl polydat::derive_support::PolydatSetup for ParsedCombinations {}
42
43/// One piece of a combinations pattern.
44pub enum Segment {
45    /// Variable: select one char from the charset based on a radix digit.
46    Charset(Vec<char>),
47    /// Fixed: always emit this string (e.g., a literal separator).
48    Literal(String),
49}
50
51impl ParsedCombinations {
52    /// Single-call setup. The `#[polydat_node]` macro invokes
53    /// this exactly once in the generated `Combinations::new()`;
54    /// no other call path exists.
55    pub fn from_pattern(pattern: &str) -> Self {
56        let mut segments = Vec::new();
57        let mut modulus: u64 = 1;
58        for spec in pattern.split(';') {
59            let chars = parse_charset(spec);
60            if chars.len() == 1 && !spec.contains('-') {
61                segments.push(Segment::Literal(chars[0].to_string()));
62            } else if chars.is_empty() {
63                segments.push(Segment::Literal(spec.to_string()));
64            } else {
65                modulus = modulus.saturating_mul(chars.len() as u64);
66                segments.push(Segment::Charset(chars));
67            }
68        }
69        Self { segments, modulus }
70    }
71}
72
73/// Map a u64 input to a deterministic string by interpreting
74/// it as a multi-positional choice over the pattern's charsets.
75#[polydat::polydat_node(category = String)]
76fn combinations(
77    input: u64,
78    pattern: polydat::derive_support::Const<&str>,
79    #[poly_const(ParsedCombinations::from_pattern, from = pattern)] parsed: &ParsedCombinations,
80) -> String {
81    let mut remainder = if parsed.modulus > 0 {
82        input % parsed.modulus
83    } else {
84        input
85    };
86    let mut result = String::with_capacity(parsed.segments.len() * 2);
87    for seg in &parsed.segments {
88        match seg {
89            Segment::Literal(s) => result.push_str(s),
90            Segment::Charset(chars) => {
91                let radix = chars.len() as u64;
92                if radix > 0 {
93                    let idx = (remainder % radix) as usize;
94                    result.push(chars[idx]);
95                    remainder /= radix;
96                }
97            }
98        }
99    }
100    result
101}
102
103impl Combinations {
104    /// The total number of unique combinations before wrapping.
105    pub fn cardinality(&self) -> u64 {
106        self.parsed.modulus
107    }
108}
109
110/// Parse a charset spec like "A-Z", "0-9", "a-z0-9", "A-Za-z0-9 _|/"
111fn parse_charset(spec: &str) -> Vec<char> {
112    let mut chars = Vec::new();
113    let spec_chars: Vec<char> = spec.chars().collect();
114    let mut i = 0;
115    while i < spec_chars.len() {
116        if i + 2 < spec_chars.len() && spec_chars[i + 1] == '-' {
117            // Range: A-Z, 0-9, etc.
118            let start = spec_chars[i];
119            let end = spec_chars[i + 2];
120            for c in start..=end {
121                chars.push(c);
122            }
123            i += 3;
124        } else {
125            chars.push(spec_chars[i]);
126            i += 1;
127        }
128    }
129    chars
130}
131
132// =================================================================
133// NumberToWords: spell out numbers in English
134// =================================================================
135
136/// Convert a u64 to its English word representation.
137///
138/// Signature: `number_to_words(input: u64) -> (String)`
139///
140/// Examples: 0 produces "zero", 42 produces "forty-two", 1000
141/// produces "one thousand". Supports the full u64 range up through
142/// quintillions.
143///
144/// JIT level: no u64 kit (String output); lowered through its slot
145/// kit, called from native code.
146#[polydat::polydat_node(category = String)]
147fn number_to_words(input: u64) -> String {
148    u64_to_words(input)
149}
150
151const ONES: [&str; 20] = [
152    "zero",
153    "one",
154    "two",
155    "three",
156    "four",
157    "five",
158    "six",
159    "seven",
160    "eight",
161    "nine",
162    "ten",
163    "eleven",
164    "twelve",
165    "thirteen",
166    "fourteen",
167    "fifteen",
168    "sixteen",
169    "seventeen",
170    "eighteen",
171    "nineteen",
172];
173
174const TENS: [&str; 10] = [
175    "", "", "twenty", "thirty", "forty", "fifty", "sixty", "seventy", "eighty", "ninety",
176];
177
178const SCALES: [&str; 7] = [
179    "",
180    "thousand",
181    "million",
182    "billion",
183    "trillion",
184    "quadrillion",
185    "quintillion",
186];
187
188fn u64_to_words(n: u64) -> String {
189    if n < 20 {
190        return ONES[n as usize].to_string();
191    }
192
193    let mut buf = String::with_capacity(64);
194    let mut chunks = [0u32; 7];
195    let mut num_chunks = 0;
196    let mut remaining = n;
197
198    while remaining > 0 {
199        chunks[num_chunks] = (remaining % 1000) as u32;
200        num_chunks += 1;
201        remaining /= 1000;
202    }
203
204    let mut first = true;
205    for i in (0..num_chunks).rev() {
206        let chunk = chunks[i];
207        if chunk > 0 {
208            if !first {
209                buf.push(' ');
210            }
211            first = false;
212            append_chunk_to_words(&mut buf, chunk);
213            if i > 0 && i < SCALES.len() {
214                buf.push(' ');
215                buf.push_str(SCALES[i]);
216            }
217        }
218    }
219
220    buf
221}
222
223fn append_chunk_to_words(buf: &mut String, n: u32) {
224    let hundreds = n / 100;
225    let remainder = n % 100;
226
227    let mut has_hundreds = false;
228    if hundreds > 0 {
229        buf.push_str(ONES[hundreds as usize]);
230        buf.push_str(" hundred");
231        has_hundreds = true;
232    }
233
234    if remainder >= 20 {
235        if has_hundreds {
236            buf.push(' ');
237        }
238        let tens = remainder / 10;
239        let ones = remainder % 10;
240        buf.push_str(TENS[tens as usize]);
241        if ones > 0 {
242            buf.push('-');
243            buf.push_str(ONES[ones as usize]);
244        }
245    } else if remainder > 0 {
246        if has_hundreds {
247            buf.push(' ');
248        }
249        buf.push_str(ONES[remainder as usize]);
250    }
251}
252
253// =================================================================
254// HashedUuid: deterministic UUID v4 from a u64 seed
255// =================================================================
256
257/// Generate a deterministic UUID v4 string from a u64 seed.
258/// Same seed always produces the same UUID; the hash output
259/// fills the 128 UUID bits with version (4) and variant
260/// (RFC 4122) bits set per spec.
261///
262/// Signature: `hashed_uuid(input: u64) -> (String)`
263#[polydat::polydat_node(category = String)]
264fn hashed_uuid(input: u64) -> String {
265    // Two hashes fill 128 bits.
266    let h1 = xxhash_rust::xxh3::xxh3_64(&input.to_le_bytes());
267    let h2 = xxhash_rust::xxh3::xxh3_64(&h1.to_le_bytes());
268    let mut bytes = [0u8; 16];
269    bytes[..8].copy_from_slice(&h1.to_le_bytes());
270    bytes[8..].copy_from_slice(&h2.to_le_bytes());
271    // Version 4 (bits 12-15 of byte 6).
272    bytes[6] = (bytes[6] & 0x0F) | 0x40;
273    // Variant RFC 4122 (bits 6-7 of byte 8).
274    bytes[8] = (bytes[8] & 0x3F) | 0x80;
275    format!(
276        "{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}",
277        bytes[0],
278        bytes[1],
279        bytes[2],
280        bytes[3],
281        bytes[4],
282        bytes[5],
283        bytes[6],
284        bytes[7],
285        bytes[8],
286        bytes[9],
287        bytes[10],
288        bytes[11],
289        bytes[12],
290        bytes[13],
291        bytes[14],
292        bytes[15],
293    )
294}
295
296// =================================================================
297// CharBuf: deterministic string from seed + charset + length
298// =================================================================
299
300/// Generate a deterministic string of a given length from a seed
301/// and character set.
302///
303/// The seed is hashed and used to index into the charset repeatedly.
304/// Same seed + charset + length always produces the same string.
305///
306/// Signature: `char_buf(seed: u64, charset: &str, length: u64) -> (String)`
307/// Expand a charset spec like "A-Za-z0-9" into a Vec<char>.
308/// Setup helper for `char_buf`.
309fn expand_charset(charset: &str) -> Vec<char> {
310    if charset.is_empty() {
311        return ('a'..='z').collect();
312    }
313    let mut result = Vec::new();
314    let chars_vec: Vec<char> = charset.chars().collect();
315    let mut i = 0;
316    while i < chars_vec.len() {
317        if i + 2 < chars_vec.len() && chars_vec[i + 1] == '-' {
318            for c in chars_vec[i]..=chars_vec[i + 2] {
319                result.push(c);
320            }
321            i += 3;
322        } else {
323            result.push(chars_vec[i]);
324            i += 1;
325        }
326    }
327    if result.is_empty() {
328        ('a'..='z').collect()
329    } else {
330        result
331    }
332}
333
334/// Generate a deterministic string of a given length from a
335/// seed and character set.
336#[polydat::polydat_node(category = String)]
337fn char_buf(
338    seed: u64,
339    charset: polydat::derive_support::Const<&str>,
340    length: u64,
341    #[poly_const(expand_charset, from = charset)] chars: &Vec<char>,
342) -> String {
343    let n = chars.len();
344    if n == 0 || length == 0 {
345        return String::new();
346    }
347    // `length` is a wire: a size the machine cannot hold is a failure
348    // of this node, not an abort of the host (`string_for`).
349    let mut result = polydat::derive_support::string_for(length, "char_buf");
350    let mut h = seed;
351    for _ in 0..length {
352        h = xxhash_rust::xxh3::xxh3_64(&h.to_le_bytes());
353        result.push(chars[(h as usize) % n]);
354    }
355    result
356}
357
358// =================================================================
359// FileLineAt: index into a file of lines at cycle time
360// =================================================================
361
362/// Read `filename` and split it into lines. Panics on file
363/// I/O failure; the macro's build-closure `catch_unwind`
364/// surfaces it as a clean compile error.
365fn read_file_lines(filename: &str) -> Vec<String> {
366    let content = std::fs::read_to_string(filename)
367        .unwrap_or_else(|e| panic!("failed to read file '{filename}': {e}"));
368    let lines: Vec<String> = content.lines().map(|l| l.to_string()).collect();
369    if lines.is_empty() {
370        panic!("file '{filename}' has no lines");
371    }
372    lines
373}
374
375/// Cycle-time line lookup over a pre-loaded text file. `filename`
376/// is read at construction time via `#[poly_const]`; the cycle
377/// input selects a line modulo the total count.
378#[polydat::polydat_node(category = String)]
379fn file_line_at(
380    index: u64,
381    filename: polydat::derive_support::Const<&str>,
382    #[poly_const(read_file_lines, from = filename)] lines: &Vec<String>,
383) -> String {
384    let _ = filename;
385    let idx = index as usize;
386    lines[idx % lines.len()].clone()
387}
388
389// =================================================================
390// StrConcat: variadic string concatenation
391// =================================================================
392
393/// Concatenate N wire inputs into a single Str output.
394///
395/// Each input is rendered to its display form: Str passes through,
396/// numerics format as decimal, Bool as `true`/`false`, Json via
397/// `to_string`. Mixed-type inputs are accepted — the assembler skips
398/// type checking for str_concat (like printf), so any upstream wire
399/// type composes.
400///
401/// Used by the DSL desugar of `+` between Str-typed operands; also
402/// callable directly as `str_concat(a, b, c, ...)`.
403///
404/// Signature: `str_concat(in_0, in_1, ...) -> (String)`
405/// Concatenate N values, rendering each as its display form.
406/// Variadic over `&[Value]`: the body stringifies
407/// per element so mixed-type inputs (Str + U64 + Bool, etc.)
408/// produce a single concatenated string; this matches the
409/// DSL's lowering of `+` between Str-typed operands.
410#[polydat::polydat_node(category = String)]
411fn str_concat(parts: &[polydat::ast::Value]) -> String {
412    use polydat::ast::Value;
413    let mut out = String::new();
414    for v in parts {
415        match v {
416            Value::Str(s) => out.push_str(s),
417            Value::U64(n) => out.push_str(&n.to_string()),
418            Value::F64(n) => out.push_str(&n.to_string()),
419            Value::Bool(b) => out.push_str(&b.to_string()),
420            Value::Json(j) => out.push_str(&j.to_string()),
421            Value::Bytes(b) => out.push_str(&String::from_utf8_lossy(b)),
422            // Everything else, extension values included, renders as it
423            // does in interpolation and `printf`: its display form.
424            other => out.push_str(&other.to_display_string()),
425        }
426    }
427    out
428}
429
430// =================================================================
431// StrLower / StrUpper: Unicode case-folding helpers
432// =================================================================
433
434/// Fold a string to lowercase (`str.to_lowercase()` semantics).
435///
436/// Signature: `str_lower(input: Str) -> (Str)`
437///
438/// Takes `String`; non-Str upstream wires reach it through
439/// assembler-inserted Str adapters (e.g. polyfill U64ToStr), not
440/// through value coercion.
441#[polydat::polydat_node(category = String)]
442fn str_lower(input: String) -> String {
443    input.to_lowercase()
444}
445
446/// Fold a string to uppercase (`str.to_uppercase()` semantics).
447///
448/// Signature: `str_upper(input: Str) -> (Str)`
449#[polydat::polydat_node(category = String)]
450fn str_upper(input: String) -> String {
451    input.to_uppercase()
452}
453/// `str_to_u64(s)` — the unsigned integer a string names, or `0` when
454/// it names none.
455///
456/// The whole string must be digits: `"4242"` is `4242`, and `"4242x"`
457/// is `0` rather than a partial read, because a partial read of a
458/// malformed value is worse than a plain miss. The `as` cast
459/// (`s as u64`) is the same parse in expression position and is a hard
460/// error on a malformed value; this node exists for the case where a
461/// caller wants the miss, and composes with `str_digit_suffix`.
462#[polydat::polydat_node(category = Conversions)]
463fn str_to_u64(s: &str) -> u64 {
464    s.trim().parse::<u64>().unwrap_or(0)
465}
466
467/// `str_digit_suffix(s)` — the run of digits ending the string, as a
468/// number, or `0` when it does not end in one.
469///
470/// `"label-17"` is `17`, `"profile_8"` is `8`, `"sift1m"` is `1`
471/// because `m` is not a digit and the run ends before it. Names that
472/// carry an index in their tail are common enough as a host convention
473/// to be worth a node; reading the tail with string arithmetic in a
474/// program would be several nodes and one off-by-one.
475///
476/// A run too long for a `u64` answers `0`, on the same principle as
477/// `str_to_u64`: a wrong number is worse than no number.
478#[polydat::polydat_node(category = Conversions)]
479fn str_digit_suffix(s: &str) -> u64 {
480    let s = s.trim_end();
481    let start = s
482        .char_indices()
483        .rev()
484        .take_while(|(_, c)| c.is_ascii_digit())
485        .last()
486        .map(|(i, _)| i);
487    match start {
488        Some(i) => s[i..].parse::<u64>().unwrap_or(0),
489        None => 0,
490    }
491}
492
493#[cfg(test)]
494mod tests {
495    use super::*;
496    use polydat::ast::{PolydatNode, Value};
497
498    // --- Combinations tests ---
499
500    #[test]
501    fn combinations_digits() {
502        let node = Combinations::new("0-9;0-9;0-9".to_string());
503        let mut out = [Value::None];
504        node.eval(&[Value::U64(123)], &mut out);
505        let s = out[0].as_str();
506        assert_eq!(s.len(), 3);
507        assert!(s.chars().all(|c| c.is_ascii_digit()));
508    }
509
510    #[test]
511    fn combinations_with_separator() {
512        let node = Combinations::new("0-9;0-9;0-9;-;0-9;0-9;0-9".to_string());
513        let mut out = [Value::None];
514        node.eval(&[Value::U64(0)], &mut out);
515        let s = out[0].as_str();
516        assert_eq!(s.len(), 7); // 3 digits + dash + 3 digits
517        assert_eq!(&s[3..4], "-");
518    }
519
520    #[test]
521    fn combinations_alpha() {
522        let node = Combinations::new("A-Z;A-Z;A-Z".to_string());
523        let mut out = [Value::None];
524        node.eval(&[Value::U64(0)], &mut out);
525        assert_eq!(out[0].as_str(), "AAA");
526        node.eval(&[Value::U64(1)], &mut out);
527        assert_eq!(out[0].as_str(), "BAA");
528    }
529
530    #[test]
531    fn combinations_cardinality() {
532        let node = Combinations::new("0-9;0-9;-;A-Z".to_string());
533        // 10 * 10 * 26 = 2600 (separator doesn't count)
534        assert_eq!(node.cardinality(), 2600);
535    }
536
537    #[test]
538    fn combinations_deterministic() {
539        let node = Combinations::new("A-Z;0-9".to_string());
540        let mut out1 = [Value::None];
541        let mut out2 = [Value::None];
542        node.eval(&[Value::U64(42)], &mut out1);
543        node.eval(&[Value::U64(42)], &mut out2);
544        assert_eq!(out1[0].as_str(), out2[0].as_str());
545    }
546
547    #[test]
548    fn combinations_wraps() {
549        let node = Combinations::new("0-9".to_string());
550        let mut out = [Value::None];
551        node.eval(&[Value::U64(0)], &mut out);
552        let a = out[0].as_str().to_string();
553        node.eval(&[Value::U64(10)], &mut out);
554        assert_eq!(out[0].as_str(), &a, "should wrap at cardinality");
555    }
556
557    // --- NumberToWords tests ---
558
559    #[test]
560    fn number_to_words_zero() {
561        assert_eq!(u64_to_words(0), "zero");
562    }
563
564    #[test]
565    fn number_to_words_teens() {
566        assert_eq!(u64_to_words(1), "one");
567        assert_eq!(u64_to_words(11), "eleven");
568        assert_eq!(u64_to_words(19), "nineteen");
569    }
570
571    #[test]
572    fn number_to_words_tens() {
573        assert_eq!(u64_to_words(20), "twenty");
574        assert_eq!(u64_to_words(42), "forty-two");
575        assert_eq!(u64_to_words(99), "ninety-nine");
576    }
577
578    #[test]
579    fn number_to_words_hundreds() {
580        assert_eq!(u64_to_words(100), "one hundred");
581        assert_eq!(u64_to_words(123), "one hundred twenty-three");
582        assert_eq!(u64_to_words(500), "five hundred");
583    }
584
585    #[test]
586    fn number_to_words_thousands() {
587        assert_eq!(u64_to_words(1000), "one thousand");
588        assert_eq!(u64_to_words(1001), "one thousand one");
589        assert_eq!(
590            u64_to_words(12345),
591            "twelve thousand three hundred forty-five"
592        );
593    }
594
595    #[test]
596    fn number_to_words_millions() {
597        assert_eq!(u64_to_words(1_000_000), "one million");
598        assert_eq!(
599            u64_to_words(1_234_567),
600            "one million two hundred thirty-four thousand five hundred sixty-seven"
601        );
602    }
603
604    #[test]
605    fn number_to_words_large() {
606        let s = u64_to_words(1_000_000_000_000);
607        assert!(s.starts_with("one trillion"), "got: {s}");
608    }
609
610    #[test]
611    fn number_to_words_node() {
612        let node = NumberToWords::new();
613        let mut out = [Value::None];
614        node.eval(&[Value::U64(42)], &mut out);
615        assert_eq!(out[0].as_str(), "forty-two");
616    }
617
618    // --- StrConcat tests ---
619
620    #[test]
621    fn str_concat_basic() {
622        let node = StrConcat::new(2);
623        let mut out = [Value::None];
624        node.eval(
625            &[Value::Str("hello ".into()), Value::Str("world".into())],
626            &mut out,
627        );
628        assert_eq!(out[0].as_str(), "hello world");
629    }
630
631    #[test]
632    fn str_concat_renders_extension_values_by_display() {
633        #[derive(Debug, Clone)]
634        struct Tag(u64);
635        impl polydat::ast::ReflectedValue for Tag {
636            fn type_name(&self) -> &str {
637                "Tag"
638            }
639            fn display(&self) -> String {
640                format!("tag#{}", self.0)
641            }
642            fn clone_reflected(&self) -> Box<dyn polydat::ast::ReflectedValue> {
643                Box::new(self.clone())
644            }
645            fn as_any(&self) -> &dyn std::any::Any {
646                self
647            }
648        }
649        let node = StrConcat::new(2);
650        let mut out = [Value::None];
651        node.eval(
652            &[Value::Str("x".into()), Value::Ext(Box::new(Tag(7)))],
653            &mut out,
654        );
655        assert_eq!(out[0].as_str(), "xtag#7");
656    }
657
658    #[test]
659    fn str_concat_mixed_types() {
660        let node = StrConcat::new(4);
661        let mut out = [Value::None];
662        node.eval(
663            &[
664                Value::Str("id=".into()),
665                Value::U64(42),
666                Value::Str(" v=".into()),
667                Value::F64(3.14),
668            ],
669            &mut out,
670        );
671        assert_eq!(out[0].as_str(), "id=42 v=3.14");
672    }
673
674    #[test]
675    fn str_concat_empty() {
676        let node = StrConcat::new(0);
677        let mut out = [Value::None];
678        node.eval(&[], &mut out);
679        assert_eq!(out[0].as_str(), "");
680    }
681
682    #[test]
683    fn str_lower_ascii_and_unicode() {
684        let node = StrLower::new();
685        let mut out = [Value::None];
686        node.eval(&[Value::Str("OTHER_M8".into())], &mut out);
687        assert_eq!(out[0].as_str(), "other_m8");
688        // Unicode folding (Rust's str::to_lowercase is full Unicode).
689        node.eval(&[Value::Str("ÄPFEL".into())], &mut out);
690        assert_eq!(out[0].as_str(), "äpfel");
691    }
692
693    #[test]
694    fn str_lower_idempotent_on_already_lowercase() {
695        let node = StrLower::new();
696        let mut out = [Value::None];
697        node.eval(&[Value::Str("fknn_oat_other".into())], &mut out);
698        assert_eq!(out[0].as_str(), "fknn_oat_other");
699    }
700
701    #[test]
702    fn str_upper_ascii_and_unicode() {
703        let node = StrUpper::new();
704        let mut out = [Value::None];
705        node.eval(&[Value::Str("other_m8".into())], &mut out);
706        assert_eq!(out[0].as_str(), "OTHER_M8");
707        node.eval(&[Value::Str("äpfel".into())], &mut out);
708        assert_eq!(out[0].as_str(), "ÄPFEL");
709    }
710
711    // `str_lower` does not display-coerce a non-string: `Wire` trait
712    // dispatch panics on a shape mismatch. Workload-level support for
713    // chained `str_lower(format_u64(...))` flows through assembler-
714    // inserted Str adapters (e.g. polyfill U64ToStr), not through
715    // a lying Wire impl. Tests that want to exercise the coercion
716    // path should construct a Str via `format_u64` upstream and
717    // feed that into str_lower's eval.
718}