Skip to main content

decimal_scaled_macros/
lib.rs

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