From 003223ebccf42379f94a9de15893c4c366e0314b Mon Sep 17 00:00:00 2001 From: Dag Brattli Date: Sat, 27 Jun 2026 18:00:12 +0200 Subject: [PATCH 1/4] fix(all): support .NET format specifiers and alignment in interpolated strings Interpolated string holes carrying a .NET format specifier (e.g. `$"{i:X4}"`) or alignment (e.g. `$"{x,6}"`) were emitted as the raw `%P(...)` placeholder instead of being formatted, because the interpolation parser only matched empty `%P()` holes. The shared interpolation parser now recognises both the alignment width and the .NET format spec and compiles each such hole to `String.format("{0,align:spec}", value)`, which every target implements. This fixes the original report on all targets (JS/TS, Python, Beam, Dart). For Beam this also required runtime work in `fable_string.erl`: - `String.Format` previously discarded everything after `:`; it now parses and applies the spec, with full standard numeric specifier support (X x D d F f E e G g N n C c P p B b R r), sign-aware zero-padding, thousands grouping and .NET-style exponents (e.g. `1234.5:E2` -> `1.23E+003`). - Alignment (`{0,6}` / `{0,-6}`) is parsed and applied. - The `String.Format(provider, ...)` overload no longer treats the leading IFormatProvider/CultureInfo argument as the format string. Fixes fable-compiler/Fable.Python#36 Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Fable.Transforms/Beam/Replacements.fs | 12 +- src/Fable.Transforms/Dart/Replacements.fs | 2 +- src/Fable.Transforms/Python/Replacements.fs | 2 +- src/Fable.Transforms/Replacements.Util.fs | 56 ++++-- src/Fable.Transforms/Replacements.fs | 2 +- src/fable-library-beam/fable_string.erl | 194 +++++++++++++++++++- tests/Beam/StringTests.fs | 36 ++++ tests/Js/Main/StringTests.fs | 16 ++ tests/Python/TestString.fs | 17 ++ 9 files changed, 309 insertions(+), 28 deletions(-) diff --git a/src/Fable.Transforms/Beam/Replacements.fs b/src/Fable.Transforms/Beam/Replacements.fs index beca74e4e7..0eb7a3c8a3 100644 --- a/src/Fable.Transforms/Beam/Replacements.fs +++ b/src/Fable.Transforms/Beam/Replacements.fs @@ -94,7 +94,7 @@ let private fsFormat | _ -> None | "PrintFormatThen", _, arg :: callee :: _ -> emitExpr r t [ callee; arg ] "(maps:get(cont, $0))($1)" |> Some | ".ctor", _, str :: (Value(NewArray(ArrayValues templateArgs, _, _), _) as values) :: _ -> - match makeStringTemplateFrom [| "%s"; "%i" |] templateArgs str with + match makeStringTemplateFrom com [| "%s"; "%i" |] templateArgs str with | Some v -> makeValue r v |> Some | None -> Helper.LibCall(com, "fable_string", "interpolate", t, [ str; values ], i.SignatureArgTypes, ?loc = r) @@ -1094,7 +1094,17 @@ let private strings // str.GetEnumerator() → convert string to list of codepoints and create enumerator | "GetEnumerator", Some c, _ -> emitExpr r t [ c ] "fable_utils:get_enumerator(binary_to_list($0))" |> Some // String.Format("{0} {1}", arg0, arg1) + // Also String.Format(provider, "{0}", arg0): the optional leading IFormatProvider/CultureInfo + // argument is ignored (the format string is the first argument typed as `String`). | "Format", None, (fmtStr :: fmtArgs) -> + let fmtStr, fmtArgs = + match fmtStr.Type with + | String -> fmtStr, fmtArgs + | _ -> + match fmtArgs with + | realFmt :: rest -> realFmt, rest + | [] -> fmtStr, fmtArgs + // When a single array argument is passed (e.g. from ParamArray), // pass it directly — format/2 already handles refs and lists. let argsList = diff --git a/src/Fable.Transforms/Dart/Replacements.fs b/src/Fable.Transforms/Dart/Replacements.fs index b1e787f770..e4c43ff7f4 100644 --- a/src/Fable.Transforms/Dart/Replacements.fs +++ b/src/Fable.Transforms/Dart/Replacements.fs @@ -928,7 +928,7 @@ let fsFormat (com: ICompiler) (ctx: Context) r t (i: CallInfo) (thisArg: Expr op _, _ -> fsharpModule com ctx r t i thisArg args | ".ctor", _, str :: (Value(NewArray(ArrayValues templateArgs, _, MutableArray), _) as values) :: _ -> - match makeStringTemplateFrom [| "%s"; "%i" |] templateArgs str with + match makeStringTemplateFrom com [| "%s"; "%i" |] templateArgs str with | Some v -> makeValue r v |> Some | None -> Helper.LibCall(com, "String", "interpolate", t, [ str; values ], i.SignatureArgTypes, ?loc = r) diff --git a/src/Fable.Transforms/Python/Replacements.fs b/src/Fable.Transforms/Python/Replacements.fs index 6325a88b28..d67fdcb251 100644 --- a/src/Fable.Transforms/Python/Replacements.fs +++ b/src/Fable.Transforms/Python/Replacements.fs @@ -1103,7 +1103,7 @@ let fsFormat (com: ICompiler) (ctx: Context) r t (i: CallInfo) (thisArg: Expr op _, _ -> fsharpModule com ctx r t i thisArg args | ".ctor", _, str :: (Value(NewArray(ArrayValues templateArgs, _, _), _) as values) :: _ -> - match makeStringTemplateFrom [| "%s"; "%i" |] templateArgs str with + match makeStringTemplateFrom com [| "%s"; "%i" |] templateArgs str with | Some v -> makeValue r v |> Some | None -> Helper.LibCall(com, "string", "interpolate", t, [ str; values ], i.SignatureArgTypes, ?loc = r) diff --git a/src/Fable.Transforms/Replacements.Util.fs b/src/Fable.Transforms/Replacements.Util.fs index f1148aabcd..ff03bd33aa 100644 --- a/src/Fable.Transforms/Replacements.Util.fs +++ b/src/Fable.Transforms/Replacements.Util.fs @@ -461,17 +461,24 @@ let makeStringTemplate StringTemplate(tag, parts, values) -// Parses an F# interpolated string format, finds every %P() hole (optionally preceded by a +// Parses an F# interpolated string format, finds every %P(...) hole (optionally preceded by a // printf format specifier), and builds a StringTemplate from the literal parts and the // supplied value expressions. // -// For each hole the caller supplies `handleFormatSpec`: -// - None → the hole has no preceding format spec (or only a simple one from simpleFormats); -// the value is used as-is. -// - Some fmtSpec → the hole has a non-simple format spec; the callback receives the spec -// (e.g. "%0.2f") and the value expression. Returning Some wraps the value; -// returning None aborts the whole template (None is returned for the string). +// A hole takes one of these shapes: +// - `%P()` → no format; the value is used as-is. +// - `%%P()` → a printf-style format spec (e.g. `%0.2f`); handled by `handleFormatSpec`. +// - `%P()` → a .NET format spec (e.g. `X4` from `$"{x:X4}"`). +// - `%P(...)` → an alignment width (e.g. `%6P()` from `$"{x,6}"`, `%-6P(X4)` from +// `$"{x,-6:X4}"`). +// Alignment and/or .NET format holes are always wrapped in a `String.format("{0,align:spec}", value)` +// call (parts omitted when absent), which every target implements. +// +// For each printf hole the caller supplies `handleFormatSpec`: +// - Some fmtSpec → wraps the value (the callback receives the spec, e.g. "%0.2f"). +// - None → aborts the whole template (None is returned for the string). let private makeStringTemplateFromWith + (com: ICompiler) (simpleFormats: string array) (handleFormatSpec: string -> Expr -> Expr option) (values: Expr list) @@ -483,7 +490,7 @@ let private makeStringTemplateFromWith let str = str.Replace("%%", "%") let matches = - Regex.Matches(str, @"((? Seq.cast (Some([], values), matches) @@ -492,11 +499,28 @@ let private makeStringTemplateFromWith | None -> None | Some(_, []) -> None // fewer values than matches | Some(mapped, value :: restValues) -> - let needsFormat = - m.Groups[1].Success && not (Array.contains m.Groups[1].Value simpleFormats) + let alignment = m.Groups[2].Value + let dotnetFormat = m.Groups[3].Value let resolved = - if needsFormat then + if alignment <> "" || dotnetFormat <> "" then + // Alignment (`$"{x,6}"`) and/or .NET format spec (`$"{x:X4}"`). + // Wrap value in: String.format("{0[,][:]}", value) + let alignPart = + if alignment <> "" then + "," + alignment + else + "" + + let fmtPart = + if dotnetFormat <> "" then + ":" + dotnetFormat + else + "" + + let fmtStr = Value(StringConstant("{0" + alignPart + fmtPart + "}"), None) + Helper.LibCall(com, "String", "format", String, [ fmtStr; value ]) |> Some + elif m.Groups[1].Success && not (Array.contains m.Groups[1].Value simpleFormats) then handleFormatSpec m.Groups[1].Value value else Some value @@ -520,11 +544,12 @@ let private makeStringTemplateFromWith | _ -> None /// Try to build a StringTemplate from an F# interpolated string. -/// Returns None if any hole carries a format specifier that is not in simpleFormats, +/// Returns None if any hole carries a printf format specifier that is not in simpleFormats, /// because those require runtime formatting that can't be inlined into a plain template. -let makeStringTemplateFrom simpleFormats values strExpr = - // Abort (return None) for any non-simple format spec. - makeStringTemplateFromWith simpleFormats (fun _ _ -> None) values strExpr +/// Holes carrying a .NET format spec (e.g. `%P(X4)`) are always inlined as a String.format call. +let makeStringTemplateFrom com simpleFormats values strExpr = + // Abort (return None) for any non-simple printf format spec. + makeStringTemplateFromWith com simpleFormats (fun _ _ -> None) values strExpr /// Like makeStringTemplateFrom but always succeeds for string literals. /// Values with format specifiers not in simpleFormats are individually wrapped in a @@ -532,6 +557,7 @@ let makeStringTemplateFrom simpleFormats values strExpr = /// Used for JSX templates, where every hole must produce a value (formatted or not). let makeStringTemplateFromAllowingFormat (com: ICompiler) simpleFormats (values: Expr list) strExpr = makeStringTemplateFromWith + com simpleFormats (fun fmtSpec value -> // Wrap value in: String.interpolate("%fmtspec%P()", [| value |]) diff --git a/src/Fable.Transforms/Replacements.fs b/src/Fable.Transforms/Replacements.fs index 446cb86b99..eb0c673592 100644 --- a/src/Fable.Transforms/Replacements.fs +++ b/src/Fable.Transforms/Replacements.fs @@ -1303,7 +1303,7 @@ let fsFormat (com: ICompiler) (ctx: Context) r t (i: CallInfo) (thisArg: Expr op _, _ -> fsharpModule com ctx r t i thisArg args | ".ctor", _, str :: (Value(NewArray(ArrayValues templateArgs, _, _), _) as values) :: _ -> - match makeStringTemplateFrom [| "%s"; "%i" |] templateArgs str with + match makeStringTemplateFrom com [| "%s"; "%i" |] templateArgs str with | Some v -> makeValue r v |> Some | None -> // Try to build a StringTemplate where formatted values are individually wrapped diff --git a/src/fable-library-beam/fable_string.erl b/src/fable-library-beam/fable_string.erl index 6b6a61b0c5..73c47d6589 100644 --- a/src/fable-library-beam/fable_string.erl +++ b/src/fable-library-beam/fable_string.erl @@ -540,25 +540,201 @@ format_dotnet(<<"{{", Rest/binary>>, Args, Acc) -> format_dotnet(<<"}}", Rest/binary>>, Args, Acc) -> format_dotnet(Rest, Args, [<<"}">> | Acc]); format_dotnet(<<"{", Rest/binary>>, Args, Acc) -> - {IdxStr, Rest2} = parse_format_index(Rest, []), + {IdxStr, Align, Spec, Rest2} = parse_format_index(Rest, []), Idx = list_to_integer(IdxStr), Val = lists:nth(Idx + 1, Args), - format_dotnet(Rest2, Args, [to_string(Val) | Acc]); + Formatted0 = + case Spec of + [] -> to_string(Val); + _ -> apply_dotnet_spec(Spec, Val) + end, + Formatted = apply_alignment(Align, Formatted0), + format_dotnet(Rest2, Args, [Formatted | Acc]); format_dotnet(<>, Args, Acc) -> format_dotnet(Rest, Args, [<> | Acc]). +%% parse_format_index/2 — Parse "{index[,alignment][:spec]}" into its components. parse_format_index(<<"}", Rest/binary>>, Acc) -> - {lists:reverse(Acc), Rest}; + {lists:reverse(Acc), [], [], Rest}; +parse_format_index(<<",", Rest/binary>>, Acc) -> + %% Alignment, e.g. {0,6} or {0,-6:X4} + {Align, Spec, Rest2} = parse_format_align(Rest, []), + {lists:reverse(Acc), Align, Spec, Rest2}; parse_format_index(<<":", Rest/binary>>, Acc) -> - %% Skip format specifier after colon (e.g., {0:N5}) until closing } - skip_until_close(Rest, lists:reverse(Acc)); + %% Format specifier, e.g. {0:X4} + {Spec, Rest2} = parse_format_spec(Rest, []), + {lists:reverse(Acc), [], Spec, Rest2}; parse_format_index(<>, Acc) -> parse_format_index(Rest, [C | Acc]). -skip_until_close(<<"}", Rest/binary>>, IdxStr) -> - {IdxStr, Rest}; -skip_until_close(<<_, Rest/binary>>, IdxStr) -> - skip_until_close(Rest, IdxStr). +parse_format_align(<<"}", Rest/binary>>, Acc) -> + {lists:reverse(Acc), [], Rest}; +parse_format_align(<<":", Rest/binary>>, Acc) -> + {Spec, Rest2} = parse_format_spec(Rest, []), + {lists:reverse(Acc), Spec, Rest2}; +parse_format_align(<>, Acc) -> + parse_format_align(Rest, [C | Acc]). + +parse_format_spec(<<"}", Rest/binary>>, Acc) -> + {lists:reverse(Acc), Rest}; +parse_format_spec(<>, Acc) -> + parse_format_spec(Rest, [C | Acc]). + +%% apply_alignment/2 — Pad to the given alignment width. A positive width +%% right-aligns (pad left); a negative width left-aligns (pad right). +apply_alignment([], Str) -> + Str; +apply_alignment(Align, Str) -> + case catch list_to_integer(Align) of + N when is_integer(N), N >= 0 -> pad_left(Str, N); + N when is_integer(N) -> pad_right(Str, -N); + _ -> Str + end. + +%% apply_dotnet_spec/2 — Apply a .NET numeric format specifier (the text after ':' +%% in "{0:Spec}") to a value. Handles the standard numeric specifiers; falls back +%% to the plain string representation for non-numeric values or unsupported specs. +apply_dotnet_spec(Spec, Value) when is_number(Value) -> + case parse_standard_spec(Spec) of + {Type, Prec} -> apply_dotnet_numeric(Type, Prec, Value); + custom -> to_string(Value) + end; +apply_dotnet_spec(_Spec, Value) -> + to_string(Value). + +%% parse_standard_spec/1 — A standard numeric spec is a single letter optionally +%% followed by a precision number (e.g. "X4", "D", "F2"). Anything else (custom +%% numeric format strings like "#,##0.00") returns `custom`. +parse_standard_spec([Type | Rest]) when + (Type >= $A andalso Type =< $Z) orelse (Type >= $a andalso Type =< $z) +-> + case Rest of + [] -> {Type, -1}; + _ -> + case all_digits(Rest) of + true -> {Type, list_to_integer(Rest)}; + false -> custom + end + end; +parse_standard_spec(_) -> + custom. + +all_digits([]) -> true; +all_digits([C | T]) when C >= $0, C =< $9 -> all_digits(T); +all_digits(_) -> false. + +%% apply_dotnet_numeric/3 — Format a numeric value per a standard specifier. +%% See https://learn.microsoft.com/dotnet/standard/base-types/standard-numeric-format-strings +apply_dotnet_numeric(Type, Prec, Value) when Type =:= $X; Type =:= $x -> + Hex = format_raw(Type, -1, Value), + pad_zeros(Hex, Prec); +apply_dotnet_numeric(Type, Prec, Value) when Type =:= $D; Type =:= $d -> + pad_zeros(integer_to_binary(trunc(Value)), Prec); +apply_dotnet_numeric(Type, Prec, Value) when Type =:= $F; Type =:= $f -> + P = default_prec(Prec, 2), + format_raw($f, P, Value); +apply_dotnet_numeric(Type, Prec, Value) when Type =:= $E; Type =:= $e -> + P = default_prec(Prec, 6), + format_exponential(Type, P, Value); +apply_dotnet_numeric(Type, Prec, Value) when Type =:= $G; Type =:= $g -> + format_raw(Type, Prec, Value); +apply_dotnet_numeric(Type, Prec, Value) when Type =:= $N; Type =:= $n -> + P = default_prec(Prec, 2), + {IntB, DecB} = split_int_dec(format_raw($f, P, Value)), + join_int_dec(thousand_separate(IntB), DecB, P); +apply_dotnet_numeric(Type, Prec, Value) when Type =:= $C; Type =:= $c -> + P = default_prec(Prec, 2), + {IntB, DecB} = split_int_dec(format_raw($f, P, abs_num(Value))), + Body = join_int_dec(<<"¤"/utf8, (thousand_separate(IntB))/binary>>, DecB, P), + case Value < 0 of + true -> <<"(", Body/binary, ")">>; + false -> Body + end; +apply_dotnet_numeric(Type, Prec, Value) when Type =:= $P; Type =:= $p -> + P = default_prec(Prec, 2), + {IntB, DecB} = split_int_dec(format_raw($f, P, Value * 100)), + Body = join_int_dec(thousand_separate(IntB), DecB, P), + <>; +apply_dotnet_numeric(Type, Prec, Value) when Type =:= $B; Type =:= $b -> + Unsigned = trunc(Value) band 16#FFFFFFFF, + MinWidth = + if + Prec =< 0 -> 1; + true -> Prec + end, + pad_left(integer_to_binary(Unsigned, 2), MinWidth, $0); +apply_dotnet_numeric(Type, _Prec, Value) when Type =:= $R; Type =:= $r -> + to_string(Value); +apply_dotnet_numeric(_Type, _Prec, Value) -> + to_string(Value). + +%% default_prec/2 — Use Default when the spec carried no explicit precision (-1). +default_prec(Prec, Default) -> + if + Prec < 0 -> Default; + true -> Prec + end. + +%% format_exponential/3 — .NET "E"/"e" format: one leading digit, Prec decimals, +%% an explicit exponent sign and a minimum of three exponent digits (e.g. 1234.5 +%% with "E2" -> "1.23E+003"). Erlang's scientific notation gives "1.23e+03", which +%% we reshape: uppercase the exponent marker on demand and left-pad the exponent. +format_exponential(TypeChar, P, Value) -> + Sci = float_to_binary(float(Value), [{scientific, P}]), + [Mantissa, ExpPart] = binary:split(Sci, <<"e">>), + <> = ExpPart, + ExpPadded = pad_left(ExpDigits, 3, $0), + EChar = + case TypeChar of + $E -> <<"E">>; + _ -> <<"e">> + end, + <>. + +abs_num(V) when V < 0 -> -V; +abs_num(V) -> V. + +%% split_int_dec/1 — Split "1234.50" into {<<"1234">>, <<"50">>}. +split_int_dec(Bin) -> + case binary:split(Bin, <<".">>) of + [I, D] -> {I, D}; + [I] -> {I, <<>>} + end. + +%% join_int_dec/3 — Reassemble integral and decimal parts, dropping the decimal +%% point when the precision is zero. +join_int_dec(IntB, DecB, Prec) -> + case Prec > 0 of + true -> <>; + false -> IntB + end. + +%% thousand_separate/1 — Insert ',' every three digits, keeping any leading sign. +thousand_separate(<<$-, Rest/binary>>) -> + <<$-, (group_thousands(Rest))/binary>>; +thousand_separate(Bin) -> + group_thousands(Bin). + +group_thousands(Bin) -> + Digits = lists:reverse(binary_to_list(Bin)), + list_to_binary(do_group(Digits, 1, [])). + +do_group([], _N, Acc) -> + Acc; +do_group([D], _N, Acc) -> + [D | Acc]; +do_group([D | T], N, Acc) when N rem 3 =:= 0 -> + do_group(T, N + 1, [$,, D | Acc]); +do_group([D | T], N, Acc) -> + do_group(T, N + 1, [D | Acc]). + +%% pad_zeros/2 — Left-pad to a minimum width with '0', keeping any leading sign. +pad_zeros(Bin, Width) when Width =< 0 -> + Bin; +pad_zeros(<<$-, Rest/binary>>, Width) -> + <<$-, (pad_left(Rest, Width, $0))/binary>>; +pad_zeros(Bin, Width) -> + pad_left(Bin, Width, $0). %% ===================================================================== %% Internal: F# format string parser diff --git a/tests/Beam/StringTests.fs b/tests/Beam/StringTests.fs index 1773c3e410..af4986c546 100644 --- a/tests/Beam/StringTests.fs +++ b/tests/Beam/StringTests.fs @@ -24,6 +24,24 @@ let ``test String interpolation with expression works`` () = let x = 21 $"the answer is {x * 2}" |> equal "the answer is 42" +[] +let ``test String interpolation with .NET format specifiers works`` () = // See Fable.Python #36 + let i = 123 + $"{i:X4}" |> equal "007B" + $"{i:x4}" |> equal "007b" + $"{i:D5}" |> equal "00123" + $"{5:B}" |> equal "101" + $"\\u{i:X4}" |> equal "\\u007B" + $"{3.14159:F2}" |> equal "3.14" + +[] +let ``test String interpolation with alignment works`` () = + let i = 123 + $"[{i,6}]" |> equal "[ 123]" + $"[{i,-6}]" |> equal "[123 ]" + $"[{i,6:X4}]" |> equal "[ 007B]" + $"[{i,-6:X4}]" |> equal "[007B ]" + [] let ``test String equality works`` () = ("hello" = "hello") |> equal true @@ -626,6 +644,24 @@ let ``test String.Format works`` () = String.Format("{2} is to {1} what {1} is to {0}", arg1, arg2, arg3) |> equal "Babel is to Fable what Fable is to F#" +[] +let ``test String.Format with IFormatProvider works`` () = + let ci = System.Globalization.CultureInfo.InvariantCulture + // The leading IFormatProvider argument must be ignored, not treated as the format string. + String.Format(ci, "{0} is to {1}", "F#", "Fable") |> equal "F# is to Fable" + String.Format(ci, "{0:N2}", 1234.5) |> equal "1,234.50" + String.Format(ci, "{0:C2}", 1234.5) |> equal "¤1,234.50" + String.Format(ci, "{0:E2}", 1234.5) |> equal "1.23E+003" + String.Format(ci, "{0:X4}", 123) |> equal "007B" + +[] +let ``test String.Format with standard numeric specifiers works`` () = + let ci = System.Globalization.CultureInfo.InvariantCulture + String.Format(ci, "{0:D5}", 123) |> equal "00123" + String.Format(ci, "{0:F2}", 3.14159) |> equal "3.14" + String.Format(ci, "{0:N2}", -1234.5) |> equal "-1,234.50" + String.Format(ci, "{0:B}", 5) |> equal "101" + [] let ``test String.Split with remove empties works`` () = let result = "a b c d ".Split([|" "|], StringSplitOptions.RemoveEmptyEntries) diff --git a/tests/Js/Main/StringTests.fs b/tests/Js/Main/StringTests.fs index 41f826563e..591443b791 100644 --- a/tests/Js/Main/StringTests.fs +++ b/tests/Js/Main/StringTests.fs @@ -320,6 +320,22 @@ let tests = testList "Strings" [ $"I think {3.0 + 0.14} is close to %.8f{Math.PI}!".Replace(",", ".") |> equal "I think 3.14 is close to 3.14159265!" + testCase "string interpolation works with .NET format specifiers" <| fun () -> // See Fable.Python #36 + let i = 123 + $"{i:X4}" |> equal "007B" + $"{i:x4}" |> equal "007b" + $"{i:D5}" |> equal "00123" + $"{5:B}" |> equal "101" + $"\\u{i:X4}" |> equal "\\u007B" + $"{3.14159:F2}" |> equal "3.14" + + testCase "string interpolation works with alignment" <| fun () -> + let i = 123 + $"[{i,6}]" |> equal "[ 123]" + $"[{i,-6}]" |> equal "[123 ]" + $"[{i,6:X4}]" |> equal "[ 007B]" + $"[{i,-6:X4}]" |> equal "[007B ]" + testCase "string interpolation works with anonymous records" <| fun () -> let person = {| diff --git a/tests/Python/TestString.fs b/tests/Python/TestString.fs index d24644b914..35dc8495b8 100644 --- a/tests/Python/TestString.fs +++ b/tests/Python/TestString.fs @@ -109,6 +109,23 @@ let ``test string interpolation works with inline expressions`` () = |> equal "I think 3.14 is close to 3.14159265!" #endif +[] +let ``test string interpolation works with .NET format specifiers`` () = // See Fable.Python #36 + let i = 123 + $"{i:X4}" |> equal "007B" + $"{i:x4}" |> equal "007b" + $"{i:D5}" |> equal "00123" + $"\\u{i:X4}" |> equal "\\u007B" + $"{3.14159:F2}" |> equal "3.14" + +[] +let ``test string interpolation works with alignment`` () = + let i = 123 + $"[{i,6}]" |> equal "[ 123]" + $"[{i,-6}]" |> equal "[123 ]" + $"[{i,6:X4}]" |> equal "[ 007B]" + $"[{i,-6:X4}]" |> equal "[007B ]" + [] let ``test string interpolation works with anonymous records`` () = let person = From e170885575bca9d9824bc13ad1aebd1dd936db4d Mon Sep 17 00:00:00 2001 From: Dag Brattli Date: Sat, 27 Jun 2026 19:16:47 +0200 Subject: [PATCH 2/4] fix(rust): support .NET format specifiers in interpolated strings Interpolated holes carrying a .NET format specifier (e.g. `$"{i:X4}"`) leaked the `(X4)` as literal text on the Rust target, because the interpolation regex only matched empty `%P()` holes. `makeRustFormatString` now recognises `%P()` holes and maps the standard numeric specifiers Rust's `format!` can express (X x D d F f E e G g B b O o R r) into the placeholder. Alignment already worked via the width group and is kept. Specifiers Rust's `format!` can't represent (N, C, P, custom numeric patterns) and the combined alignment+format case are left as best-effort; full parity would need a runtime formatter and is deferred to a follow-up. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Fable.Transforms/Rust/Replacements.fs | 156 +++++++++++++++++----- tests/Rust/tests/src/StringTests.fs | 16 +++ 2 files changed, 142 insertions(+), 30 deletions(-) diff --git a/src/Fable.Transforms/Rust/Replacements.fs b/src/Fable.Transforms/Rust/Replacements.fs index a614ebd0a5..1a9613f6e4 100644 --- a/src/Fable.Transforms/Rust/Replacements.fs +++ b/src/Fable.Transforms/Rust/Replacements.fs @@ -809,10 +809,78 @@ let fsharpModule (com: ICompiler) (ctx: Context) r (t: Type) (i: CallInfo) (this Helper.LibCall(com, moduleName, memberName, t, args, i.SignatureArgTypes, ?loc = r) |> Some +// Maps a .NET standard numeric format specifier (e.g. "X4", "D5", "F2") to a +// Rust format spec body { flags; width; precision; type } so it can be merged +// into a `format!` placeholder. Returns None for specifiers Rust's `format!` +// can't express (N, C, P, custom numeric patterns) so the value is emitted plain. +let private mapDotnetSpecToRust (spec: string) = + match Regex.Match(spec, @"^([A-Za-z])(\d*)$") with + | m when m.Success -> + let typ = m.Groups[1].Value + let n = m.Groups[2].Value + // A width on hex/binary/octal/decimal means zero-pad to that width. + let zeroPad = + if n <> "" then + "0" + else + "" + + match typ with + | "X" + | "x" -> Some(zeroPad, n, "", typ) + | "B" + | "b" -> Some(zeroPad, n, "", "b") + | "O" + | "o" -> Some(zeroPad, n, "", "o") + | "D" + | "d" -> Some(zeroPad, n, "", "") + | "F" + | "f" -> + Some( + "", + "", + "." + + (if n = "" then + "2" + else + n), + "" + ) + | "E" -> + Some( + "", + "", + "." + + (if n = "" then + "6" + else + n), + "E" + ) + | "e" -> + Some( + "", + "", + "." + + (if n = "" then + "6" + else + n), + "e" + ) + | "G" + | "g" + | "R" + | "r" -> Some("", "", "", "") + | _ -> None + | _ -> None + let makeRustFormatString interpolated (fmt: string) = - let pattern1 = @"([^%]?)%([0+\- ]*)(\*|\d+)?(\.\d+)?(\w)" + let pattern1 = + @"(?
[^%]?)%(?[0+\- ]*)(?\*|\d+)?(?\.\d+)?(?\w)"
 
-    let pattern2 = @"([^%]?)%([0+\- ]*)(\*|\d+)?(\.\d+)?(?:P\(\)|(\w)(?:%P\(\))?)"
+    let pattern2 =
+        @"(?
[^%]?)%(?[0+\- ]*)(?\*|\d+)?(?\.\d+)?(?:P\((?[^)]*)\)|(?\w)(?:%P\(\))?)"
 
     let pattern =
         if interpolated then
@@ -844,35 +912,63 @@ let makeRustFormatString interpolated (fmt: string) =
             pattern,
             fun m ->
                 argCount <- argCount + 1
-                let g1 = m.Groups[1].Value
-                let g2 = m.Groups[2].Value |> formatFlags
-                let g3 = m.Groups[3].Value.Replace("*", "$") // width parameter
-                let g4 = m.Groups[4].Value
-                let g5 = m.Groups[5].Value
-
-                let g4 =
-                    if String.IsNullOrEmpty(g4) && (g5 = "f" || g5 = "F") then
-                        ".6"
-                    else
-                        g4
-
-                let g5 =
-                    match g5 with
-                    | "A" -> "?"
-                    | "B" -> "b"
-                    | ("b" | "c" | "d" | "i" | "s" | "u") -> ""
-                    | ("o" | "x" | "X" | "e" | "E") as t -> t
-                    | _ -> ""
-
-                let argFmt =
-                    let formatting = g2 + g3 + g4 + g5
-
-                    if String.IsNullOrEmpty(formatting) then
-                        g1 + "{}"
+                let pre = m.Groups["pre"].Value
+                let flags = m.Groups["flags"].Value |> formatFlags
+                let width = m.Groups["width"].Value.Replace("*", "$") // width parameter
+                let prec = m.Groups["prec"].Value
+
+                let formatting =
+                    if m.Groups["dotnet"].Success then
+                        // .NET interpolation hole: %P() from e.g. $"{x,6:X4}"
+                        let dotnet = m.Groups["dotnet"].Value
+
+                        if dotnet = "" then
+                            // Plain or alignment-only hole, e.g. %P() or %6P()
+                            flags + width + prec
+                        else
+                            match mapDotnetSpecToRust dotnet with
+                            | Some(specFlags, specWidth, specPrec, specType) ->
+                                // An explicit alignment width takes the field width (space-padded);
+                                // otherwise use the specifier's own (zero-padded) width.
+                                let f =
+                                    if width <> "" then
+                                        flags
+                                    else
+                                        specFlags
+
+                                let w =
+                                    if width <> "" then
+                                        width
+                                    else
+                                        specWidth
+
+                                f + w + specPrec + specType
+                            | None ->
+                                // Unsupported specifier: keep any alignment, drop the rest.
+                                flags + width + prec
                     else
-                        g1 + "{:" + formatting + "}"
-
-                argFmt
+                        let typ = m.Groups["type"].Value
+
+                        let prec =
+                            if String.IsNullOrEmpty(prec) && (typ = "f" || typ = "F") then
+                                ".6"
+                            else
+                                prec
+
+                        let typ =
+                            match typ with
+                            | "A" -> "?"
+                            | "B" -> "b"
+                            | ("b" | "c" | "d" | "i" | "s" | "u") -> ""
+                            | ("o" | "x" | "X" | "e" | "E") as t -> t
+                            | _ -> ""
+
+                        flags + width + prec + typ
+
+                if String.IsNullOrEmpty(formatting) then
+                    pre + "{}"
+                else
+                    pre + "{:" + formatting + "}"
         )
 
     rustFmt, argCount
diff --git a/tests/Rust/tests/src/StringTests.fs b/tests/Rust/tests/src/StringTests.fs
index 8d66db7a9f..4902fa7213 100644
--- a/tests/Rust/tests/src/StringTests.fs
+++ b/tests/Rust/tests/src/StringTests.fs
@@ -346,6 +346,22 @@ let ``string interpolation works with inline expressions`` () =
     $"I think {3.0 + 0.14} is close to {float32 Math.PI}!"
     |> equal "I think 3.14 is close to 3.1415927!"
 
+[]
+let ``string interpolation works with .NET format specifiers`` () = // See Fable.Python #36
+    let i = 123
+    $"{i:X4}" |> equal "007B"
+    $"{i:x4}" |> equal "007b"
+    $"{i:D5}" |> equal "00123"
+    $"{5:B}" |> equal "101"
+    $"u{i:X4}" |> equal "u007B"
+    $"{3.14159:F2}" |> equal "3.14"
+
+[]
+let ``string interpolation works with alignment`` () =
+    let i = 123
+    $"[{i,6}]" |> equal "[   123]"
+    $"[{i,-6}]" |> equal "[123   ]"
+
 []
 let ``string interpolation works with anonymous records`` () =
     let person =

From a9cd1db0c78dfddc7bbc0c84a4de93b001a1a8b0 Mon Sep 17 00:00:00 2001
From: Dag Brattli 
Date: Sat, 27 Jun 2026 19:32:06 +0200
Subject: [PATCH 3/4] fix(python): support the binary ("B") format specifier in
 String.format

The Rust extension's .NET format dispatcher handled d/D, x/X, f/F and g/G but
not b/B, so `$"{i:B}"` / `String.Format("{0:B}", i)` returned the raw value
instead of its binary representation. Added `apply_binary_format` with optional
zero-padding (e.g. `B8`).

Also add `*.rs` to the Python fable-library input patterns so edits to the Rust
extension sources trigger a maturin rebuild (previously only `.py`/`.fs` changes
were tracked, so Rust changes were silently cached).

Co-Authored-By: Claude Opus 4.8 (1M context) 
---
 src/Fable.Build/FableLibrary/Python.fs |  2 ++
 src/fable-library-py/src/strings.rs    | 15 +++++++++++++++
 tests/Python/TestString.fs             |  1 +
 3 files changed, 18 insertions(+)

diff --git a/src/Fable.Build/FableLibrary/Python.fs b/src/Fable.Build/FableLibrary/Python.fs
index 9f5c9a3d91..e36f528206 100644
--- a/src/Fable.Build/FableLibrary/Python.fs
+++ b/src/Fable.Build/FableLibrary/Python.fs
@@ -17,6 +17,8 @@ type BuildFableLibraryPython(?skipCore: bool, ?postFableBuildStage: unit -> unit
                 [
                     Path.Combine("src", "fable-library-py", "**", "*.py")
                     Path.Combine("src", "fable-library-py", "**", "*.fs")
+                    // Rust extension sources, so edits to them trigger a maturin rebuild
+                    Path.Combine("src", "fable-library-py", "**", "*.rs")
                     Path.Combine("src", "fable-library-ts", "**", "*.fs")
                 ]
         )
diff --git a/src/fable-library-py/src/strings.rs b/src/fable-library-py/src/strings.rs
index 5d7d88963d..ae9ebffbb4 100644
--- a/src/fable-library-py/src/strings.rs
+++ b/src/fable-library-py/src/strings.rs
@@ -1003,6 +1003,7 @@ mod formatting {
         match format_type {
             "d" | "D" => apply_decimal_format(value, params),
             "x" | "X" => apply_hexadecimal_format(value, params, format_type == "X"),
+            "b" | "B" => apply_binary_format(value, params),
             "f" | "F" => apply_fixed_point_format(value, params),
             "g" | "G" => apply_general_format(value),
             _ => value.to_string(), // Fallback for unknown formats
@@ -1050,6 +1051,20 @@ mod formatting {
         }
     }
 
+    /// Format binary integers with optional zero-padding (.NET "B"/"b" specifier).
+    fn apply_binary_format(value: &str, width_str: &str) -> String {
+        match value.parse::() {
+            Ok(num) => match width_str.is_empty() {
+                true => format!("{:b}", num),
+                false => width_str
+                    .parse::()
+                    .map(|width| format!("{:0width$b}", num, width = width))
+                    .unwrap_or_else(|_| format!("{:b}", num)),
+            },
+            Err(_) => value.to_string(),
+        }
+    }
+
     /// Format fixed-point numbers with optional precision.
     fn apply_fixed_point_format(value: &str, precision_str: &str) -> String {
         match value.parse::() {
diff --git a/tests/Python/TestString.fs b/tests/Python/TestString.fs
index 35dc8495b8..a8e09b0abc 100644
--- a/tests/Python/TestString.fs
+++ b/tests/Python/TestString.fs
@@ -115,6 +115,7 @@ let ``test string interpolation works with .NET format specifiers`` () = // See
     $"{i:X4}" |> equal "007B"
     $"{i:x4}" |> equal "007b"
     $"{i:D5}" |> equal "00123"
+    $"{5:B}" |> equal "101"
     $"\\u{i:X4}" |> equal "\\u007B"
     $"{3.14159:F2}" |> equal "3.14"
 

From 5df0a041bcec173776af25312e1930f39d1333c8 Mon Sep 17 00:00:00 2001
From: Dag Brattli 
Date: Sun, 28 Jun 2026 09:25:39 +0200
Subject: [PATCH 4/4] fix(beam): use .NET general-format semantics for the G/g
 specifier

The G/g specifier delegated to format_raw/3 with C-style %g semantics
(precision = decimal places, forced scientific at large magnitudes),
so e.g. {0:G} of 1234567 produced "1.23457E+06" instead of "1234567".

Add format_general/3 implementing .NET semantics: precision is
significant digits, fixed-point or scientific is chosen by exponent
(scientific when exp < -4 or exp >= precision), trailing zeros are
trimmed, the exponent is 2-digit minimum, and a bare {0:G} keeps the
default representation (integers in full).

Co-Authored-By: Claude Opus 4.8 (1M context) 
---
 src/fable-library-beam/fable_string.erl | 45 ++++++++++++++++++++++++-
 tests/Beam/StringTests.fs               | 12 +++++++
 2 files changed, 56 insertions(+), 1 deletion(-)

diff --git a/src/fable-library-beam/fable_string.erl b/src/fable-library-beam/fable_string.erl
index 73c47d6589..870ff0da88 100644
--- a/src/fable-library-beam/fable_string.erl
+++ b/src/fable-library-beam/fable_string.erl
@@ -637,7 +637,7 @@ apply_dotnet_numeric(Type, Prec, Value) when Type =:= $E; Type =:= $e ->
     P = default_prec(Prec, 6),
     format_exponential(Type, P, Value);
 apply_dotnet_numeric(Type, Prec, Value) when Type =:= $G; Type =:= $g ->
-    format_raw(Type, Prec, Value);
+    format_general(Type, Prec, Value);
 apply_dotnet_numeric(Type, Prec, Value) when Type =:= $N; Type =:= $n ->
     P = default_prec(Prec, 2),
     {IntB, DecB} = split_int_dec(format_raw($f, P, Value)),
@@ -691,6 +691,49 @@ format_exponential(TypeChar, P, Value) ->
         end,
     <>.
 
+%% format_general/3 — .NET "G"/"g" (general) format. The value is shown with
+%% `Prec` significant digits, choosing fixed-point or scientific notation
+%% (scientific when the exponent is < -4 or >= Prec), with trailing zeros
+%% removed. Unlike "E"/"e", the exponent uses a 2-digit minimum. When no
+%% precision is given, the value uses its default representation (integers in
+%% full), matching bare interpolation (`$"{x}"`).
+format_general(_Type, Prec, Value) when Prec =< 0 ->
+    to_string(Value);
+format_general(Type, Prec, Value) ->
+    F = float(Value),
+    case F == 0.0 of
+        true ->
+            <<"0">>;
+        false ->
+            Exp = trunc(math:floor(math:log10(abs(F)))),
+            EChar =
+                case Type of
+                    $G -> $E;
+                    _ -> $e
+                end,
+            case Exp < -4 orelse Exp >= Prec of
+                true -> general_scientific(EChar, Prec, F);
+                false -> general_fixed(Prec, F, Exp)
+            end
+    end.
+
+%% general_fixed/3 — Fixed-point notation with (Prec - 1 - Exp) decimals so the
+%% result carries Prec significant digits, trailing zeros trimmed.
+general_fixed(Prec, F, Exp) ->
+    Decimals = max(0, Prec - 1 - Exp),
+    trim_trailing_zeros(format_raw($f, Decimals, F)).
+
+%% general_scientific/3 — Scientific notation with Prec significant digits
+%% (one leading digit + Prec-1 decimals), trailing zeros trimmed, and a
+%% 2-digit minimum exponent (e.g. "1.23E+06").
+general_scientific(EChar, Prec, F) ->
+    Sci = float_to_binary(F, [{scientific, max(0, Prec - 1)}]),
+    [Mantissa0, ExpPart] = binary:split(Sci, <<"e">>),
+    Mantissa = trim_trailing_zeros(Mantissa0),
+    <> = ExpPart,
+    ExpPadded = pad_left(ExpDigits, 2, $0),
+    <>.
+
 abs_num(V) when V < 0 -> -V;
 abs_num(V) -> V.
 
diff --git a/tests/Beam/StringTests.fs b/tests/Beam/StringTests.fs
index af4986c546..206e0e9a30 100644
--- a/tests/Beam/StringTests.fs
+++ b/tests/Beam/StringTests.fs
@@ -662,6 +662,18 @@ let ``test String.Format with standard numeric specifiers works`` () =
     String.Format(ci, "{0:N2}", -1234.5) |> equal "-1,234.50"
     String.Format(ci, "{0:B}", 5) |> equal "101"
 
+[]
+let ``test String.Format with the general (G) specifier works`` () =
+    let ci = System.Globalization.CultureInfo.InvariantCulture
+    // No precision: integers shown in full, not forced into scientific notation.
+    String.Format(ci, "{0:G}", 1234567) |> equal "1234567"
+    // Precision is significant digits, choosing fixed-point...
+    String.Format(ci, "{0:G4}", 123.456) |> equal "123.5"
+    String.Format(ci, "{0:G3}", 3.14159) |> equal "3.14"
+    String.Format(ci, "{0:G2}", 0.0001234) |> equal "0.00012"
+    // ...or scientific (2-digit exponent) when the magnitude is >= 10^precision.
+    String.Format(ci, "{0:G3}", 1234567.0) |> equal "1.23E+06"
+
 []
 let ``test String.Split with remove empties works`` () =
     let result = "a b c  d ".Split([|" "|], StringSplitOptions.RemoveEmptyEntries)