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.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.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/src/fable-library-beam/fable_string.erl b/src/fable-library-beam/fable_string.erl
index 6b6a61b0c5..870ff0da88 100644
--- a/src/fable-library-beam/fable_string.erl
+++ b/src/fable-library-beam/fable_string.erl
@@ -540,25 +540,244 @@ 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_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)),
+    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,
+    <>.
+
+%% 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.
+
+%% 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/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/Beam/StringTests.fs b/tests/Beam/StringTests.fs
index 1773c3e410..206e0e9a30 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,36 @@ 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.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)
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..a8e09b0abc 100644
--- a/tests/Python/TestString.fs
+++ b/tests/Python/TestString.fs
@@ -109,6 +109,24 @@ 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"
+    $"{5:B}" |> equal "101"
+    $"\\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 =
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 =