Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[JS/TS] Fix N and n numeric format + add C, c, B, b numeric format #4068

Merged
merged 8 commits into from
Mar 3, 2025
10 changes: 10 additions & 0 deletions src/Fable.Cli/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

* [JS/TS] Add support for `CaseRules.LowerAll` on `StringEnums` (by @shayanhabibi)
* [Rust] Support Rust 2024 language edition (by @ncave)
* [JS/TS] Add `C` and `c` format for numeric types (by @MangelMaxime)
* [JS/TS] Add `B` and `b` format for numeric types (by @MangelMaxime)
* [JS/TS] Add `n` format for numeric types (by @MangelMaxime)
* [JS/TS] Generate compiler error when detecting an invalid/unsupported format specifier for numeric types (by @MangelMaxime)

### Fixed

Expand All @@ -34,6 +38,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
)
```

* [JS/TS] Fix numeric formats (by @MangelMaxime)

### Changed

* [JS/TS] Throw an error is an invalid Numeric format is provided (mimic .NET behavior) (by @MangelMaxime)

## 5.0.0-alpha.10 - 2025-02-16

### Added
Expand Down
10 changes: 10 additions & 0 deletions src/Fable.Compiler/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

* [JS/TS] Add support for `CaseRules.LowerAll` on `StringEnums` (by @shayanhabibi)
* [Rust] Support Rust 2024 language edition (by @ncave)
* [JS/TS] Add `C` and `c` format for numeric types (by @MangelMaxime)
* [JS/TS] Add `B` and `b` format for numeric types (by @MangelMaxime)
* [JS/TS] Add `n` format for numeric types (by @MangelMaxime)
* [JS/TS] Generate compiler error when detecting an invalid/unsupported format specifier for numeric types (by @MangelMaxime)

### Fixed

Expand All @@ -34,6 +38,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
)
```

* [JS/TS] Fix numeric formats (by @MangelMaxime)

### Changed

* [JS/TS] Throw an error is an invalid Numeric format is provided (mimic .NET behavior) (by @MangelMaxime)

## 5.0.0-alpha.10 - 2025-02-16

### Added
Expand Down
102 changes: 76 additions & 26 deletions src/Fable.Transforms/Replacements.fs
Original file line number Diff line number Diff line change
Expand Up @@ -2398,6 +2398,76 @@ let parseBool (com: ICompiler) (ctx: Context) r t (i: CallInfo) (thisArg: Expr o
| ("Compare" | "CompareTo" | "Equals" | "GetHashCode"), _ -> valueTypes com ctx r t i thisArg args
| _ -> None

let numericStringFormat (com: ICompiler) (ctx: Context) r t (i: CallInfo) (thisArg: Expr option) format =
let libCallFormat () =
let format = emitExpr r String [ format ] "'{0:' + $0 + '}'"

Helper.LibCall(
com,
"String",
"format",
t,
[ format; thisArg.Value ],
[ format.Type; thisArg.Value.Type ],
?loc = r
)
|> Some

match format with
| StringConst format ->
let m = Regex.Match(format, "^(?<token>[a-zA-Z])(?<precision>\d{0,2})$")

if m.Success then
let token = m.Groups.["token"].Value

let numberKind =
match i.DeclaringEntityFullName with
| Patterns.DicContains FSharp2Fable.TypeHelpers.numberTypes kind -> kind
| x -> failwithf $"Unexpected type in parse: %A{x}"

let errorOpt =
match token.ToLower() with
| "b" ->
match numberKind with
| Integers _ -> None
| BigIntegers _ -> "with binary format specifier is not supported by Fable" |> Some
| _ -> "does not support binary format specifier" |> Some

| "c" -> None
| "d" ->
match numberKind with
| Integers _
| BigIntegers _ -> None
| _ -> "does not support decimal format specifier" |> Some
| "e" ->
match numberKind with
| BigIntegers _ -> "does not support exponential format specifier" |> Some
| _ -> None
| "f"
| "g"
| "n"
| "p" -> None
| "r" -> "with round-trip format specifier is not support by Fable" |> Some
| "x" ->
match numberKind with
| Integers _
| BigIntegers _ -> None
| _ -> "does not support hexadecimal format specifier" |> Some
| _ -> "received an unknown format specifier" |> Some

match errorOpt with
| Some message ->
$"%s{i.DeclaringEntityFullName}.ToString %s{message}"
|> addError com ctx.InlinePath r

None
| None -> libCallFormat ()
else
libCallFormat ()

| _ -> libCallFormat ()


let parseNum (com: ICompiler) (ctx: Context) r t (i: CallInfo) (thisArg: Expr option) (args: Expr list) =
let parseCall meth str args style =
let kind =
Expand Down Expand Up @@ -2496,19 +2566,9 @@ let parseNum (com: ICompiler) (ctx: Context) r t (i: CallInfo) (thisArg: Expr op
| "Pow", _ ->
Helper.GlobalCall("Math", t, args, i.SignatureArgTypes, memb = "pow", ?loc = r)
|> Some
| "ToString", [ ExprTypeAs(String, format) ] ->
let format = emitExpr r String [ format ] "'{0:' + $0 + '}'"

Helper.LibCall(
com,
"String",
"format",
t,
[ format; thisArg.Value ],
[ format.Type; thisArg.Value.Type ],
?loc = r
)
|> Some
| "ToString", [ ExprTypeAs(String, format) ]
| "ToString", [ ExprTypeAs(String, format); _ (* Culture info *) ] ->
numericStringFormat com ctx r t i thisArg format
| "ToString", _ -> Helper.GlobalCall("String", String, [ thisArg.Value ], ?loc = r) |> Some
| ("Compare" | "CompareTo" | "Equals" | "GetHashCode"), _ -> valueTypes com ctx r t i thisArg args
| _ -> None
Expand Down Expand Up @@ -2554,19 +2614,9 @@ let decimals (com: ICompiler) (ctx: Context) r (t: Type) (i: CallInfo) (thisArg:

Helper.LibCall(com, "Decimal", meth, t, args, i.SignatureArgTypes, ?loc = r)
|> Some
| "ToString", [ ExprTypeAs(String, format) ] ->
let format = emitExpr r String [ format ] "'{0:' + $0 + '}'"

Helper.LibCall(
com,
"String",
"format",
t,
[ format; thisArg.Value ],
[ format.Type; thisArg.Value.Type ],
?loc = r
)
|> Some
| "ToString", [ ExprTypeAs(String, format) ]
| "ToString", [ ExprTypeAs(String, format); _ (* Culture info *) ] ->
numericStringFormat com ctx r t i thisArg format
| "ToString", _ -> Helper.InstanceCall(thisArg.Value, "toString", String, [], ?loc = r) |> Some
| ("Compare" | "CompareTo" | "Equals" | "GetHashCode"), _ -> valueTypes com ctx r t i thisArg args
| _, _ -> None
Expand Down
11 changes: 8 additions & 3 deletions src/fable-library-ts/Numeric.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ export function isNumeric(x: any) {
return typeof x === "number" || typeof x === "bigint" || x?.[symbol];
}

export function isIntegral(x: Numeric) {
// Not perfect, because in JS we can't distinguish between 1.0 and 1
return typeof x === "number" && Number.isInteger(x) || typeof x === "bigint";
}

export function compare(x: Numeric, y: number) {
if (typeof x === "number") {
return x < y ? -1 : (x > y ? 1 : 0);
Expand All @@ -42,7 +47,7 @@ export function toFixed(x: Numeric, dp?: number) {
if (typeof x === "number") {
return x.toFixed(dp);
} else if (typeof x === "bigint") {
return x;
return x.toString();
} else {
return x[symbol]().toFixed(dp);
}
Expand All @@ -52,7 +57,7 @@ export function toPrecision(x: Numeric, sd?: number) {
if (typeof x === "number") {
return x.toPrecision(sd);
} else if (typeof x === "bigint") {
return x;
return x.toString();
} else {
return x[symbol]().toPrecision(sd);
}
Expand All @@ -77,4 +82,4 @@ export function toHex(x: Numeric) {
} else {
return x[symbol]().toHex();
}
}
}
91 changes: 81 additions & 10 deletions src/fable-library-ts/String.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { toString as dateToString } from "./Date.js";
import { compare as numericCompare, isNumeric, multiply, Numeric, toExponential, toFixed, toHex, toPrecision } from "./Numeric.js";
import { compare as numericCompare, isNumeric, isIntegral, multiply, Numeric, toExponential, toFixed, toHex, toPrecision } from "./Numeric.js";
import { escape } from "./RegExp.js";
import { toString } from "./Types.js";

Expand Down Expand Up @@ -295,6 +295,19 @@ export function fsFormat(str: string) {
};
}

function splitIntAndDecimalPart(value: string) {
let [repInt, repDecimal] = value.split(".");
repDecimal === undefined && (repDecimal = "");
return {
integral: repInt,
decimal: repDecimal
}
}

function thousandSeparate(value: string) {
return value.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
}

export function format(str: string | object, ...args: any[]) {
let str2: string;
if (typeof str === "object") {
Expand All @@ -310,31 +323,89 @@ export function format(str: string | object, ...args: any[]) {
throw new Error("Index must be greater or equal to zero and less than the arguments' length.")
}
let rep = args[idx];
let parts;
if (isNumeric(rep)) {
precision = precision == null ? null : parseInt(precision, 10);
precision = precision == "" ? null : parseInt(precision, 10);
switch (format) {
case "b": case "B":
if (!isIntegral(rep)) {
throw new Error("Format specifier was invalid.");
}
rep = (rep >>> 0).toString(2).replace(/^0+/, "").padStart(precision || 1, "0");
break;
case "c": case "C":
const isNegative = isLessThan(rep, 0);
if (isLessThan(rep, 0)) {
rep = multiply(rep, -1);
}
precision = precision == null ? 2 : precision;
rep = toFixed(rep, precision);
parts = splitIntAndDecimalPart(rep);
rep = "¤" + thousandSeparate(parts.integral) + "." + padRight(parts.decimal, precision, "0");
if (isNegative) {
rep = "(" + rep + ")";
}
break;
case "d": case "D":
if (!isIntegral(rep)) {
throw new Error("Format specifier was invalid.");
}
rep = String(rep);
if (precision != null) {
if (rep.startsWith("-")) {
rep = "-" + padLeft(rep.substring(1), precision, "0");
} else {
rep = padLeft(rep, precision, "0");
}
}
break;
case "e": case "E":
rep = precision != null ? toExponential(rep, precision) : toExponential(rep);
break;
case "f": case "F":
precision = precision != null ? precision : 2;
rep = toFixed(rep, precision);
if (precision > 0) {
parts = splitIntAndDecimalPart(rep);
rep = parts.integral + "." + padRight(parts.decimal, precision, "0");
}
break;
case "g": case "G":
rep = precision != null ? toPrecision(rep, precision) : toPrecision(rep);
// TODO: Check why some numbers are formatted with decimal part
rep = trimEnd(trimEnd(rep, "0"), ".");
break;
case "e": case "E":
rep = precision != null ? toExponential(rep, precision) : toExponential(rep);
case "n": case "N":
precision = precision != null ? precision : 2;
rep = toFixed(rep, precision);
parts = splitIntAndDecimalPart(rep);
rep = thousandSeparate(parts.integral) + "." + padRight(parts.decimal, precision, "0");
break;
case "p": case "P":
precision = precision != null ? precision : 2;
rep = toFixed(multiply(rep, 100), precision) + " %";
break;
case "d": case "D":
rep = precision != null ? padLeft(String(rep), precision, "0") : String(rep);
rep = toFixed(multiply(rep, 100), precision)
parts = splitIntAndDecimalPart(rep);
rep = thousandSeparate(parts.integral) + "." + padRight(parts.decimal, precision, "0") + " %";
break;
case "r": case "R":
throw new Error("The round-trip format is not supported by Fable");
case "x": case "X":
rep = precision != null ? padLeft(toHex(rep), precision, "0") : toHex(rep);
if (format === "X") { rep = rep.toUpperCase(); }
if (!isIntegral(rep)) {
throw new Error("Format specifier was invalid.");
}
precision = precision != null ? precision : 2;
rep = padLeft(toHex(rep), precision, "0");
if (format === "X") {
rep = rep.toUpperCase();
}
break;
default:
// If we have format and were not able to handle it throw
// See: https://learn.microsoft.com/en-us/dotnet/standard/base-types/standard-numeric-format-strings#standard-format-specifiers
if (format) {
throw new Error("Format specifier was invalid.");
}

if (pattern) {
let sign = "";
rep = (pattern as string).replace(/([0#,]+)(\.[0#]+)?/, (_, intPart: string, decimalPart: string) => {
Expand Down
Loading
Loading