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: P1 (String output; no compiled_u64 path).
29/// Derived state for `combinations`. Computed once per node
30/// instance via `from_pattern`; the macro stores the instance in a
31/// struct field and hands the eval body a `&ParsedCombinations`
32/// borrow each call.
33pub struct ParsedCombinations {
34    /// The segments, in output order.
35    pub segments: Vec<Segment>,
36    /// The product of the charset sizes: the distinct combinations.
37    pub modulus: u64,
38}
39
40impl polydat::derive_support::PolydatSetup for ParsedCombinations {}
41
42/// One piece of a combinations pattern.
43pub enum Segment {
44    /// Variable: select one char from the charset based on a radix digit.
45    Charset(Vec<char>),
46    /// Fixed: always emit this string (e.g., a literal separator).
47    Literal(String),
48}
49
50impl ParsedCombinations {
51    /// Single-call setup. The `#[polydat_node]` macro invokes
52    /// this exactly once in the generated `Combinations::new()`;
53    /// no other call path exists.
54    pub fn from_pattern(pattern: &str) -> Self {
55        let mut segments = Vec::new();
56        let mut modulus: u64 = 1;
57        for spec in pattern.split(';') {
58            let chars = parse_charset(spec);
59            if chars.len() == 1 && !spec.contains('-') {
60                segments.push(Segment::Literal(chars[0].to_string()));
61            } else if chars.is_empty() {
62                segments.push(Segment::Literal(spec.to_string()));
63            } else {
64                modulus = modulus.saturating_mul(chars.len() as u64);
65                segments.push(Segment::Charset(chars));
66            }
67        }
68        Self { segments, modulus }
69    }
70}
71
72/// Map a u64 input to a deterministic string by interpreting
73/// it as a multi-positional choice over the pattern's charsets.
74#[polydat::polydat_node(category = String)]
75fn combinations(
76    input: u64,
77    pattern: polydat::derive_support::Const<&str>,
78    #[poly_const(ParsedCombinations::from_pattern, from = pattern)] parsed: &ParsedCombinations,
79) -> String {
80    let mut remainder = if parsed.modulus > 0 {
81        input % parsed.modulus
82    } else {
83        input
84    };
85    let mut result = String::with_capacity(parsed.segments.len() * 2);
86    for seg in &parsed.segments {
87        match seg {
88            Segment::Literal(s) => result.push_str(s),
89            Segment::Charset(chars) => {
90                let radix = chars.len() as u64;
91                if radix > 0 {
92                    let idx = (remainder % radix) as usize;
93                    result.push(chars[idx]);
94                    remainder /= radix;
95                }
96            }
97        }
98    }
99    result
100}
101
102impl Combinations {
103    /// The total number of unique combinations before wrapping.
104    pub fn cardinality(&self) -> u64 {
105        self.parsed.modulus
106    }
107}
108
109/// Parse a charset spec like "A-Z", "0-9", "a-z0-9", "A-Za-z0-9 _|/"
110fn parse_charset(spec: &str) -> Vec<char> {
111    let mut chars = Vec::new();
112    let spec_chars: Vec<char> = spec.chars().collect();
113    let mut i = 0;
114    while i < spec_chars.len() {
115        if i + 2 < spec_chars.len() && spec_chars[i + 1] == '-' {
116            // Range: A-Z, 0-9, etc.
117            let start = spec_chars[i];
118            let end = spec_chars[i + 2];
119            for c in start..=end {
120                chars.push(c);
121            }
122            i += 3;
123        } else {
124            chars.push(spec_chars[i]);
125            i += 1;
126        }
127    }
128    chars
129}
130
131// =================================================================
132// NumberToWords: spell out numbers in English
133// =================================================================
134
135/// Convert a u64 to its English word representation.
136///
137/// Signature: `number_to_words(input: u64) -> (String)`
138///
139/// Examples: 0 produces "zero", 42 produces "forty-two", 1000
140/// produces "one thousand". Supports the full u64 range up through
141/// quintillions.
142///
143/// JIT level: P1 (String output; no compiled_u64 path).
144#[polydat::polydat_node(category = String)]
145fn number_to_words(input: u64) -> String {
146    u64_to_words(input)
147}
148
149const ONES: [&str; 20] = [
150    "zero",
151    "one",
152    "two",
153    "three",
154    "four",
155    "five",
156    "six",
157    "seven",
158    "eight",
159    "nine",
160    "ten",
161    "eleven",
162    "twelve",
163    "thirteen",
164    "fourteen",
165    "fifteen",
166    "sixteen",
167    "seventeen",
168    "eighteen",
169    "nineteen",
170];
171
172const TENS: [&str; 10] = [
173    "", "", "twenty", "thirty", "forty", "fifty", "sixty", "seventy", "eighty", "ninety",
174];
175
176const SCALES: [&str; 7] = [
177    "",
178    "thousand",
179    "million",
180    "billion",
181    "trillion",
182    "quadrillion",
183    "quintillion",
184];
185
186fn u64_to_words(n: u64) -> String {
187    if n < 20 {
188        return ONES[n as usize].to_string();
189    }
190
191    let mut buf = String::with_capacity(64);
192    let mut chunks = [0u32; 7];
193    let mut num_chunks = 0;
194    let mut remaining = n;
195
196    while remaining > 0 {
197        chunks[num_chunks] = (remaining % 1000) as u32;
198        num_chunks += 1;
199        remaining /= 1000;
200    }
201
202    let mut first = true;
203    for i in (0..num_chunks).rev() {
204        let chunk = chunks[i];
205        if chunk > 0 {
206            if !first {
207                buf.push(' ');
208            }
209            first = false;
210            append_chunk_to_words(&mut buf, chunk);
211            if i > 0 && i < SCALES.len() {
212                buf.push(' ');
213                buf.push_str(SCALES[i]);
214            }
215        }
216    }
217
218    buf
219}
220
221fn append_chunk_to_words(buf: &mut String, n: u32) {
222    let hundreds = n / 100;
223    let remainder = n % 100;
224
225    let mut has_hundreds = false;
226    if hundreds > 0 {
227        buf.push_str(ONES[hundreds as usize]);
228        buf.push_str(" hundred");
229        has_hundreds = true;
230    }
231
232    if remainder >= 20 {
233        if has_hundreds {
234            buf.push(' ');
235        }
236        let tens = remainder / 10;
237        let ones = remainder % 10;
238        buf.push_str(TENS[tens as usize]);
239        if ones > 0 {
240            buf.push('-');
241            buf.push_str(ONES[ones as usize]);
242        }
243    } else if remainder > 0 {
244        if has_hundreds {
245            buf.push(' ');
246        }
247        buf.push_str(ONES[remainder as usize]);
248    }
249}
250
251// =================================================================
252// HashedUuid: deterministic UUID v4 from a u64 seed
253// =================================================================
254
255/// Generate a deterministic UUID v4 string from a u64 seed.
256/// Same seed always produces the same UUID; the hash output
257/// fills the 128 UUID bits with version (4) and variant
258/// (RFC 4122) bits set per spec.
259///
260/// Signature: `hashed_uuid(input: u64) -> (String)`
261#[polydat::polydat_node(category = String)]
262fn hashed_uuid(input: u64) -> String {
263    // Two hashes fill 128 bits.
264    let h1 = xxhash_rust::xxh3::xxh3_64(&input.to_le_bytes());
265    let h2 = xxhash_rust::xxh3::xxh3_64(&h1.to_le_bytes());
266    let mut bytes = [0u8; 16];
267    bytes[..8].copy_from_slice(&h1.to_le_bytes());
268    bytes[8..].copy_from_slice(&h2.to_le_bytes());
269    // Version 4 (bits 12-15 of byte 6).
270    bytes[6] = (bytes[6] & 0x0F) | 0x40;
271    // Variant RFC 4122 (bits 6-7 of byte 8).
272    bytes[8] = (bytes[8] & 0x3F) | 0x80;
273    format!(
274        "{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}",
275        bytes[0],
276        bytes[1],
277        bytes[2],
278        bytes[3],
279        bytes[4],
280        bytes[5],
281        bytes[6],
282        bytes[7],
283        bytes[8],
284        bytes[9],
285        bytes[10],
286        bytes[11],
287        bytes[12],
288        bytes[13],
289        bytes[14],
290        bytes[15],
291    )
292}
293
294// =================================================================
295// CharBuf: deterministic string from seed + charset + length
296// =================================================================
297
298/// Generate a deterministic string of a given length from a seed
299/// and character set.
300///
301/// The seed is hashed and used to index into the charset repeatedly.
302/// Same seed + charset + length always produces the same string.
303///
304/// Signature: `char_buf(seed: u64, charset: &str, length: u64) -> (String)`
305/// Expand a charset spec like "A-Za-z0-9" into a Vec<char>.
306/// Setup helper for `char_buf`.
307fn expand_charset(charset: &str) -> Vec<char> {
308    if charset.is_empty() {
309        return ('a'..='z').collect();
310    }
311    let mut result = Vec::new();
312    let chars_vec: Vec<char> = charset.chars().collect();
313    let mut i = 0;
314    while i < chars_vec.len() {
315        if i + 2 < chars_vec.len() && chars_vec[i + 1] == '-' {
316            for c in chars_vec[i]..=chars_vec[i + 2] {
317                result.push(c);
318            }
319            i += 3;
320        } else {
321            result.push(chars_vec[i]);
322            i += 1;
323        }
324    }
325    if result.is_empty() {
326        ('a'..='z').collect()
327    } else {
328        result
329    }
330}
331
332/// Generate a deterministic string of a given length from a
333/// seed and character set.
334#[polydat::polydat_node(category = String)]
335fn char_buf(
336    seed: u64,
337    charset: polydat::derive_support::Const<&str>,
338    length: u64,
339    #[poly_const(expand_charset, from = charset)] chars: &Vec<char>,
340) -> String {
341    let n = chars.len();
342    let len = length as usize;
343    if n == 0 || len == 0 {
344        return String::new();
345    }
346    let mut result = String::with_capacity(len);
347    let mut h = seed;
348    for _ in 0..len {
349        h = xxhash_rust::xxh3::xxh3_64(&h.to_le_bytes());
350        result.push(chars[(h as usize) % n]);
351    }
352    result
353}
354
355// =================================================================
356// FileLineAt: index into a file of lines at cycle time
357// =================================================================
358
359/// Read `filename` and split it into lines. Panics on file
360/// I/O failure; the macro's build-closure `catch_unwind`
361/// surfaces it as a clean compile error.
362fn read_file_lines(filename: &str) -> Vec<String> {
363    let content = std::fs::read_to_string(filename)
364        .unwrap_or_else(|e| panic!("failed to read file '{filename}': {e}"));
365    let lines: Vec<String> = content.lines().map(|l| l.to_string()).collect();
366    if lines.is_empty() {
367        panic!("file '{filename}' has no lines");
368    }
369    lines
370}
371
372/// Cycle-time line lookup over a pre-loaded text file. `filename`
373/// is read at construction time via `#[poly_const]`; the cycle
374/// input selects a line modulo the total count.
375#[polydat::polydat_node(category = String)]
376fn file_line_at(
377    index: u64,
378    filename: polydat::derive_support::Const<&str>,
379    #[poly_const(read_file_lines, from = filename)] lines: &Vec<String>,
380) -> String {
381    let _ = filename;
382    let idx = index as usize;
383    lines[idx % lines.len()].clone()
384}
385
386// =================================================================
387// StrConcat: variadic string concatenation
388// =================================================================
389
390/// Concatenate N wire inputs into a single Str output.
391///
392/// Each input is rendered to its display form: Str passes through,
393/// numerics format as decimal, Bool as `true`/`false`, Json via
394/// `to_string`. Mixed-type inputs are accepted — the assembler skips
395/// type checking for str_concat (like printf), so any upstream wire
396/// type composes.
397///
398/// Used by the DSL desugar of `+` between Str-typed operands; also
399/// callable directly as `str_concat(a, b, c, ...)`.
400///
401/// Signature: `str_concat(in_0, in_1, ...) -> (String)`
402/// Concatenate N values, rendering each as its display form.
403/// Variadic over `&[Value]`: the body stringifies
404/// per element so mixed-type inputs (Str + U64 + Bool, etc.)
405/// produce a single concatenated string; this matches the
406/// DSL's lowering of `+` between Str-typed operands.
407#[polydat::polydat_node(category = String)]
408fn str_concat(parts: &[polydat::ast::Value]) -> String {
409    use polydat::ast::Value;
410    let mut out = String::new();
411    for v in parts {
412        match v {
413            Value::Str(s) => out.push_str(s),
414            Value::U64(n) => out.push_str(&n.to_string()),
415            Value::F64(n) => out.push_str(&n.to_string()),
416            Value::Bool(b) => out.push_str(&b.to_string()),
417            Value::Json(j) => out.push_str(&j.to_string()),
418            Value::Bytes(b) => out.push_str(&String::from_utf8_lossy(b)),
419            // Everything else, extension values included, renders as it
420            // does in interpolation and `printf`: its display form.
421            other => out.push_str(&other.to_display_string()),
422        }
423    }
424    out
425}
426
427// =================================================================
428// StrLower / StrUpper: Unicode case-folding helpers
429// =================================================================
430
431/// Fold a string to lowercase (`str.to_lowercase()` semantics).
432///
433/// Signature: `str_lower(input: Str) -> (Str)`
434///
435/// Takes `String` (not `&str`) so `FromValue<String>` honors the legacy
436/// "stringify any input via `to_display_string`" behavior;
437/// switching to `&str` would tighten this to require Str
438/// inputs only, which the type-checker doesn't yet enforce
439/// (deferred to SRD-79's type-driven resolution).
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}