Skip to main content

decimal_scaled_macros/
lib.rs

1//! Construction macros for `decimal-scaled`.
2//!
3//! See `macros/README.md` for the full spec. This crate now ships:
4//!
5//! - `d9!(…)`, `d18!(…)`, `d38!(…)` — narrow-tier entry points
6//!   (i32 / i64 / i128 storage).
7//! - `d76!(…)`, `d153!(…)`, `d307!(…)` — wide-tier entry points
8//!   (Int256 / Int512 / Int1024 storage). Available when the
9//!   parent crate's `d76` / `d153` / `d307` (or umbrella `wide` /
10//!   `x-wide`) feature is on.
11//! - Per-scale wrappers `d9s2!`, `d38s12!`, etc. live in the parent
12//!   crate as `macro_rules!` declarations — they pre-bake `scale N`
13//!   and forward to the proc-macro.
14//!
15//! Argument grammar (each entry point accepts the same shape):
16//!
17//! - `dN!(literal)` — scale inferred from the literal's fractional
18//!   digit count.
19//! - `dN!(literal, scale N)` — explicit target scale.
20//! - `dN!(literal, rounded)` — opt into half-to-even rounding when
21//!   the literal carries more fractional digits than the target.
22//! - `dN!(0x… | 0o… | 0b…)` — Rust radix-prefix integer literals.
23//!   Equivalent to passing `radix 16 / 8 / 2`; scale defaults to 0.
24//! - `dN!(literal, radix R)` — accepts `R ∈ {2, 8, 10, 16}` and
25//!   reinterprets the digit characters in that base.
26//! - `dN!(expr, scale N)` — inline expression form (runtime scale-up);
27//!   `scale N` is mandatory.
28
29use proc_macro::TokenStream;
30use proc_macro2::{Span, TokenStream as TokenStream2, TokenTree};
31use quote::quote;
32use syn::{
33    Expr, ExprLit, ExprUnary, Lit, Result, UnOp,
34};
35
36// ── Width descriptor ───────────────────────────────────────────────────
37
38/// One of the public decimal widths the macros can target. Each
39/// carries the per-width metadata the literal / expression paths
40/// need to emit correctly-typed bits.
41#[derive(Clone, Copy)]
42struct Width {
43    /// Macro name as seen by users (`d38`, `d76`, …). Used in error
44    /// messages.
45    name: &'static str,
46    /// `MAX_SCALE` for this width — the largest `SCALE` that fits
47    /// without overflowing storage.
48    max_scale: u32,
49    /// Crate-relative path to the type. Filled into the emitted
50    /// `::decimal_scaled::DXX::<SCALE>::from_bits(…)` expression.
51    type_path: &'static str,
52    /// Crate-relative path to the underlying signed integer (used
53    /// for the inline-expression form's `let _v: …` type anchor).
54    storage_path: &'static str,
55    /// `true` for D76 / D153 / D307 (hand-rolled wide integer
56    /// storage). Drives the emit-via-`from_str_radix` path.
57    wide: bool,
58}
59
60const D9: Width = Width {
61    name: "d9",
62    max_scale: 9,
63    type_path: "::decimal_scaled::D9",
64    storage_path: "i32",
65    wide: false,
66};
67const D18: Width = Width {
68    name: "d18",
69    max_scale: 18,
70    type_path: "::decimal_scaled::D18",
71    storage_path: "i64",
72    wide: false,
73};
74const D38: Width = Width {
75    name: "d38",
76    max_scale: 38,
77    type_path: "::decimal_scaled::D38",
78    storage_path: "i128",
79    wide: false,
80};
81const D76: Width = Width {
82    name: "d76",
83    max_scale: 76,
84    type_path: "::decimal_scaled::D76",
85    storage_path: "::decimal_scaled::Int256",
86    wide: true,
87};
88const D153: Width = Width {
89    name: "d153",
90    max_scale: 153,
91    type_path: "::decimal_scaled::D153",
92    storage_path: "::decimal_scaled::Int512",
93    wide: true,
94};
95const D307: Width = Width {
96    name: "d307",
97    max_scale: 307,
98    type_path: "::decimal_scaled::D307",
99    storage_path: "::decimal_scaled::Int1024",
100    wide: true,
101};
102
103// ── Public proc-macro entry points ────────────────────────────────────
104
105/// `d9!` — construct a `decimal_scaled::D9<SCALE>` value at
106/// compile time. See the crate-level docs and `macros/README.md`.
107#[proc_macro]
108pub fn d9(input: TokenStream) -> TokenStream {
109    expand_for(D9, input)
110}
111
112/// `d18!` — construct a `decimal_scaled::D18<SCALE>` value at
113/// compile time. See the crate-level docs and `macros/README.md`.
114#[proc_macro]
115pub fn d18(input: TokenStream) -> TokenStream {
116    expand_for(D18, input)
117}
118
119/// `d38!` — construct a `decimal_scaled::D38<SCALE>` value at
120/// compile time. See the crate-level docs and `macros/README.md`.
121#[proc_macro]
122pub fn d38(input: TokenStream) -> TokenStream {
123    expand_for(D38, input)
124}
125
126/// `d76!` — construct a `decimal_scaled::D76<SCALE>` value at
127/// compile time. Requires the parent crate's `d76` / `wide` feature.
128#[proc_macro]
129pub fn d76(input: TokenStream) -> TokenStream {
130    expand_for(D76, input)
131}
132
133/// `d153!` — construct a `decimal_scaled::D153<SCALE>` value at
134/// compile time. Requires the parent crate's `d153` / `wide` feature.
135#[proc_macro]
136pub fn d153(input: TokenStream) -> TokenStream {
137    expand_for(D153, input)
138}
139
140/// `d307!` — construct a `decimal_scaled::D307<SCALE>` value at
141/// compile time. Requires the parent crate's `d307` / `x-wide`
142/// feature.
143#[proc_macro]
144pub fn d307(input: TokenStream) -> TokenStream {
145    expand_for(D307, input)
146}
147
148fn expand_for(width: Width, input: TokenStream) -> TokenStream {
149    // Convert to proc_macro2::TokenStream so we can manipulate
150    // token trees directly. Rust's lexer won't accept
151    // `1.A3` as a single token, so we have to do our own
152    // splitting for the radix-fractional case.
153    let tokens: TokenStream2 = input.into();
154    match parse_invocation(tokens, width) {
155        Ok(inv) => inv.expand(),
156        Err(e) => e.into_compile_error().into(),
157    }
158}
159
160/// Split the input on top-level commas, scan qualifier segments
161/// to find any `radix N`, then dispatch the value segment to the
162/// right parser. The radix-fractional path (`1.A3, radix 16`)
163/// goes through a custom token walker because the value position
164/// isn't valid Rust syntax. Decimal / radix-prefixed / expression
165/// shapes go through the standard Rust-Expr path.
166fn parse_invocation(tokens: TokenStream2, width: Width) -> Result<Invocation> {
167    let segments = split_top_commas(tokens);
168    if segments.is_empty() || segments[0].is_empty() {
169        return Err(syn::Error::new(
170            Span::call_site(),
171            format!("{}!() requires a value argument", width.name),
172        ));
173    }
174
175    // Quick pre-scan over qualifier segments: did the user pass
176    // an explicit `radix N`? We need that to decide which value
177    // parser to use, since `radix 16` lets `1.A3` be a literal.
178    let mut explicit_radix: Option<(u32, Span)> = None;
179    for seg in &segments[1..] {
180        if let Some((kw, _)) = seg.first().zip(seg.get(1)) {
181            if let TokenTree::Ident(id) = kw {
182                if id.to_string() == "radix" {
183                    if let TokenTree::Literal(lit) = &seg[1] {
184                        if let Ok(r) = lit.to_string().parse::<u32>() {
185                            explicit_radix = Some((r, lit.span()));
186                        }
187                    }
188                }
189            }
190        }
191    }
192
193    // Parse the value segment. The custom radix-fractional walker
194    // only fires when the user passed a non-decimal `radix N`
195    // *and* the segment doesn't already parse as a Rust Expr.
196    let value_segment = &segments[0];
197    let value_parse = try_radix_fractional(value_segment, explicit_radix)?;
198
199    if let Some((digits, sign, natural_scale, value_span)) = value_parse {
200        // Custom radix-fractional path. We've already established
201        // a non-decimal radix, so parse the qualifiers normally and
202        // skip pick_radix.
203        let (scale_qualifier, _radix_q, rounded) =
204            parse_qualifier_segments(&segments[1..], width)?;
205        return Ok(Invocation::Literal {
206            width,
207            digits,
208            sign,
209            natural_scale,
210            scale_qualifier,
211            rounded,
212            radix_literal: true,
213            value_span,
214        });
215    }
216
217    // Standard Rust-Expr path. Re-assemble the value tokens for the
218    // syn Expr parser.
219    let value_ts: TokenStream2 = value_segment.iter().cloned().collect();
220    let value_expr: Expr = syn::parse2(value_ts)?;
221    let value_span = expr_span(&value_expr);
222
223    let (scale_qualifier, radix_qualifier, rounded) =
224        parse_qualifier_segments(&segments[1..], width)?;
225
226    if let Some((sign, raw_str, lit_span)) = try_decimal_literal(&value_expr) {
227        let radix = pick_radix(&raw_str, radix_qualifier, lit_span)?;
228        let (digits, natural_scale) =
229            parse_value_token(&raw_str, lit_span, radix)?;
230        Ok(Invocation::Literal {
231            width,
232            digits,
233            sign,
234            natural_scale,
235            scale_qualifier,
236            rounded,
237            radix_literal: radix != 10,
238            value_span,
239        })
240    } else {
241        if let Some((_, radix_span)) = radix_qualifier {
242            return Err(syn::Error::new(
243                radix_span,
244                "`radix` qualifier is only valid with a literal value",
245            ));
246        }
247        let (scale, scale_span) = scale_qualifier.ok_or_else(|| {
248            syn::Error::new(
249                value_span,
250                format!(
251                    "scale must be specified for an expression value: `{}!(expr, scale N)`",
252                    width.name
253                ),
254            )
255        })?;
256        let _ = rounded;
257        Ok(Invocation::Expression {
258            width,
259            expr: value_expr,
260            scale,
261            scale_span,
262        })
263    }
264}
265
266/// Split a token stream on top-level commas (commas inside
267/// brackets / parens / braces stay with their content). Returns
268/// a vector of segments; each segment is a `Vec<TokenTree>`.
269fn split_top_commas(tokens: TokenStream2) -> Vec<Vec<TokenTree>> {
270    let mut out: Vec<Vec<TokenTree>> = vec![Vec::new()];
271    for tt in tokens {
272        match &tt {
273            TokenTree::Punct(p) if p.as_char() == ',' && p.spacing() == proc_macro2::Spacing::Alone => {
274                out.push(Vec::new());
275            }
276            _ => out.last_mut().unwrap().push(tt),
277        }
278    }
279    out
280}
281
282/// If the value segment looks like a radix-fractional literal
283/// (possibly sign-prefixed `INT . IDENT-or-INT`) *and* an explicit
284/// non-decimal radix was requested, return the parsed
285/// `(digit-string, sign, natural_scale, span)`. Returns `Ok(None)`
286/// to defer to the standard Rust-Expr parser when the shape
287/// doesn't match.
288fn try_radix_fractional(
289    segment: &[TokenTree],
290    explicit_radix: Option<(u32, Span)>,
291) -> Result<Option<(String, i128, u32, Span)>> {
292    let Some((radix, _radix_span)) = explicit_radix else {
293        return Ok(None);
294    };
295    if radix == 10 {
296        return Ok(None);
297    }
298
299    let mut i = 0;
300    let mut sign: i128 = 1;
301    if let Some(TokenTree::Punct(p)) = segment.first() {
302        if p.as_char() == '-' {
303            sign = -1;
304            i += 1;
305        } else if p.as_char() == '+' {
306            i += 1;
307        }
308    }
309
310    // After the optional sign, we accept any of:
311    //   single Float literal — e.g. `11.0110` (Rust tokenises this
312    //       as one Float token; we split on the embedded `.`)
313    //   `INT . IDENT` — e.g. `1.A3` in radix 16 (Rust can't lex
314    //       this as one token, so it arrives as three)
315    //   `INT . INT`   — same situation, e.g. `1.10` where the
316    //       fractional part happens to be digit-only
317    //   single INT    — pure integer in the given radix
318    let int_tok = segment.get(i);
319    let dot_tok = segment.get(i + 1);
320    let frac_tok = segment.get(i + 2);
321    let extra = segment.get(i + 3);
322
323    let int_lit = match int_tok {
324        Some(TokenTree::Literal(lit)) => lit.to_string(),
325        Some(TokenTree::Ident(id)) => id.to_string(),
326        _ => return Ok(None),
327    };
328
329    let (int_part, frac_part, span) = if int_lit.contains('.') && dot_tok.is_none() {
330        // Single literal that already contains the dot (`11.0110`).
331        let span = match int_tok.unwrap() {
332            TokenTree::Literal(lit) => lit.span(),
333            _ => Span::call_site(),
334        };
335        let (head, tail) = int_lit.split_once('.').unwrap();
336        (head.to_string(), tail.to_string(), span)
337    } else {
338        match (dot_tok, frac_tok, extra) {
339            (Some(TokenTree::Punct(p)), Some(frac), None) if p.as_char() == '.' => {
340                let frac_str = match frac {
341                    TokenTree::Literal(lit) => lit.to_string(),
342                    TokenTree::Ident(id) => id.to_string(),
343                    _ => return Ok(None),
344                };
345                let span = match int_tok.unwrap() {
346                    TokenTree::Literal(lit) => lit.span(),
347                    TokenTree::Ident(id) => id.span(),
348                    _ => Span::call_site(),
349                };
350                (int_lit, frac_str, span)
351            }
352            (None, None, _) => {
353                let span = match int_tok.unwrap() {
354                    TokenTree::Literal(lit) => lit.span(),
355                    TokenTree::Ident(id) => id.span(),
356                    _ => Span::call_site(),
357                };
358                (int_lit, String::new(), span)
359            }
360            _ => return Ok(None),
361        }
362    };
363
364    // Strip a Rust integer prefix (`0x`, `0o`, `0b`) on the
365    // integer part — it must match the explicit radix or it's an
366    // error. The fractional part doesn't carry a prefix.
367    let cleaned_int = if let Some((prefix_r, rest)) = strip_radix_prefix(&int_part) {
368        if prefix_r != radix {
369            return Err(syn::Error::new(
370                span,
371                format!(
372                    "radix qualifier ({radix}) disagrees with integer-part prefix (radix {prefix_r})"
373                ),
374            ));
375        }
376        rest.to_string()
377    } else {
378        int_part
379    };
380
381    let int_cleaned: String =
382        cleaned_int.chars().filter(|c| *c != '_').collect();
383    let frac_cleaned: String =
384        frac_part.chars().filter(|c| *c != '_').collect();
385
386    if int_cleaned.is_empty() {
387        return Err(syn::Error::new(
388            span,
389            "decimal literals require a digit on each side of the dot (write `0.A3` not `.A3`)",
390        ));
391    }
392    for c in int_cleaned.chars().chain(frac_cleaned.chars()) {
393        if !is_radix_digit(c, radix) {
394            return Err(syn::Error::new(
395                span,
396                format!("digit `{c}` not valid for radix {radix}"),
397            ));
398        }
399    }
400
401    // Concatenate int + frac digits, parse as a magnitude in `radix`.
402    // `natural_scale` is the number of fractional digits in the
403    // source — the user must still supply `, scale N` because the
404    // bits at the source's natural scale are not normally what they
405    // want as storage bits anyway.
406    let combined = format!("{int_cleaned}{frac_cleaned}");
407    let magnitude = match i128::from_str_radix(&combined, radix) {
408        Ok(v) => v,
409        Err(_) => {
410            return Err(syn::Error::new(
411                span,
412                format!(
413                    "digit string `{combined}` overflows i128 when parsed in radix {radix}"
414                ),
415            ));
416        }
417    };
418    Ok(Some((
419        magnitude.to_string(),
420        sign,
421        frac_cleaned.len() as u32,
422        span,
423    )))
424}
425
426fn is_radix_digit(c: char, radix: u32) -> bool {
427    c.to_digit(radix).is_some()
428}
429
430/// Re-parse the qualifier segments to find `scale N` / `radix N` /
431/// `rounded`. Equivalent to the old `parse_qualifiers` but works on
432/// token-vec segments instead of a `ParseStream`.
433fn parse_qualifier_segments(
434    segments: &[Vec<TokenTree>],
435    width: Width,
436) -> Result<(Option<(u32, Span)>, Option<(u32, Span)>, bool)> {
437    let _ = width;
438    let mut scale: Option<(u32, Span)> = None;
439    let mut radix: Option<(u32, Span)> = None;
440    let mut rounded = false;
441    for seg in segments {
442        if seg.is_empty() {
443            continue;
444        }
445        let TokenTree::Ident(kw) = &seg[0] else {
446            return Err(syn::Error::new(
447                tt_span(&seg[0]),
448                "expected qualifier identifier (scale | radix | rounded)",
449            ));
450        };
451        match kw.to_string().as_str() {
452            "scale" => {
453                let lit = seg.get(1).and_then(|t| match t {
454                    TokenTree::Literal(l) => Some(l),
455                    _ => None,
456                });
457                let lit = lit.ok_or_else(|| {
458                    syn::Error::new(kw.span(), "`scale` requires an integer literal: `scale N`")
459                })?;
460                let n: u32 = lit.to_string().parse().map_err(|_| {
461                    syn::Error::new(lit.span(), "scale must be a non-negative integer")
462                })?;
463                if scale.is_some() {
464                    return Err(syn::Error::new(kw.span(), "duplicate `scale` qualifier"));
465                }
466                scale = Some((n, lit.span()));
467            }
468            "radix" => {
469                let lit = seg.get(1).and_then(|t| match t {
470                    TokenTree::Literal(l) => Some(l),
471                    _ => None,
472                });
473                let lit = lit.ok_or_else(|| {
474                    syn::Error::new(kw.span(), "`radix` requires an integer literal: `radix N`")
475                })?;
476                let r: u32 = lit.to_string().parse().map_err(|_| {
477                    syn::Error::new(lit.span(), "radix must be one of 2, 8, 10, 16")
478                })?;
479                if !matches!(r, 2 | 8 | 10 | 16) {
480                    return Err(syn::Error::new(
481                        lit.span(),
482                        format!("radix must be one of 2, 8, 10, 16 (got {r})"),
483                    ));
484                }
485                if radix.is_some() {
486                    return Err(syn::Error::new(kw.span(), "duplicate `radix` qualifier"));
487                }
488                radix = Some((r, lit.span()));
489            }
490            "rounded" => {
491                if rounded {
492                    return Err(syn::Error::new(kw.span(), "duplicate `rounded` qualifier"));
493                }
494                if seg.len() > 1 {
495                    return Err(syn::Error::new(
496                        tt_span(&seg[1]),
497                        "`rounded` takes no argument",
498                    ));
499                }
500                rounded = true;
501            }
502            other => {
503                return Err(syn::Error::new(
504                    kw.span(),
505                    format!(
506                        "unknown qualifier `{other}`; expected one of: scale, radix, rounded"
507                    ),
508                ));
509            }
510        }
511    }
512    Ok((scale, radix, rounded))
513}
514
515fn tt_span(tt: &TokenTree) -> Span {
516    match tt {
517        TokenTree::Group(g) => g.span(),
518        TokenTree::Ident(i) => i.span(),
519        TokenTree::Punct(p) => p.span(),
520        TokenTree::Literal(l) => l.span(),
521    }
522}
523
524// ── Invocation model ──────────────────────────────────────────────────
525
526enum Invocation {
527    Literal {
528        width: Width,
529        /// Signed magnitude as a decimal digit string (no sign, no
530        /// dot — already shifted so digits represent `value · 10^natural_scale`).
531        digits: String,
532        /// `-1` for negative literals, `+1` for non-negative.
533        sign: i128,
534        natural_scale: u32,
535        scale_qualifier: Option<(u32, Span)>,
536        rounded: bool,
537        /// `true` for radix-prefixed (non-decimal) literals. For
538        /// these, the parsed magnitude *is* the storage bits — the
539        /// target scale only labels the resulting type, no
540        /// additional shift is applied.
541        radix_literal: bool,
542        value_span: Span,
543    },
544    Expression {
545        width: Width,
546        expr: Expr,
547        scale: u32,
548        scale_span: Span,
549    },
550}
551
552impl Invocation {
553    fn expand(self) -> TokenStream {
554        match self {
555            Invocation::Literal {
556                width,
557                digits,
558                sign,
559                natural_scale,
560                scale_qualifier,
561                rounded,
562                radix_literal,
563                value_span,
564            } => expand_literal(
565                width,
566                digits,
567                sign,
568                natural_scale,
569                scale_qualifier,
570                rounded,
571                radix_literal,
572                value_span,
573            ),
574            Invocation::Expression {
575                width,
576                expr,
577                scale,
578                scale_span,
579            } => expand_expression(width, expr, scale, scale_span),
580        }
581    }
582}
583
584// ── Qualifier parsing ─────────────────────────────────────────────────
585
586/// Resolve the effective radix for a literal. Reconciles an explicit
587/// `radix N` qualifier with a Rust prefix (`0x`, `0o`, `0b`); reports
588/// a conflict if the two disagree.
589fn pick_radix(
590    raw: &str,
591    qualifier: Option<(u32, Span)>,
592    span: Span,
593) -> Result<u32> {
594    let prefix_radix = if let Some(stripped) = raw.strip_prefix("0x").or_else(|| raw.strip_prefix("0X")) {
595        Some((16, stripped))
596    } else if let Some(stripped) = raw.strip_prefix("0o").or_else(|| raw.strip_prefix("0O")) {
597        Some((8, stripped))
598    } else if let Some(stripped) = raw.strip_prefix("0b").or_else(|| raw.strip_prefix("0B")) {
599        Some((2, stripped))
600    } else {
601        None
602    };
603    match (prefix_radix, qualifier) {
604        (None, None) => Ok(10),
605        (None, Some((r, _))) => Ok(r),
606        (Some((p, _)), None) => Ok(p),
607        (Some((p, _)), Some((r, _sp))) if p == r => Ok(r),
608        (Some((p, _)), Some((r, sp))) => Err(syn::Error::new(
609            sp,
610            format!("radix qualifier ({r}) disagrees with literal prefix (radix {p})"),
611        )),
612    }.map_err(|e: syn::Error| {
613        // Force the span to point at the literal when the disagreement
614        // error was produced inside the closure above (no-op for the
615        // common path).
616        let _ = span;
617        e
618    })
619}
620
621// ── Literal-form codegen ─────────────────────────────────────────────
622
623fn expand_literal(
624    width: Width,
625    digits: String,
626    sign: i128,
627    natural_scale: u32,
628    scale_qualifier: Option<(u32, Span)>,
629    rounded: bool,
630    radix_literal: bool,
631    value_span: Span,
632) -> TokenStream {
633    let (target_scale, scale_span) = match scale_qualifier {
634        Some((n, sp)) => (n, sp),
635        None => (natural_scale, value_span),
636    };
637
638    if target_scale > width.max_scale {
639        return error(
640            scale_span,
641            format!(
642                "scale {target_scale} exceeds max for {} (max = {})",
643                width.name.to_uppercase(),
644                width.max_scale
645            ),
646        );
647    }
648
649    // For radix-prefixed (non-decimal) literals, the parsed magnitude
650    // IS the storage bits — the target scale only labels the
651    // resulting type. Skip the scale shift entirely.
652    if radix_literal {
653        let _ = rounded;
654        if width.wide {
655            return emit_wide(width, target_scale, sign, &digits);
656        } else {
657            return emit_narrow(width, target_scale, sign, &digits, value_span);
658        }
659    }
660
661    // Decimal literal path — shift the digit string to express
662    // `value * 10^target_scale`:
663    //   target == natural   → digits unchanged
664    //   target  > natural   → append (target − natural) zeros
665    //   target  < natural   → drop the bottom (natural − target) digits,
666    //                         applying half-to-even rounding only if
667    //                         `rounded` was set.
668    let shifted_digits: String;
669    let final_digits: &str;
670
671    if target_scale == natural_scale {
672        final_digits = &digits;
673    } else if target_scale > natural_scale {
674        let pad = target_scale - natural_scale;
675        shifted_digits = pad_with_zeros(&digits, pad as usize);
676        final_digits = &shifted_digits;
677    } else {
678        let shift = natural_scale - target_scale;
679        // The digits string must be at least `shift` long for a
680        // scale-down to even make sense; pad with leading zeros if
681        // the underlying value has fewer digits than `shift`.
682        let padded = if (digits.len() as u32) <= shift {
683            pad_leading_zeros(&digits, (shift + 1) as usize - digits.len())
684        } else {
685            digits.clone()
686        };
687        let split = padded.len() - shift as usize;
688        let (kept, dropped) = padded.split_at(split);
689        let exact = dropped.bytes().all(|b| b == b'0');
690        if !exact && !rounded {
691            return error(
692                value_span,
693                format!(
694                    "literal has {natural_scale} fractional digits, target scale {target_scale} would lose precision; pass `rounded` to opt into half-to-even rounding"
695                ),
696            );
697        }
698        if exact {
699            shifted_digits = if kept.is_empty() { "0".to_string() } else { kept.to_string() };
700        } else {
701            // Half-to-even on the kept|dropped boundary.
702            shifted_digits = round_half_to_even(kept, dropped, sign < 0);
703        }
704        final_digits = &shifted_digits;
705    }
706
707    if width.wide {
708        emit_wide(width, target_scale, sign, final_digits)
709    } else {
710        emit_narrow(width, target_scale, sign, final_digits, value_span)
711    }
712}
713
714fn emit_narrow(
715    width: Width,
716    target_scale: u32,
717    sign: i128,
718    digits: &str,
719    value_span: Span,
720) -> TokenStream {
721    let magnitude: i128 = match digits.parse::<i128>() {
722        Ok(v) => v,
723        Err(_) => {
724            return error(
725                value_span,
726                format!("scaled value overflows i128 before narrowing to {}'s storage", width.name.to_uppercase()),
727            );
728        }
729    };
730    let signed = match magnitude.checked_mul(sign) {
731        Some(v) => v,
732        None => {
733            return error(
734                value_span,
735                "scaled value overflows i128 after applying sign".to_string(),
736            );
737        }
738    };
739    // Now range-check against the target storage's actual MIN/MAX.
740    let (min, max): (i128, i128) = match width.storage_path {
741        "i32" => (i32::MIN as i128, i32::MAX as i128),
742        "i64" => (i64::MIN as i128, i64::MAX as i128),
743        "i128" => (i128::MIN, i128::MAX),
744        _ => unreachable!("narrow path called with non-narrow storage"),
745    };
746    if signed < min || signed > max {
747        return error(
748            value_span,
749            format!(
750                "scaled value {signed} overflows {}'s storage ({})",
751                width.name.to_uppercase(),
752                width.storage_path
753            ),
754        );
755    }
756    let bits_tokens: proc_macro2::TokenStream = match width.storage_path {
757        "i32" => {
758            let v = signed as i32;
759            quote! { #v }
760        }
761        "i64" => {
762            let v = signed as i64;
763            quote! { #v }
764        }
765        "i128" => quote! { #signed },
766        _ => unreachable!(),
767    };
768    let type_path: proc_macro2::TokenStream = width.type_path.parse().unwrap();
769    let out = quote! {
770        #type_path :: <#target_scale> :: from_bits(#bits_tokens)
771    };
772    out.into()
773}
774
775fn emit_wide(
776    width: Width,
777    target_scale: u32,
778    sign: i128,
779    digits: &str,
780) -> TokenStream {
781    // Wide integers have `from_str_radix` as `const fn`. Emit a
782    // const block that materialises the bits at compile time.
783    let signed_str = if sign < 0 {
784        format!("-{digits}")
785    } else {
786        digits.to_string()
787    };
788    let type_path: proc_macro2::TokenStream = width.type_path.parse().unwrap();
789    let storage_path: proc_macro2::TokenStream = width.storage_path.parse().unwrap();
790    let err_msg = format!("{}! bits parse failed", width.name);
791    let out = quote! {
792        #type_path :: <#target_scale> :: from_bits({
793            const BITS: #storage_path = match <#storage_path>::from_str_radix(#signed_str, 10) {
794                ::core::result::Result::Ok(v) => v,
795                ::core::result::Result::Err(_) => panic!(#err_msg),
796            };
797            BITS
798        })
799    };
800    out.into()
801}
802
803// ── Expression-form codegen ───────────────────────────────────────────
804
805fn expand_expression(
806    width: Width,
807    expr: Expr,
808    scale: u32,
809    scale_span: Span,
810) -> TokenStream {
811    if scale > width.max_scale {
812        return error(
813            scale_span,
814            format!(
815                "scale {scale} exceeds max for {} (max = {})",
816                width.name.to_uppercase(),
817                width.max_scale
818            ),
819        );
820    }
821    let type_path: proc_macro2::TokenStream = width.type_path.parse().unwrap();
822    let storage_path: proc_macro2::TokenStream = width.storage_path.parse().unwrap();
823    let err_msg = format!("{}! overflow: expression * 10^SCALE exceeds storage range", width.name);
824    let out = if width.wide {
825        // Wide path: build the multiplier in the wide-int type via
826        // its `pow(scale)` const fn, then runtime-multiply.
827        quote! {
828            #type_path :: <#scale> :: from_bits({
829                let _v: #storage_path = (#expr);
830                let mult: #storage_path = <#storage_path>::from_str_radix("10", 10)
831                    .expect("d{}! mult literal")
832                    .pow(#scale);
833                _v.checked_mul(mult).expect(#err_msg)
834            })
835        }
836    } else if scale == 0 {
837        quote! {
838            #type_path :: <0> :: from_bits({
839                let _v: #storage_path = (#expr);
840                _v
841            })
842        }
843    } else {
844        // Narrow path: literal i32/i64/i128 multiplier via `pow`.
845        let mult_lit: proc_macro2::TokenStream = match width.storage_path {
846            "i32" => {
847                let v = 10i32.pow(scale);
848                quote! { #v }
849            }
850            "i64" => {
851                let v = 10i64.pow(scale);
852                quote! { #v }
853            }
854            "i128" => {
855                let v = 10i128.pow(scale);
856                quote! { #v }
857            }
858            _ => unreachable!(),
859        };
860        quote! {
861            #type_path :: <#scale> :: from_bits({
862                let _v: #storage_path = (#expr);
863                _v.checked_mul(#mult_lit).expect(#err_msg)
864            })
865        }
866    };
867    out.into()
868}
869
870// ── String-arithmetic helpers ─────────────────────────────────────────
871
872fn pad_with_zeros(digits: &str, pad: usize) -> String {
873    let mut out = String::with_capacity(digits.len() + pad);
874    out.push_str(digits);
875    for _ in 0..pad {
876        out.push('0');
877    }
878    out
879}
880
881fn pad_leading_zeros(digits: &str, pad: usize) -> String {
882    let mut out = String::with_capacity(digits.len() + pad);
883    for _ in 0..pad {
884        out.push('0');
885    }
886    out.push_str(digits);
887    out
888}
889
890/// Half-to-even rounding on a digit-string split at the kept|dropped
891/// boundary. `negative` selects sign handling for the rounding tie
892/// rules. Returns the new digit string (no sign).
893fn round_half_to_even(kept: &str, dropped: &str, _negative: bool) -> String {
894    debug_assert!(!dropped.is_empty());
895    // Determine whether dropped > / == / < `5000…0`.
896    let first = dropped.as_bytes()[0];
897    let rest_nonzero = dropped.bytes().skip(1).any(|b| b != b'0');
898    let round_up = match first.cmp(&b'5') {
899        std::cmp::Ordering::Less => false,
900        std::cmp::Ordering::Greater => true,
901        std::cmp::Ordering::Equal => {
902            // dropped starts with 5. If anything else is non-zero,
903            // we're past the halfway mark — round up. Otherwise
904            // exact half — round to even on `kept`'s last digit.
905            if rest_nonzero {
906                true
907            } else {
908                let last_kept = kept.bytes().last().unwrap_or(b'0');
909                (last_kept - b'0') & 1 == 1
910            }
911        }
912    };
913    let kept_or_zero = if kept.is_empty() { "0" } else { kept };
914    if !round_up {
915        kept_or_zero.to_string()
916    } else {
917        add_one_to_digits(kept_or_zero)
918    }
919}
920
921/// Increment a decimal-digit string by 1, propagating carry.
922fn add_one_to_digits(digits: &str) -> String {
923    let mut bytes: Vec<u8> = digits.as_bytes().to_vec();
924    let mut i = bytes.len();
925    let mut carry = 1u8;
926    while i > 0 && carry > 0 {
927        i -= 1;
928        let d = bytes[i] - b'0' + carry;
929        if d >= 10 {
930            bytes[i] = b'0';
931            carry = 1;
932        } else {
933            bytes[i] = b'0' + d;
934            carry = 0;
935        }
936    }
937    if carry > 0 {
938        bytes.insert(0, b'1');
939    }
940    String::from_utf8(bytes).expect("ascii digits")
941}
942
943// ── Numeric-token parsing ─────────────────────────────────────────────
944
945fn try_decimal_literal(expr: &Expr) -> Option<(i128, String, Span)> {
946    match expr {
947        Expr::Lit(ExprLit { lit, .. }) => match lit {
948            Lit::Float(f) => Some((1, f.to_string(), f.span())),
949            Lit::Int(i) => Some((1, i.to_string(), i.span())),
950            _ => None,
951        },
952        Expr::Unary(ExprUnary {
953            op: UnOp::Neg(_),
954            expr: inner,
955            ..
956        }) => {
957            if let Expr::Lit(ExprLit { lit, .. }) = inner.as_ref() {
958                match lit {
959                    Lit::Float(f) => Some((-1, f.to_string(), f.span())),
960                    Lit::Int(i) => Some((-1, i.to_string(), i.span())),
961                    _ => None,
962                }
963            } else {
964                None
965            }
966        }
967        _ => None,
968    }
969}
970
971fn expr_span(expr: &Expr) -> Span {
972    use syn::spanned::Spanned;
973    expr.span()
974}
975
976/// Parses the raw literal token (`raw`, no sign) under the chosen
977/// `radix`. Returns `(digits, natural_scale)` where `digits` is the
978/// integer magnitude as a decimal string and `natural_scale` is the
979/// inferred decimal scale of the value as written.
980///
981/// For decimal literals (radix == 10) this handles fractional and
982/// scientific notation. For non-decimal radices it accepts integer-
983/// only Rust-prefixed forms (`0x…`, `0o…`, `0b…`) plus the raw digit
984/// string from an explicit `radix N` qualifier; mid-fractional non-
985/// decimal forms (`1.A3, radix 16`) are rejected — `syn` doesn't
986/// tokenise them as a single literal anyway.
987fn parse_value_token(
988    raw: &str,
989    span: Span,
990    radix: u32,
991) -> Result<(String, u32)> {
992    // Reject Rust type suffixes (1.5_f64 etc.).
993    for (i, c) in raw.char_indices() {
994        if (c == 'i' || c == 'u') || (c == 'f' && i > 0 && !raw[..i].contains('.') && !raw[..i].chars().last().map_or(false, |x| x.is_ascii_digit())) {
995            // No-op: we'll handle the `f`/`i`/`u` filter via parse failures.
996            let _ = i;
997        }
998    }
999    if let Some(idx) = raw.find(|c: char| c == 'f') {
1000        // `1_f64`-style suffix.
1001        if idx > 0 && raw.as_bytes()[idx - 1] == b'_' {
1002            return Err(syn::Error::new(
1003                span,
1004                "type suffixes (e.g. _i64, _f32) are not accepted in decimal-scaled literals",
1005            ));
1006        }
1007    }
1008    if let Some(idx) = raw.rfind(|c: char| c == 'i' || c == 'u') {
1009        // Ignore the `i` in `radix` (impossible here — `raw` is the
1010        // value token only) and the `i` that follows a digit
1011        // (`0o755_i32`).
1012        if idx > 0 && raw.as_bytes()[idx - 1] == b'_' {
1013            return Err(syn::Error::new(
1014                span,
1015                "type suffixes (e.g. _i64, _f32) are not accepted in decimal-scaled literals",
1016            ));
1017        }
1018    }
1019
1020    if radix == 10 {
1021        return parse_decimal_token(raw, span);
1022    }
1023
1024    // Non-decimal: accept Rust-prefix forms and bare digit strings.
1025    // Strip prefix if present and verify it matches `radix`.
1026    let digits_part = strip_radix_prefix(raw).map(|(p, rest)| {
1027        // p must match radix; if not, the caller's pick_radix already
1028        // flagged it.
1029        let _ = p;
1030        rest
1031    }).unwrap_or(raw);
1032
1033    if digits_part.contains('.') {
1034        return Err(syn::Error::new(
1035            span,
1036            "fractional non-decimal literals are not supported (use an explicit `scale N` with an integer-only digit string instead)",
1037        ));
1038    }
1039
1040    // Underscore separators are stripped.
1041    let cleaned: String = digits_part.chars().filter(|c| *c != '_').collect();
1042    if cleaned.is_empty() {
1043        return Err(syn::Error::new(span, "empty digit string"));
1044    }
1045
1046    // Parse the digit string in the given radix as an i128, then
1047    // re-render as a base-10 string. (Wide-tier widths may need more
1048    // than i128 — for now we narrow through i128 and let the wide
1049    // path's range check catch any overflow.)
1050    let magnitude = match i128::from_str_radix(&cleaned, radix) {
1051        Ok(v) => v,
1052        Err(_) => {
1053            return Err(syn::Error::new(
1054                span,
1055                format!(
1056                    "digit string `{cleaned}` is not valid in radix {radix} or overflows i128"
1057                ),
1058            ));
1059        }
1060    };
1061    Ok((magnitude.to_string(), 0))
1062}
1063
1064fn strip_radix_prefix(raw: &str) -> Option<(u32, &str)> {
1065    if let Some(rest) = raw.strip_prefix("0x").or_else(|| raw.strip_prefix("0X")) {
1066        Some((16, rest))
1067    } else if let Some(rest) = raw.strip_prefix("0o").or_else(|| raw.strip_prefix("0O")) {
1068        Some((8, rest))
1069    } else if let Some(rest) = raw.strip_prefix("0b").or_else(|| raw.strip_prefix("0B")) {
1070        Some((2, rest))
1071    } else {
1072        None
1073    }
1074}
1075
1076fn parse_decimal_token(raw: &str, span: Span) -> Result<(String, u32)> {
1077    let (mantissa, sci_exp) = match raw.find(['e', 'E']) {
1078        Some(idx) => {
1079            let m = &raw[..idx];
1080            let e: i32 = raw[idx + 1..].parse().map_err(|_| {
1081                syn::Error::new(
1082                    span,
1083                    format!("invalid scientific exponent: `{}`", &raw[idx + 1..]),
1084                )
1085            })?;
1086            (m, e)
1087        }
1088        None => (raw, 0_i32),
1089    };
1090    let (int_part, frac_part) = match mantissa.find('.') {
1091        Some(idx) => {
1092            let int_part = &mantissa[..idx];
1093            let frac_part = &mantissa[idx + 1..];
1094            if int_part.is_empty() {
1095                return Err(syn::Error::new(
1096                    span,
1097                    "decimal literals require a digit on each side of the dot (write `0.5` not `.5`)",
1098                ));
1099            }
1100            if frac_part.is_empty() {
1101                return Err(syn::Error::new(
1102                    span,
1103                    "decimal literals require a digit on each side of the dot (write `1.0` not `1.`)",
1104                ));
1105            }
1106            (int_part, frac_part)
1107        }
1108        None => (mantissa, ""),
1109    };
1110    let cleaned_int: String = int_part.chars().filter(|c| *c != '_').collect();
1111    let cleaned_frac: String = frac_part.chars().filter(|c| *c != '_').collect();
1112    for c in cleaned_int.chars().chain(cleaned_frac.chars()) {
1113        if !c.is_ascii_digit() {
1114            return Err(syn::Error::new(
1115                span,
1116                format!("invalid digit `{c}` in decimal literal"),
1117            ));
1118        }
1119    }
1120    let mantissa_scale = cleaned_frac.len() as u32;
1121    let mut digits = cleaned_int;
1122    digits.push_str(&cleaned_frac);
1123    // Strip leading zeros so the digit string canonicalises to its
1124    // numerical magnitude.
1125    let trimmed = digits.trim_start_matches('0');
1126    let digits = if trimmed.is_empty() { "0".to_string() } else { trimmed.to_string() };
1127
1128    // Apply scientific exponent: natural_scale = max(0, mantissa_scale - sci_exp).
1129    let signed_natural = (mantissa_scale as i64) - (sci_exp as i64);
1130    if signed_natural >= 0 {
1131        Ok((digits, signed_natural as u32))
1132    } else {
1133        // sci_exp > mantissa_scale: pad trailing zeros, natural scale = 0.
1134        let pad = (-signed_natural) as usize;
1135        Ok((pad_with_zeros(&digits, pad), 0))
1136    }
1137}
1138
1139fn error(span: Span, msg: String) -> TokenStream {
1140    syn::Error::new(span, msg).into_compile_error().into()
1141}