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    let len = length as usize;
345    if n == 0 || len == 0 {
346        return String::new();
347    }
348    let mut result = String::with_capacity(len);
349    let mut h = seed;
350    for _ in 0..len {
351        h = xxhash_rust::xxh3::xxh3_64(&h.to_le_bytes());
352        result.push(chars[(h as usize) % n]);
353    }
354    result
355}
356
357// =================================================================
358// FileLineAt: index into a file of lines at cycle time
359// =================================================================
360
361/// Read `filename` and split it into lines. Panics on file
362/// I/O failure; the macro's build-closure `catch_unwind`
363/// surfaces it as a clean compile error.
364fn read_file_lines(filename: &str) -> Vec<String> {
365    let content = std::fs::read_to_string(filename)
366        .unwrap_or_else(|e| panic!("failed to read file '{filename}': {e}"));
367    let lines: Vec<String> = content.lines().map(|l| l.to_string()).collect();
368    if lines.is_empty() {
369        panic!("file '{filename}' has no lines");
370    }
371    lines
372}
373
374/// Cycle-time line lookup over a pre-loaded text file. `filename`
375/// is read at construction time via `#[poly_const]`; the cycle
376/// input selects a line modulo the total count.
377#[polydat::polydat_node(category = String)]
378fn file_line_at(
379    index: u64,
380    filename: polydat::derive_support::Const<&str>,
381    #[poly_const(read_file_lines, from = filename)] lines: &Vec<String>,
382) -> String {
383    let _ = filename;
384    let idx = index as usize;
385    lines[idx % lines.len()].clone()
386}
387
388// =================================================================
389// StrConcat: variadic string concatenation
390// =================================================================
391
392/// Concatenate N wire inputs into a single Str output.
393///
394/// Each input is rendered to its display form: Str passes through,
395/// numerics format as decimal, Bool as `true`/`false`, Json via
396/// `to_string`. Mixed-type inputs are accepted — the assembler skips
397/// type checking for str_concat (like printf), so any upstream wire
398/// type composes.
399///
400/// Used by the DSL desugar of `+` between Str-typed operands; also
401/// callable directly as `str_concat(a, b, c, ...)`.
402///
403/// Signature: `str_concat(in_0, in_1, ...) -> (String)`
404/// Concatenate N values, rendering each as its display form.
405/// Variadic over `&[Value]`: the body stringifies
406/// per element so mixed-type inputs (Str + U64 + Bool, etc.)
407/// produce a single concatenated string; this matches the
408/// DSL's lowering of `+` between Str-typed operands.
409#[polydat::polydat_node(category = String)]
410fn str_concat(parts: &[polydat::ast::Value]) -> String {
411    use polydat::ast::Value;
412    let mut out = String::new();
413    for v in parts {
414        match v {
415            Value::Str(s) => out.push_str(s),
416            Value::U64(n) => out.push_str(&n.to_string()),
417            Value::F64(n) => out.push_str(&n.to_string()),
418            Value::Bool(b) => out.push_str(&b.to_string()),
419            Value::Json(j) => out.push_str(&j.to_string()),
420            Value::Bytes(b) => out.push_str(&String::from_utf8_lossy(b)),
421            // Everything else, extension values included, renders as it
422            // does in interpolation and `printf`: its display form.
423            other => out.push_str(&other.to_display_string()),
424        }
425    }
426    out
427}
428
429// =================================================================
430// StrLower / StrUpper: Unicode case-folding helpers
431// =================================================================
432
433/// Fold a string to lowercase (`str.to_lowercase()` semantics).
434///
435/// Signature: `str_lower(input: Str) -> (Str)`
436///
437/// Takes `String`; non-Str upstream wires reach it through
438/// assembler-inserted Str adapters (e.g. polyfill U64ToStr), not
439/// through value coercion.
440#[polydat::polydat_node(category = String)]
441fn str_lower(input: String) -> String {
442    input.to_lowercase()
443}
444
445/// Fold a string to uppercase (`str.to_uppercase()` semantics).
446///
447/// Signature: `str_upper(input: Str) -> (Str)`
448#[polydat::polydat_node(category = String)]
449fn str_upper(input: String) -> String {
450    input.to_uppercase()
451}
452
453#[cfg(test)]
454mod tests {
455    use super::*;
456    use polydat::ast::{PolydatNode, Value};
457
458    // --- Combinations tests ---
459
460    #[test]
461    fn combinations_digits() {
462        let node = Combinations::new("0-9;0-9;0-9".to_string());
463        let mut out = [Value::None];
464        node.eval(&[Value::U64(123)], &mut out);
465        let s = out[0].as_str();
466        assert_eq!(s.len(), 3);
467        assert!(s.chars().all(|c| c.is_ascii_digit()));
468    }
469
470    #[test]
471    fn combinations_with_separator() {
472        let node = Combinations::new("0-9;0-9;0-9;-;0-9;0-9;0-9".to_string());
473        let mut out = [Value::None];
474        node.eval(&[Value::U64(0)], &mut out);
475        let s = out[0].as_str();
476        assert_eq!(s.len(), 7); // 3 digits + dash + 3 digits
477        assert_eq!(&s[3..4], "-");
478    }
479
480    #[test]
481    fn combinations_alpha() {
482        let node = Combinations::new("A-Z;A-Z;A-Z".to_string());
483        let mut out = [Value::None];
484        node.eval(&[Value::U64(0)], &mut out);
485        assert_eq!(out[0].as_str(), "AAA");
486        node.eval(&[Value::U64(1)], &mut out);
487        assert_eq!(out[0].as_str(), "BAA");
488    }
489
490    #[test]
491    fn combinations_cardinality() {
492        let node = Combinations::new("0-9;0-9;-;A-Z".to_string());
493        // 10 * 10 * 26 = 2600 (separator doesn't count)
494        assert_eq!(node.cardinality(), 2600);
495    }
496
497    #[test]
498    fn combinations_deterministic() {
499        let node = Combinations::new("A-Z;0-9".to_string());
500        let mut out1 = [Value::None];
501        let mut out2 = [Value::None];
502        node.eval(&[Value::U64(42)], &mut out1);
503        node.eval(&[Value::U64(42)], &mut out2);
504        assert_eq!(out1[0].as_str(), out2[0].as_str());
505    }
506
507    #[test]
508    fn combinations_wraps() {
509        let node = Combinations::new("0-9".to_string());
510        let mut out = [Value::None];
511        node.eval(&[Value::U64(0)], &mut out);
512        let a = out[0].as_str().to_string();
513        node.eval(&[Value::U64(10)], &mut out);
514        assert_eq!(out[0].as_str(), &a, "should wrap at cardinality");
515    }
516
517    // --- NumberToWords tests ---
518
519    #[test]
520    fn number_to_words_zero() {
521        assert_eq!(u64_to_words(0), "zero");
522    }
523
524    #[test]
525    fn number_to_words_teens() {
526        assert_eq!(u64_to_words(1), "one");
527        assert_eq!(u64_to_words(11), "eleven");
528        assert_eq!(u64_to_words(19), "nineteen");
529    }
530
531    #[test]
532    fn number_to_words_tens() {
533        assert_eq!(u64_to_words(20), "twenty");
534        assert_eq!(u64_to_words(42), "forty-two");
535        assert_eq!(u64_to_words(99), "ninety-nine");
536    }
537
538    #[test]
539    fn number_to_words_hundreds() {
540        assert_eq!(u64_to_words(100), "one hundred");
541        assert_eq!(u64_to_words(123), "one hundred twenty-three");
542        assert_eq!(u64_to_words(500), "five hundred");
543    }
544
545    #[test]
546    fn number_to_words_thousands() {
547        assert_eq!(u64_to_words(1000), "one thousand");
548        assert_eq!(u64_to_words(1001), "one thousand one");
549        assert_eq!(
550            u64_to_words(12345),
551            "twelve thousand three hundred forty-five"
552        );
553    }
554
555    #[test]
556    fn number_to_words_millions() {
557        assert_eq!(u64_to_words(1_000_000), "one million");
558        assert_eq!(
559            u64_to_words(1_234_567),
560            "one million two hundred thirty-four thousand five hundred sixty-seven"
561        );
562    }
563
564    #[test]
565    fn number_to_words_large() {
566        let s = u64_to_words(1_000_000_000_000);
567        assert!(s.starts_with("one trillion"), "got: {s}");
568    }
569
570    #[test]
571    fn number_to_words_node() {
572        let node = NumberToWords::new();
573        let mut out = [Value::None];
574        node.eval(&[Value::U64(42)], &mut out);
575        assert_eq!(out[0].as_str(), "forty-two");
576    }
577
578    // --- StrConcat tests ---
579
580    #[test]
581    fn str_concat_basic() {
582        let node = StrConcat::new(2);
583        let mut out = [Value::None];
584        node.eval(
585            &[Value::Str("hello ".into()), Value::Str("world".into())],
586            &mut out,
587        );
588        assert_eq!(out[0].as_str(), "hello world");
589    }
590
591    #[test]
592    fn str_concat_renders_extension_values_by_display() {
593        #[derive(Debug, Clone)]
594        struct Tag(u64);
595        impl polydat::ast::ReflectedValue for Tag {
596            fn type_name(&self) -> &str {
597                "Tag"
598            }
599            fn display(&self) -> String {
600                format!("tag#{}", self.0)
601            }
602            fn clone_reflected(&self) -> Box<dyn polydat::ast::ReflectedValue> {
603                Box::new(self.clone())
604            }
605            fn as_any(&self) -> &dyn std::any::Any {
606                self
607            }
608        }
609        let node = StrConcat::new(2);
610        let mut out = [Value::None];
611        node.eval(
612            &[Value::Str("x".into()), Value::Ext(Box::new(Tag(7)))],
613            &mut out,
614        );
615        assert_eq!(out[0].as_str(), "xtag#7");
616    }
617
618    #[test]
619    fn str_concat_mixed_types() {
620        let node = StrConcat::new(4);
621        let mut out = [Value::None];
622        node.eval(
623            &[
624                Value::Str("id=".into()),
625                Value::U64(42),
626                Value::Str(" v=".into()),
627                Value::F64(3.14),
628            ],
629            &mut out,
630        );
631        assert_eq!(out[0].as_str(), "id=42 v=3.14");
632    }
633
634    #[test]
635    fn str_concat_empty() {
636        let node = StrConcat::new(0);
637        let mut out = [Value::None];
638        node.eval(&[], &mut out);
639        assert_eq!(out[0].as_str(), "");
640    }
641
642    #[test]
643    fn str_lower_ascii_and_unicode() {
644        let node = StrLower::new();
645        let mut out = [Value::None];
646        node.eval(&[Value::Str("OTHER_M8".into())], &mut out);
647        assert_eq!(out[0].as_str(), "other_m8");
648        // Unicode folding (Rust's str::to_lowercase is full Unicode).
649        node.eval(&[Value::Str("ÄPFEL".into())], &mut out);
650        assert_eq!(out[0].as_str(), "äpfel");
651    }
652
653    #[test]
654    fn str_lower_idempotent_on_already_lowercase() {
655        let node = StrLower::new();
656        let mut out = [Value::None];
657        node.eval(&[Value::Str("fknn_oat_other".into())], &mut out);
658        assert_eq!(out[0].as_str(), "fknn_oat_other");
659    }
660
661    #[test]
662    fn str_upper_ascii_and_unicode() {
663        let node = StrUpper::new();
664        let mut out = [Value::None];
665        node.eval(&[Value::Str("other_m8".into())], &mut out);
666        assert_eq!(out[0].as_str(), "OTHER_M8");
667        node.eval(&[Value::Str("äpfel".into())], &mut out);
668        assert_eq!(out[0].as_str(), "ÄPFEL");
669    }
670
671    // `str_lower_accepts_non_string_via_display` retired: SRD-80b
672    // Wire trait dispatch panics on shape mismatch instead of
673    // silently display-coercing. Workload-level support for
674    // chained `str_lower(format_u64(...))` flows through assembler-
675    // inserted Str adapters (e.g. polyfill U64ToStr), not through
676    // a lying Wire impl. Tests that want to exercise the coercion
677    // path should construct a Str via `format_u64` upstream and
678    // feed that into str_lower's eval.
679}