Functional Weave
Code in Rust

text.format-decimal

Format a float as plain decimal text with fixed or trimmed places and optional grouping, identically in every language.

1.0.0 · published 2026-10-03 by charlie · Anterra

Pinned by 19 tests, run in TypeScript, Python and Rust.

What it does

Turns a float into decimal text: `formatDecimal(1234567.891, 2, false, ",")` is `"1,234,567.89"`. It exists because the three languages print floats differently: `String(1e-7)` is `"1e-7"`, Python's `str(1e-7)` is `"1e-07"`, Rust prints `0.0000001`, and `2.0` is `"2"`, `"2.0"` and `"2"`. Anything that puts numbers into text that must match across languages (SVG path data, axis labels, CSV) should go through this.

The value is rounded once, half away from zero, by `math.round-float` (so `2.675` to 2 places is `2.67`, because that is the double actually stored, and `0.125` is `0.13`). The rounded value becomes a whole count of 10^-decimals units, and the text is built from that integer's digits. There is never an exponent, and never `-0`: a negative number that rounds to zero prints as `0`, or `0.00` with fixed places.

For example

  • format_decimal(3.142, 2, false, ) → 3.14 two fixed places
  • format_decimal(2.5, 2, false, ) → 2.50 fixed places keep trailing zeros
  • format_decimal(2.5, 2, true, ) → 2.5 trimmed places drop trailing zeros

The function

The same function in TypeScript, Python and Rust, pinned by the same tests. Pick your language; the choice follows you around the registry.

pub fn format_decimal(value: f64, decimals: i64, trim_zeros: bool, group_separator: &str) -> String
valuefloatany finite number whose magnitude times 10^decimals is below 10^15
decimalsint0 to 12 places, rounded half away from zero by math.round-float
trim_zerosbooldrop trailing zeros after the point, and the point itself if nothing is left
group_separatorstringput between groups of three digits in the whole part, e.g. "," or ""
returnsstring

Your code names it in one line, in the file that uses it

fune!(text.format-decimal@^1);  // then call format_decimal(…)
impl/rust.rs · 52 lines · open · raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

use super::funejson::Value;  ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
use super::math_round_float::round_float;  ← from math.round-float ^1.0.0 · built alongside by fune

const POW10: [f64; 13] = [
    1.0, 10.0, 100.0, 1e3, 1e4, 1e5, 1e6, 1e7, 1e8, 1e9, 1e10, 1e11, 1e12,
];

/// A float as decimal text, never in exponent form and never "-0".
///
/// Display, str() and String() disagree about floats, so the number is
/// rounded once, turned into a whole count of 10^-decimals units, and the
/// digits of that integer are laid out by hand.
///
/// # Panics
/// Panics if `value` is not finite, `decimals` is outside 0..=12, or the
/// value times 10^decimals is 10^15 or more.
pub fn format_decimal(value: f64, decimals: i64, trim_zeros: bool, group_separator: &str) -> String {
    let rounded = round_float(value, decimals);
    let places = decimals as usize;
    let scaled = rounded.abs() * POW10[places];
    if scaled >= 1e15 {
        panic!("value is too large to format at {} decimal places, received {}", decimals, value);
    }
    let units = round_float(scaled, 0) as i64;
    let mut digits = units.to_string();
    while digits.len() < places + 1 {
        digits.insert(0, '0');
    }
    let split = digits.len() - places;
    let whole = &digits[..split];
    let mut fraction = &digits[split..];
    if trim_zeros {
        fraction = fraction.trim_end_matches('0');
    }
    let mut grouped = String::new();
    for (i, ch) in whole.chars().enumerate() {
        if i > 0 && (whole.len() - i) % 3 == 0 {
            grouped.push_str(group_separator);
        }
        grouped.push(ch);
    }
    let body = if fraction.is_empty() { grouped } else { format!("{}.{}", grouped, fraction) };
    if units > 0 && rounded < 0.0 {
        format!("-{}", body)
    } else {
        body
    }
}

pub fn fune_vector(args: &[Value]) -> Value {
    Value::str(&format_decimal(args[0].as_f64(), args[1].as_i64(), args[2].as_bool(), args[3].as_str()))
}

Install

fune build

With that line in your source, in a Rust project (language rust in fune.project), fune build resolves it and its 1 dependency, pins them in fune.lock, downloads only the Rust package of each, and builds the code above into your project’s .fune/build, one readable file per capability with a header linking back here. A crate’s build.rs runs it before every compile. Or pin a range in fune.project and build in one step:

fune add text.format-decimal
Download for Rust text.format-decimal-1.0.0-rust.fune · 7,599 bytes sha256 ca861fb4d86f893e0351007fbe6267ef429a8099313dc86f1b3b14d41c5374bb

The manifest, vectors and README with only the Rust implementation. Install it without the registry with fune add ./text.format-decimal-1.0.0-rust.fune, or fetch it from a terminal with fune pull text.format-decimal@1.0.0:rust.

The whole function, every language, is one file too: text.format-decimal-1.0.0.fune, 10,670 bytes, sha256 dfb1ce562594c30b832d373293ea7f9748721400553b6297338c3bfdf3e1962b. It installs into a project of any language.

Customise it in your app

The seams this capability offers. Put a marker directly above a function of your own and fune build wires it into the built code; the package on the registry is not changed, the built file’s header lists it under CUSTOMISED, and fune hooks lists every hook in the project. How hooks work.

before — your function gets the arguments and returns them, changed or not, or throws to refuse the call.

// fune: before text.format-decimal

after — your function gets the result and the arguments, and returns the final result.

// fune: after text.format-decimal

replace — inside this capability’s code only, calls to a dependency go to your function, with the same signature. Other capabilities that use it are unaffected; write in * to replace it everywhere.

// fune: replace math.round-float in text.format-decimal

step — your function runs at a numbered point inside the function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show text.format-decimal --steps.

// fune: step text.format-decimal after <n|label>

Tests

A version published now needs at least 8 tests for every function, and one that expects the error for each function that throws; the registry refuses it otherwise. fune verify --all runs each case in TypeScript, Python and Rust, and a project runs them again with fune verify. This page lists the cases; it does not run them. The exact JSON is vectors.json.

CaseArgumentsExpected
two fixed places 3.142, 2, false, → 3.14
fixed places keep trailing zeros 2.5, 2, false, → 2.50
trimmed places drop trailing zeros 2.5, 2, true, → 2.5
a whole number trims to no point at all 7, 2, true, → 7
zero places 1,234.5, 0, false, → 1235
thousands grouped with a comma 1,234,567.891, 2, false, , → 1,234,567.89
grouping with a thin space, only in the whole part 12,345.679, 4, false, → 12 345.6789
exactly three digits get no separator 999, 0, false, , → 999
a negative number -1,234.5, 1, false, , → -1,234.5
a negative that rounds to zero prints 0, not -0 -0.004, 2, false, → 0.00
Show the other 9 tests
CaseArgumentsExpected
a small number keeps its leading zeros, never 1e-7 0, 7, false, → 0.0000001
a large number is never 1e+21 style 123,456,789,012, 0, false, → 123456789012
2.675 is stored below the tie, so 2.67 (as toFixed) 2.675, 2, false, → 2.67
0.125 is a true tie and goes away from zero 0.125, 2, false, → 0.13
binary noise disappears 0.3, 12, true, → 0.3
fractional zero trims to 0 0, 3, true, , → 0
rounding carries into a new group 999,999.996, 2, false, , → 1,000,000.00
too many digits for an exact count is an error 1,000,000,000,000,000, 0, false, → error: value is too large to format at 0 decimal places
more than 12 places is an error 1, 13, false, → error: decimals must be a whole number from 0 to 12

More from the author

`trimZeros` drops trailing zeros after the point, then the point itself if nothing is left: `2.50` becomes `2.5`, `7.00` becomes `7`. `groupSeparator` goes between groups of three digits of the whole part only (`","`, `"."`, `" "`, or `""` for none). The decimal point is always `.` and the minus sign the ASCII hyphen; for currency amounts use `money.format`, which knows each currency's digits and symbols.

The count of units must stay below 10^15, where a double still counts in whole numbers exactly; a larger value (say 1e15 with no decimals, or 1000 at 12 places) is an error rather than a string with invented digits.

Files

PathBytes
README.md1,480
impl/python.py1,273
impl/rust.rs1,869
impl/typescript.ts1,663
vectors.json2,084