From fb4b8960fa9bdd88c48ed517d8658125b208fa51 Mon Sep 17 00:00:00 2001 From: Tuomas Hietanen Date: Wed, 15 Jul 2026 20:58:16 +0100 Subject: [PATCH 1/4] feat(rust): various codegen and runtime improvements (#4781) === changelog === - Cast `bool` to integer types; support `**` via `.powf` - Add `Decimal.IsInteger/IsEvenInteger/IsOddInteger/IsCanonical` - Fix two-switch decision trees dispatching to the wrong target - Fix reference-typed match bindings panicking via `mem::zeroed` - Fix string and non-ident option pattern matches emitting invalid Rust `match` arms - Fix `Convert.ToXxx(bool)` - Fix `Char.ToUpper/ToLower` truncating multi-char case mappings - Fix `Random.nextDouble` panic message - Fix `Map.find/tryFind` panicking on reference-typed values === changelog === Co-authored-by: Mangel Maxime --- src/Fable.Transforms/Rust/Fable2Rust.fs | 74 ++++++++++++++----- src/Fable.Transforms/Rust/Replacements.fs | 13 +++- src/fable-library-rust/src/Char.rs | 18 ++++- src/fable-library-rust/src/Decimal.rs | 10 ++- src/fable-library-rust/src/Map.fs | 37 +++++++--- src/fable-library-rust/src/Native.rs | 7 ++ src/fable-library-rust/src/Random.rs | 2 +- src/quicktest-rust/src/main.fs | 35 ++++++++- tests/Rust/tests/src/ArithmeticTests.fs | 24 ++++++ tests/Rust/tests/src/CharTests.fs | 11 +++ tests/Rust/tests/src/ControlFlowTests.fs | 89 +++++++++++++++++++++++ tests/Rust/tests/src/ConvertTests.fs | 20 +++++ tests/Rust/tests/src/MapTests.fs | 11 +++ tests/Rust/tests/src/StringTests.fs | 6 ++ 14 files changed, 314 insertions(+), 43 deletions(-) diff --git a/src/Fable.Transforms/Rust/Fable2Rust.fs b/src/Fable.Transforms/Rust/Fable2Rust.fs index 58aeba6934..632adbd4f9 100644 --- a/src/Fable.Transforms/Rust/Fable2Rust.fs +++ b/src/Fable.Transforms/Rust/Fable2Rust.fs @@ -1527,6 +1527,10 @@ module Util = | t1, t2 when t1 = t2 -> expr // no cast needed if types are the same | Fable.Number _, Fable.Number _ -> expr |> mkCastExpr ty | Fable.Char, Fable.Number(UInt32, Fable.NumberInfo.Empty) -> expr |> mkCastExpr ty + // bool -> integer (Rust allows `b as iN`/`b as uN`, yields 0/1) + | Fable.Boolean, + Fable.Number((Int8 | UInt8 | Int16 | UInt16 | Int32 | UInt32 | Int64 | UInt64 | Int128 | UInt128 | NativeInt | UNativeInt), + _) -> expr |> mkCastExpr ty | Fable.Tuple(ga1, false), Fable.Tuple(ga2, true) when ga1 = ga2 -> expr |> makeAsRef |> makeClone //.ToValueTuple() | Fable.Tuple(ga1, true), Fable.Tuple(ga2, false) when ga1 = ga2 -> expr |> makeLrcPtrValue com ctx //.ToTuple() @@ -1840,8 +1844,33 @@ module Util = makeLibCall com ctx genArgsOpt "Native" "null" [] let makeInit com ctx (typ: Fable.Type) = - let genArgsOpt = transformGenArgs com ctx [ typ ] - makeLibCall com ctx genArgsOpt "Native" "getZero" [] + // `Native::getZero` uses `core::mem::zeroed`, which panics at runtime when the + // type is a reference type (Rc/Arc/Box pointer). For such pre-declared match + // bindings we must emit a valid placeholder value instead (it is only there to + // satisfy definite-assignment and gets overwritten before it is ever read). + match typ with + | Fable.Any -> + // `dyn Any` is unsized, so `Native::null` (needs a sized `NullableRef`) does + // not apply; use a valid boxed-unit placeholder of type `LrcPtr`. + makeLibCall com ctx None "Native" "getZeroObj" [] + | _ -> + // Only concrete (sized) `Lrc`-wrapped reference types can use the null-ref + // placeholder. Unsized refs (interfaces, enumerators) and Arc/Box-wrapped + // types keep `getZero` (they don't reach this path in practice). + let isConcreteLrcRef = + match shouldBeRefCountWrapped com ctx typ with + | Some Lrc -> + match typ with + | Fable.DeclaredType(entRef, _) -> not (com.GetEntity(entRef)).IsInterface + | Replacements.Util.IsEnumerator _ -> false + | _ -> true + | _ -> false + + if isConcreteLrcRef then + makeNull com ctx typ + else + let genArgsOpt = transformGenArgs com ctx [ typ ] + makeLibCall com ctx genArgsOpt "Native" "getZero" [] let makeDefault com ctx (typ: Fable.Type) = let genArgsOpt = transformGenArgs com ctx [ typ ] @@ -2370,6 +2399,12 @@ module Util = | UnaryOperator.UnaryNotBitwise -> mkNotExpr expr // ?loc=range) | UnaryOperator.UnaryAddressOf -> expr |> mkAddrOfExpr + | Fable.Binary(BinaryOperator.BinaryExponent, leftExpr, rightExpr) -> + // Rust has no `**` operator; F# (**) is floating-point exponentiation. + let left = transformLeaveContext com ctx None leftExpr |> maybeAddParens leftExpr + let right = transformLeaveContext com ctx None rightExpr |> maybeAddParens rightExpr + mkMethodCallExpr "powf" None left [ right ] + | Fable.Binary(op, leftExpr, rightExpr) -> let kind = match op with @@ -2387,7 +2422,7 @@ module Util = | BinaryOperator.BinaryMultiply -> Rust.BinOpKind.Mul | BinaryOperator.BinaryDivide -> Rust.BinOpKind.Div | BinaryOperator.BinaryModulus -> Rust.BinOpKind.Rem - | BinaryOperator.BinaryExponent -> failwithf "BinaryExponent not supported. TODO: implement with pow." + | BinaryOperator.BinaryExponent -> failwith "unreachable: BinaryExponent handled above" | BinaryOperator.BinaryOrBitwise -> Rust.BinOpKind.BitOr | BinaryOperator.BinaryXorBitwise -> Rust.BinOpKind.BitXor | BinaryOperator.BinaryAndBitwise -> Rust.BinOpKind.BitAnd @@ -3360,12 +3395,15 @@ module Util = function | Fable.Operation(Fable.Binary(BinaryEqual, left, right), _, _, _) -> match left, right with - | _, Fable.Value((Fable.CharConstant _ | Fable.StringConstant _ | Fable.NumberConstant _), _) -> - Some(left, right) - | Fable.Value((Fable.CharConstant _ | Fable.StringConstant _ | Fable.NumberConstant _), _), _ -> - Some(right, left) + // NOTE: StringConstant is intentionally excluded — Rust match patterns can't + // represent a fable-library string (it lowers to a `string("...")` call), so + // string matches must use the if/else decision-tree path with real `==`. + | _, Fable.Value((Fable.CharConstant _ | Fable.NumberConstant _), _) -> Some(left, right) + | Fable.Value((Fable.CharConstant _ | Fable.NumberConstant _), _), _ -> Some(right, left) | _ -> None - | Fable.Test(expr, Fable.OptionTest isSome, r) -> + // Only a plain-ident scrutinee is convertible: transformSwitch can't recover the + // `Option` type from anything else, and would emit bogus `0_i32` patterns. + | Fable.Test(Fable.IdentExpr _ as expr, Fable.OptionTest isSome, r) -> let evalExpr = Fable.Get(expr, Fable.UnionTag, Fable.Number(Int32, Fable.NumberInfo.Empty), r) @@ -3545,18 +3583,16 @@ module Util = let ctx = { ctx with DecisionTargets = targets } transformSwitch com ctx (targetId |> Fable.IdentExpr) cases (Some defaultCase) - match tryTransformAsSwitch treeExpr with - | Some(evalExpr, cases, defaultCase) -> - // let cases = groupSwitchCases typ cases defaultCase - let switch1 = transformSwitch com ctx evalExpr cases (Some defaultCase) - - varDecls @ [ switch1 |> mkSemiStmt ] @ [ switch2 |> mkExprStmt ] - |> mkStmtBlockExpr - | None -> - let decisionTree = com.TransformExpr(ctx, treeExpr) + // NOTE: switch1 must ASSIGN matchResult (+ bound idents) via DecisionTreeSuccess, + // not execute the target bodies directly. Transforming the tree as a switch here + // (via tryTransformAsSwitch) would run the target bodies with still-unassigned + // bindings and leave matchResult = 0, so switch2 would always dispatch to target 0. + // Instead, transform the tree directly so DecisionTreeSuccess nodes become + // assignments (via transformDecisionTreeSuccess), then let switch2 dispatch. + let decisionTree = com.TransformExpr(ctx, treeExpr) - varDecls @ [ decisionTree |> mkSemiStmt ] @ [ switch2 |> mkExprStmt ] - |> mkStmtBlockExpr + varDecls @ [ decisionTree |> mkSemiStmt ] @ [ switch2 |> mkExprStmt ] + |> mkStmtBlockExpr let transformDecisionTree (com: IRustCompiler) ctx targets (treeExpr: Fable.Expr) : Rust.Expr = let treeExpr = simplifyDecisionTree treeExpr diff --git a/src/Fable.Transforms/Rust/Replacements.fs b/src/Fable.Transforms/Rust/Replacements.fs index fba43b41c7..0af218b01a 100644 --- a/src/Fable.Transforms/Rust/Replacements.fs +++ b/src/Fable.Transforms/Rust/Replacements.fs @@ -188,6 +188,7 @@ let convertTo com (ctx: Context) r t (args: Expr list) = match t with | Boolean -> match sourceType with + | Boolean -> args.Head | Number(Decimal, _) -> Helper.LibCall(com, "Decimal", "toBoolean", t, args, ?loc = r) | Number(BigInt, _) -> Helper.LibCall(com, "BigInt", "toBoolean", t, args, ?loc = r) | Number(_kind, _) -> Helper.LibCall(com, "Convert", "toBoolean", t, args, ?loc = r) @@ -239,6 +240,10 @@ let convertTo com (ctx: Context) r t (args: Expr list) = | Number(kind, _) -> match sourceType with + | Boolean -> + // .NET Convert.ToXxx(bool) yields 0/1; route via i32 so float targets also work + let code = TypeCast(args.Head, Int32.Number) + TypeCast(code, t) | Char -> let code = TypeCast(args.Head, UInt32.Number) TypeCast(code, t) @@ -1640,7 +1645,10 @@ let strings (com: ICompiler) (ctx: Context) r t (i: CallInfo) (thisArg: Expr opt Helper.LibCall(com, "String", "splitChars", t, [ c; arg1; arg2; arg3 ], ?loc = r) |> Some - // TODO: handle arrays of string separators with more than one element + // Remaining gap: the count-bearing string[] overload Split(string[], int, options) + // for multi-element / non-literal arrays. splitStrings has no count parameter, and a + // correct global left-to-right count across multiple separators is non-trivial. + // The common Split(string[], options) form is handled above via splitStrings. | _ -> None | "StartsWith", Some c, _ -> match args with @@ -2240,6 +2248,9 @@ let decimals (com: ICompiler) (ctx: Context) r (t: Type) (i: CallInfo) (thisArg: | ("get_Zero" | "get_One" | "get_MinusOne" | "get_MinValue" | "get_MaxValue"), _ -> Helper.LibValue(com, "Decimal", Naming.removeGetSetPrefix i.CompiledName, t) |> Some + | ("IsInteger" | "IsEvenInteger" | "IsOddInteger" | "IsCanonical" | "IsNegative" | "IsPositive"), _ -> + Helper.LibCall(com, "Decimal", Naming.lowerFirst i.CompiledName, t, args, i.SignatureArgTypes, ?loc = r) + |> Some | "get_Scale", [] -> match thisArg with | Some c -> diff --git a/src/fable-library-rust/src/Char.rs b/src/fable-library-rust/src/Char.rs index 580b4ce4a3..de0e15da59 100644 --- a/src/fable-library-rust/src/Char.rs +++ b/src/fable-library-rust/src/Char.rs @@ -409,8 +409,13 @@ pub mod Char_ { if (IsAscii(c)) { c.to_ascii_uppercase() } else { - //TODO: when Uppercase is more than one character - c.to_uppercase().next().unwrap() + // .NET Char.ToUpper never expands: when the full-case mapping is not a + // single char (e.g. 'ß' -> "SS"), the original char is returned unchanged. + let mut it = c.to_uppercase(); + match (it.next(), it.next()) { + (Some(u), None) => u, + _ => c, + } } } @@ -422,8 +427,13 @@ pub mod Char_ { if (IsAscii(c)) { c.to_ascii_lowercase() } else { - //TODO: when Lowercase is more than one character - c.to_lowercase().next().unwrap() + // .NET Char.ToLower never expands: when the full-case mapping is not a + // single char, the original char is returned unchanged. + let mut it = c.to_lowercase(); + match (it.next(), it.next()) { + (Some(l), None) => l, + _ => c, + } } } diff --git a/src/fable-library-rust/src/Decimal.rs b/src/fable-library-rust/src/Decimal.rs index d2a0a1ece9..f1d6bc2226 100644 --- a/src/fable-library-rust/src/Decimal.rs +++ b/src/fable-library-rust/src/Decimal.rs @@ -60,10 +60,12 @@ pub mod Decimal_ { pub fn isNegative(x: decimal) -> bool { x.is_sign_negative() } pub fn isPositive(x: decimal) -> bool { x.is_sign_positive() } - // pub fn isInteger(x: decimal) -> bool { false } //TODO: - // pub fn isEvenInteger(x: decimal) -> bool { false } //TODO: - // pub fn isOddInteger(x: decimal) -> bool { false } //TODO: - // pub fn isCanonical(x: decimal) -> bool { false } //TODO: + pub fn isInteger(x: decimal) -> bool { x.fract() == Decimal::ZERO } + pub fn isEvenInteger(x: decimal) -> bool { isInteger(x) && (x % Decimal::from(2)) == Decimal::ZERO } + pub fn isOddInteger(x: decimal) -> bool { isInteger(x) && (x % Decimal::from(2)) != Decimal::ZERO } + // Canonical means the stored scale is already minimal (no representable trailing + // zero digits), e.g. 1.0M and 1.20M are non-canonical, but 1M and 3.14M are. + pub fn isCanonical(x: decimal) -> bool { x.scale() == x.normalize().scale() } pub fn toInt8(x: decimal) -> i8 { x.to_i8().unwrap() } pub fn toUInt8(x: decimal) -> u8 { x.to_u8().unwrap() } diff --git a/src/fable-library-rust/src/Map.fs b/src/fable-library-rust/src/Map.fs index fbf1858960..e7e09ed839 100644 --- a/src/fable-library-rust/src/Map.fs +++ b/src/fable-library-rust/src/Map.fs @@ -157,21 +157,34 @@ let rec tryGetValue k (v: byref<'V>) (m: Map<'K, 'V>) = let throwKeyNotFound () = failwith SR.keyNotFound // [] -let find k (m: Map<'K, 'V>) = - let mutable v = Unchecked.defaultof<'V> +// Returns the value directly as an option rather than through a byref out-param. +// The out-param form (see tryGetValue) needs `Unchecked.defaultof<'V>`, which Fable-Rust +// lowers to getZero/mem::zeroed and panics for reference-type values (e.g. obj). This +// form works for both value and reference types. +let rec tryFindOpt k (m: Map<'K, 'V>) : 'V option = + match getRoot m with + | None -> None + | Some t -> + let c = compare k t.Key - if tryGetValue k &v m then - v - else - throwKeyNotFound () + if c = 0 then + Some t.Value + elif t.Height = 1 then + None + else + tryFindOpt + k + (if c < 0 then + t.Left + else + t.Right) -let tryFind k (m: Map<'K, 'V>) = - let mutable v = Unchecked.defaultof<'V> +let find k (m: Map<'K, 'V>) = + match tryFindOpt k m with + | Some v -> v + | None -> throwKeyNotFound () - if tryGetValue k &v m then - Some v - else - None +let tryFind k (m: Map<'K, 'V>) = tryFindOpt k m let item k (m: Map<'K, 'V>) = find k m diff --git a/src/fable-library-rust/src/Native.rs b/src/fable-library-rust/src/Native.rs index 12ff5aa9ea..42a249daf3 100644 --- a/src/fable-library-rust/src/Native.rs +++ b/src/fable-library-rust/src/Native.rs @@ -314,6 +314,13 @@ pub mod Native_ { unsafe { core::mem::zeroed() } // will panic on Rc/Arc/Box } + // A valid placeholder value of type `LrcPtr`. Used to pre-declare + // reference-typed (`obj`) match bindings, as `getZero`/`mem::zeroed` panics on + // the `Rc` fat pointer and `null` is not available for the unsized `dyn Any`. + pub fn getZeroObj() -> LrcPtr { + box_(()) + } + pub fn defaultOf() -> T { Default::default() } diff --git a/src/fable-library-rust/src/Random.rs b/src/fable-library-rust/src/Random.rs index 8c5f39bb59..2d38180625 100644 --- a/src/fable-library-rust/src/Random.rs +++ b/src/fable-library-rust/src/Random.rs @@ -108,7 +108,7 @@ pub mod Random_ { } pub fn nextDouble(&self) -> f64 { - unimplemented!("MSG") + unimplemented!("{}", MSG) } pub fn nextBytes(&self, buffer: Array) { diff --git a/src/quicktest-rust/src/main.fs b/src/quicktest-rust/src/main.fs index 5f68f2f6be..deef3d37b3 100644 --- a/src/quicktest-rust/src/main.fs +++ b/src/quicktest-rust/src/main.fs @@ -1,8 +1,39 @@ open System -let user = "World" +type RefC(v: int) = + member _.V = v + +let twoSwitchRef (x: RefC option) (b: bool) = + match x with + | Some c when b -> c.V + 1 + | Some c -> c.V + | None -> -1 + +type RefU = + | RA of RefC + | RB of RefC + +let orPatRef (u: RefU) = + match u with + | RA c + | RB c -> c.V + +type ObjU = + | OA of obj + | OB of obj + +let orPatObj (u: ObjU) : obj = + match u with + | OA o + | OB o -> o [] let main argv = - Console.WriteLine("Hello {0}!", user) + Console.WriteLine(twoSwitchRef (Some(RefC 5)) true) + Console.WriteLine(twoSwitchRef (Some(RefC 5)) false) + Console.WriteLine(twoSwitchRef None false) + Console.WriteLine(orPatRef (RA(RefC 5))) + Console.WriteLine(orPatRef (RB(RefC 7))) + Console.WriteLine((orPatObj (OA(box 9))) :? int) + Console.WriteLine((orPatObj (OB(box 11))) :? string) 0 diff --git a/tests/Rust/tests/src/ArithmeticTests.fs b/tests/Rust/tests/src/ArithmeticTests.fs index 1067e99a29..3acf821fe8 100644 --- a/tests/Rust/tests/src/ArithmeticTests.fs +++ b/tests/Rust/tests/src/ArithmeticTests.fs @@ -173,6 +173,30 @@ let ``Decimal literals can be generated`` () = 79228162514264337593543950335M |> equal Decimal.MaxValue -79228162514264337593543950335M |> equal Decimal.MinValue +[] +let ``Decimal integer predicates work`` () = + System.Decimal.IsInteger(3M) |> equal true + System.Decimal.IsInteger(3.5M) |> equal false + System.Decimal.IsEvenInteger(4M) |> equal true + System.Decimal.IsEvenInteger(3M) |> equal false + System.Decimal.IsOddInteger(3M) |> equal true + System.Decimal.IsOddInteger(4M) |> equal false + System.Decimal.IsOddInteger(-3M) |> equal true + System.Decimal.IsOddInteger(3.5M) |> equal false + +[] +let ``Decimal.IsCanonical works`` () = + // Canonical means the value's stored scale is already minimal (no representable + // trailing zero digits); non-canonical values have a scale larger than needed. + System.Decimal.IsCanonical(1M) |> equal true + System.Decimal.IsCanonical(100M) |> equal true + System.Decimal.IsCanonical(3.14M) |> equal true + System.Decimal.IsCanonical(-5M) |> equal true + System.Decimal.IsCanonical(0M) |> equal true + System.Decimal.IsCanonical(1.0M) |> equal false + System.Decimal.IsCanonical(1.20M) |> equal false + System.Decimal.IsCanonical(0.0M) |> equal false + [] let ``Decimal.ToString works`` () = string 001.23456M |> equal "1.23456" diff --git a/tests/Rust/tests/src/CharTests.fs b/tests/Rust/tests/src/CharTests.fs index 678547e930..b8de96f1a0 100644 --- a/tests/Rust/tests/src/CharTests.fs +++ b/tests/Rust/tests/src/CharTests.fs @@ -12,6 +12,17 @@ let ``Char.ToUpper works`` () = let ``Char.ToLower works`` () = Char.ToLower('B') |> equal 'b' +[] +let ``Char.ToUpper with 1-to-many mapping returns original char`` () = + // .NET Char.ToUpper never expands: 'ß' upper-maps to "SS", so the char is unchanged. + Char.ToUpper('ß') |> equal 'ß' + // A non-ASCII char with a single-char mapping still converts. + Char.ToUpper('é') |> equal 'É' + +[] +let ``Char.ToLower with non-ASCII single mapping works`` () = + Char.ToLower('É') |> equal 'é' + [] let ``Char.ToUpperInvariant works`` () = Char.ToUpperInvariant('b') |> equal 'B' diff --git a/tests/Rust/tests/src/ControlFlowTests.fs b/tests/Rust/tests/src/ControlFlowTests.fs index b53381e13b..f906be25d4 100644 --- a/tests/Rust/tests/src/ControlFlowTests.fs +++ b/tests/Rust/tests/src/ControlFlowTests.fs @@ -66,3 +66,92 @@ let ``while loop works`` () = i <- i + 1 total <- total + i total |> equal 55 + +// --- Regression tests for the Rust decision-tree/switch backend --- + +type private OptRec = { Field: int option } + +let private (|Positive|_|) x = + if x > 0 then Some() else None + +// Bug 2: bare-wildcard option match where the scrutinee is NOT a plain ident +// (field access / active-pattern result) used to emit `0_i32` int patterns +// against a native Option, a type error. +let private optFieldNoBind (r: OptRec) = + match r.Field with + | Some _ -> "some" + | None -> "none" + +let private optApNoBind n = + match n with + | Positive _ -> "pos" + | _ -> "other" + +[] +let ``option wildcard match on field access works`` () = + optFieldNoBind { Field = Some 3 } |> equal "some" + optFieldNoBind { Field = None } |> equal "none" + +[] +let ``option wildcard match on active pattern works`` () = + optApNoBind 5 |> equal "pos" + optApNoBind -1 |> equal "other" + +let private optFieldBind (r: OptRec) = + match r.Field with + | Some v -> v * 10 + | None -> 0 + +[] +let ``option binding match on field access works`` () = + optFieldBind { Field = Some 4 } |> equal 40 + optFieldBind { Field = None } |> equal 0 + +// Bug 1: reference-typed bindings reaching the two-switch decision-tree path +// used to be pre-declared via getZero (mem::zeroed), which panics on Rc pointers. + +type private RefC(v: int) = + member _.V = v + +// when-guard causes a shared default target -> two-switch path, ref binding `c` +let private twoSwitchRef (x: RefC option) (b: bool) = + match x with + | Some c when b -> c.V + 1 + | Some c -> c.V + | None -> -1 + +[] +let ``two-switch with reference binding works`` () = + twoSwitchRef (Some(RefC 5)) true |> equal 6 + twoSwitchRef (Some(RefC 5)) false |> equal 5 + twoSwitchRef None false |> equal -1 + +type private RefU = + | RA of RefC + | RB of RefC + +// or-pattern shares one target with a binding -> two-switch path +let private orPatRef (u: RefU) = + match u with + | RA c + | RB c -> c.V + +[] +let ``two-switch or-pattern with reference binding works`` () = + orPatRef (RA(RefC 5)) |> equal 5 + orPatRef (RB(RefC 7)) |> equal 7 + +type private ObjU = + | OA of obj + | OB of obj + +let private orPatObj (u: ObjU) : obj = + match u with + | OA o + | OB o -> o + +[] +let ``two-switch or-pattern with obj binding works`` () = + // exercises the getZeroObj placeholder for a `dyn Any` binding on the two-switch path + orPatObj (OA(box 9)) :? int |> equal true + orPatObj (OB(box 11)) :? string |> equal false diff --git a/tests/Rust/tests/src/ConvertTests.fs b/tests/Rust/tests/src/ConvertTests.fs index 4b930baa8c..33f69d37de 100644 --- a/tests/Rust/tests/src/ConvertTests.fs +++ b/tests/Rust/tests/src/ConvertTests.fs @@ -1564,3 +1564,23 @@ let ``Encoding.UTF8.GetString for range works`` () = let bytes = [| 0x7Auy; 0x61uy; 0xCCuy; 0x86uy; 0xC7uy; 0xBDuy; 0xCEuy; 0xB2uy; 0xF1uy; 0x8Fuy; 0xB3uy; 0xBFuy |] System.Text.Encoding.UTF8.GetString(bytes, 6, 6) |> equal "\u03B2\uD8FF\uDCFF" + +[] +let ``System.Convert.ToInteger from bool works`` () = + Convert.ToSByte(true) |> equal 1y + Convert.ToSByte(false) |> equal 0y + Convert.ToByte(true) |> equal 1uy + Convert.ToInt16(true) |> equal 1s + Convert.ToUInt16(true) |> equal 1us + Convert.ToInt32(true) |> equal 1 + Convert.ToInt32(false) |> equal 0 + Convert.ToUInt32(true) |> equal 1u + Convert.ToInt64(true) |> equal 1L + Convert.ToUInt64(true) |> equal 1uL + +[] +let ``System.Convert.ToFloat from bool works`` () = + Convert.ToSingle(true) |> equal 1.f + Convert.ToSingle(false) |> equal 0.f + Convert.ToDouble(true) |> equal 1. + Convert.ToDouble(false) |> equal 0. diff --git a/tests/Rust/tests/src/MapTests.fs b/tests/Rust/tests/src/MapTests.fs index f478598e31..2e456284ed 100644 --- a/tests/Rust/tests/src/MapTests.fs +++ b/tests/Rust/tests/src/MapTests.fs @@ -230,6 +230,17 @@ let ``Map.find with option works`` () = xs |> Map.find 1 |> (fun x -> x.Value) |> equal 1. +[] +let ``Map.find works with reference-type values`` () = + // Reference-type (Rc-wrapped) values used to panic: find/tryFind initialized a + // byref out-param with Unchecked.defaultof, which lowers to getZero/mem::zeroed. + let xs = Map [ 1, box "one"; 2, box 99 ] + xs |> Map.find 1 |> unbox |> equal "one" + xs |> Map.find 2 |> unbox |> equal 99 + let strs = Map [ "a", [ 10; 20 ]; "b", [ 30 ] ] + strs |> Map.find "a" |> List.sum |> equal 30 + strs |> Map.tryFind "b" |> Option.get |> List.head |> equal 30 + [] let ``Map.tryFind with option works`` () = let xs = Map [1,Some 1.; 2,None] diff --git a/tests/Rust/tests/src/StringTests.fs b/tests/Rust/tests/src/StringTests.fs index 2e57732910..7f67365f7a 100644 --- a/tests/Rust/tests/src/StringTests.fs +++ b/tests/Rust/tests/src/StringTests.fs @@ -952,6 +952,12 @@ let ``String.Split with single separator works`` () = let ``String.Split with multiple char args works`` () = "a;b,c".Split(',', ';') |> equal [|"a"; "b"; "c"|] +[] +let ``String.Split with multiple string separators works`` () = + "1-2_3".Split([|"-"; "_"|], StringSplitOptions.None) |> equal [|"1"; "2"; "3"|] + "a::b;;c".Split([|"::"; ";;"|], StringSplitOptions.None) |> equal [|"a"; "b"; "c"|] + "a--b".Split([|"--"|], StringSplitOptions.None) |> equal [|"a"; "b"|] + [] let ``String.Split with string array works`` () = "a;b,c".Split([|","; ";"|], StringSplitOptions.None) From 0ab2efe3d825bec33c5c11d7d15d30307e3e1e0b Mon Sep 17 00:00:00 2001 From: Mangel Maxime Date: Wed, 15 Jul 2026 21:59:27 +0200 Subject: [PATCH 2/4] chore: undo changes to QuickTest project --- src/quicktest/QuickTest.fs | 65 -------------------------------------- 1 file changed, 65 deletions(-) diff --git a/src/quicktest/QuickTest.fs b/src/quicktest/QuickTest.fs index f83ade04cb..848758087f 100644 --- a/src/quicktest/QuickTest.fs +++ b/src/quicktest/QuickTest.fs @@ -99,68 +99,3 @@ printfn "Running quick tests..." // to Fable.Tests project. For example: // testCase "Addition works" <| fun () -> // 2 + 2 |> equal 4 - -testCase "Path methods work" -<| fun () -> - System.IO.Path.GetDirectoryName("temp/test.txt") |> equal "temp" - System.IO.Path.GetExtension("temp/test.txt") |> equal ".txt" - System.IO.Path.GetFileName("temp/test.txt") |> equal "test.txt" - System.IO.Path.GetFileNameWithoutExtension("temp/test.txt") |> equal "test" - System.IO.Path.HasExtension("temp/test.txt") |> equal true - System.IO.Path.HasExtension("temp/test") |> equal false - (System.IO.Path.GetFullPath("temp/test.txt")).Length > 0 |> equal true - (System.IO.Path.GetTempPath()).Length > 0 |> equal true - (System.IO.Path.GetRandomFileName()).Length > 0 |> equal true - -testCase "File methods work" -<| fun () -> - let file = - System.IO.Path.Combine(System.IO.Path.GetTempPath(), System.IO.Path.GetRandomFileName()) - - System.IO.File.Exists(file) |> equal false - - System.IO.File.WriteAllText(file, "Hello World") - System.IO.File.Exists(file) |> equal true - System.IO.File.ReadAllText(file) |> equal "Hello World" - - System.IO.File.WriteAllLines(file, [| "Hello"; "World" |]) - let lines = System.IO.File.ReadAllLines(file) - lines |> equal [| "Hello"; "World" |] - - let bytes = System.IO.File.ReadAllBytes(file) - bytes.Length > 0 |> equal true - System.IO.File.WriteAllBytes(file, bytes) - - let file2 = file + ".copy" - System.IO.File.Copy(file, file2) - System.IO.File.Exists(file2) |> equal true - - let file3 = file + ".moved" - System.IO.File.Move(file2, file3) - System.IO.File.Exists(file2) |> equal false - System.IO.File.Exists(file3) |> equal true - - System.IO.File.Delete(file) - System.IO.File.Delete(file3) - System.IO.File.Exists(file) |> equal false - System.IO.File.Exists(file3) |> equal false - -testCase "Path.GetTempFileName works" -<| fun () -> - let file1 = System.IO.Path.GetTempFileName() - let file2 = System.IO.Path.GetTempFileName() - System.IO.File.Exists(file1) |> equal true - System.IO.File.Exists(file2) |> equal true - (file1 = file2) |> equal false - System.IO.File.Delete(file1) - System.IO.File.Delete(file2) - -testCase "Environment/Console methods work" -<| fun () -> - System.Environment.GetEnvironmentVariable("__FABLE_NON_EXISTENT_VAR__") - |> equal null - - System.Environment.CurrentDirectory.Length > 0 |> equal true - - System.Console.Out.WriteLine("Hello stdout") - System.Console.Error.WriteLine("Hello stderr") From 973ee68c829df4360e3901b3e709f1de4c494a7e Mon Sep 17 00:00:00 2001 From: Tuomas Hietanen Date: Tue, 14 Jul 2026 21:17:35 +0100 Subject: [PATCH 3/4] feat(php): quotations --- src/Fable.Transforms/Php/Fable2Php.fs | 5 +- src/fable-library-php/quotation.php | 244 ++++++++++++++++++++++++++ tests/Php/Fable.Tests.Php.fsproj | 1 + tests/Php/TestQuotation.fs | 203 +++++++++++++++++++++ 4 files changed, 452 insertions(+), 1 deletion(-) create mode 100644 src/fable-library-php/quotation.php create mode 100644 tests/Php/TestQuotation.fs diff --git a/src/Fable.Transforms/Php/Fable2Php.fs b/src/Fable.Transforms/Php/Fable2Php.fs index 4afbb5ee5b..66fe72487f 100644 --- a/src/Fable.Transforms/Php/Fable2Php.fs +++ b/src/Fable.Transforms/Php/Fable2Php.fs @@ -936,7 +936,10 @@ let rec tryFindMethod methodName (phpType: PhpType) = let rec convertExpr (com: IPhpCompiler) (expr: Fable.Expr) = match expr with | Fable.Extended _ -> failwith "TODO: Extended instructions" - | Fable.Quote _ -> failwith "Quotations are not yet supported for PHP target" + | Fable.Quote(body, _isTyped, _r) -> + // Lower the quoted body to runtime calls that build the quotation AST, + // then convert like any other expression (mirrors JS/Python/Beam/Rust). + convertExpr com (QuotationEmitter.emitQuotedExpr com body) | Fable.Unresolved _ -> failwith "Unexpected unresolved expression" diff --git a/src/fable-library-php/quotation.php b/src/fable-library-php/quotation.php new file mode 100644 index 0000000000..77ea6ebc92 --- /dev/null +++ b/src/fable-library-php/quotation.php @@ -0,0 +1,244 @@ + $name, 'type' => $type, 'isMutable' => $isMutable]; +} + +function varGetName($v) { return $v['name']; } +function varGetType($v) { return $v['type']; } +function varGetIsMutable($v) { return $v['isMutable']; } + +// --- Expr constructors --- + +function mkValue($value, $type) { return ['tag' => 'Value', 'value' => $value, 'type' => $type]; } + +// A null-value node (no instance / null literal / empty option or list). +function mkNull($type) { return ['tag' => 'Value', 'value' => null, 'type' => $type]; } + +function mkVar($var) { return ['tag' => 'Var', 'var' => $var]; } +function mkLambda($var, $body) { return ['tag' => 'Lambda', 'var' => $var, 'body' => $body]; } +function mkApplication($func, $arg) { return ['tag' => 'Application', 'func' => $func, 'arg' => $arg]; } +function mkLet($var, $value, $body) { return ['tag' => 'Let', 'var' => $var, 'value' => $value, 'body' => $body]; } + +function mkIfThenElse($guard, $thenExpr, $elseExpr) { + return ['tag' => 'IfThenElse', 'guard' => $guard, 'thenExpr' => $thenExpr, 'elseExpr' => $elseExpr]; +} + +function mkCall($instance, $method, $args, $declaringType = '') { + return ['tag' => 'Call', 'instance' => $instance, 'method' => $method, 'args' => $args, 'declaringType' => $declaringType]; +} + +function mkSequential($first, $second) { return ['tag' => 'Sequential', 'first' => $first, 'second' => $second]; } +function mkNewTuple($elements) { return ['tag' => 'NewTuple', 'elements' => $elements]; } + +function mkNewUnion($typeName, $tag, $fields) { + return ['tag' => 'NewUnion', 'typeName' => $typeName, 'unionTag' => $tag, 'fields' => $fields]; +} + +function mkNewRecord($fieldNames, $values) { + return ['tag' => 'NewRecord', 'fieldNames' => $fieldNames, 'values' => $values]; +} + +function mkNewList($head, $tail) { return ['tag' => 'NewList', 'head' => $head, 'tail' => $tail]; } +function mkTupleGet($expr, $index) { return ['tag' => 'TupleGet', 'expr' => $expr, 'index' => $index]; } +function mkUnionTag($expr) { return ['tag' => 'UnionTag', 'expr' => $expr]; } +function mkUnionField($expr, $fieldIndex) { return ['tag' => 'UnionField', 'expr' => $expr, 'fieldIndex' => $fieldIndex]; } +function mkFieldGet($expr, $fieldName) { return ['tag' => 'FieldGet', 'expr' => $expr, 'fieldName' => $fieldName]; } +function mkFieldSet($expr, $fieldName, $value) { return ['tag' => 'FieldSet', 'expr' => $expr, 'fieldName' => $fieldName, 'value' => $value]; } +function mkVarSet($target, $value) { return ['tag' => 'VarSet', 'target' => $target, 'value' => $value]; } + +// --- Type accessor --- + +function getType($e) { + switch ($e['tag']) { + case 'Value': return $e['type']; + case 'Lambda': return $e['var']['type']; + default: return 'obj'; + } +} + +// --- Pattern helpers (null = no match, value = match) --- + +function isValue($e) { return $e['tag'] === 'Value' ? [$e['value'], $e['type']] : null; } +function isVar($e) { return $e['tag'] === 'Var' ? $e['var'] : null; } +function isLambda($e) { return $e['tag'] === 'Lambda' ? [$e['var'], $e['body']] : null; } +function isApplication($e) { return $e['tag'] === 'Application' ? [$e['func'], $e['arg']] : null; } +function isLet($e) { return $e['tag'] === 'Let' ? [$e['var'], $e['value'], $e['body']] : null; } +function isIfThenElse($e) { return $e['tag'] === 'IfThenElse' ? [$e['guard'], $e['thenExpr'], $e['elseExpr']] : null; } + +function isCall($e) { + if ($e['tag'] !== 'Call') return null; + // A static/operator call carries the "novalue" sentinel node as its instance + // (distinct from a genuine quoted null, tagged "null"); expose it as null so + // Patterns.Call matches F#. + $inst = $e['instance']; + if (is_array($inst) && ($inst['tag'] ?? null) === 'Value' && ($inst['type'] ?? null) === 'novalue') { + $inst = null; + } + return [$inst, $e['method'], $e['args']]; +} + +function isSequential($e) { return $e['tag'] === 'Sequential' ? [$e['first'], $e['second']] : null; } +function isNewTuple($e) { return $e['tag'] === 'NewTuple' ? $e['elements'] : null; } +function isNewUnionCase($e) { return $e['tag'] === 'NewUnion' ? [$e['typeName'], $e['unionTag'], $e['fields']] : null; } +function isNewRecord($e) { return $e['tag'] === 'NewRecord' ? [$e['fieldNames'], $e['values']] : null; } +function isTupleGet($e) { return $e['tag'] === 'TupleGet' ? [$e['expr'], $e['index']] : null; } +function isFieldGet($e) { + if ($e['tag'] !== 'FieldGet') return null; + // A static property getter carries the "novalue" sentinel node as its instance + // (distinct from a genuine quoted null, tagged "null"); expose it as null so + // Patterns.PropertyGet matches F#. Third slot mirrors PropertyGet's indexer-args, + // always empty here (field/property accessors are never indexed). + $inst = $e['expr']; + if (is_array($inst) && ($inst['tag'] ?? null) === 'Value' && ($inst['type'] ?? null) === 'novalue') { + $inst = null; + } + return [$inst, $e['fieldName'], []]; +} + +// --- Free variables --- + +function getFreeVars($e) { + $free = []; + $seen = []; + $walk = function ($e, $bound) use (&$walk, &$free, &$seen) { + switch ($e['tag']) { + case 'Var': + $name = $e['var']['name']; + if (!in_array($name, $bound, true) && !in_array($name, $seen, true)) { + $free[] = $e['var']; + $seen[] = $name; + } + break; + case 'Lambda': + $walk($e['body'], array_merge($bound, [$e['var']['name']])); + break; + case 'Let': + $walk($e['value'], $bound); + $walk($e['body'], array_merge($bound, [$e['var']['name']])); + break; + case 'Application': + $walk($e['func'], $bound); + $walk($e['arg'], $bound); + break; + case 'IfThenElse': + $walk($e['guard'], $bound); + $walk($e['thenExpr'], $bound); + $walk($e['elseExpr'], $bound); + break; + case 'Call': + foreach ($e['args'] as $a) $walk($a, $bound); + break; + case 'Sequential': + $walk($e['first'], $bound); + $walk($e['second'], $bound); + break; + case 'NewTuple': + foreach ($e['elements'] as $el) $walk($el, $bound); + break; + case 'TupleGet': + $walk($e['expr'], $bound); + break; + case 'FieldGet': + $walk($e['expr'], $bound); + break; + } + }; + $walk($e, []); + return $free; +} + +// --- Substitution --- + +function substitute($e, $fn) { + $sub = function ($e) use (&$sub, $fn) { + switch ($e['tag']) { + case 'Var': + $r = $fn($e['var']); + return $r !== null ? $r : $e; + case 'Lambda': return mkLambda($e['var'], $sub($e['body'])); + case 'Let': return mkLet($e['var'], $sub($e['value']), $sub($e['body'])); + case 'Application': return mkApplication($sub($e['func']), $sub($e['arg'])); + case 'IfThenElse': return mkIfThenElse($sub($e['guard']), $sub($e['thenExpr']), $sub($e['elseExpr'])); + case 'Call': + return mkCall($e['instance'], $e['method'], array_map($sub, $e['args']), $e['declaringType']); + case 'Sequential': return mkSequential($sub($e['first']), $sub($e['second'])); + case 'NewTuple': return mkNewTuple(array_map($sub, $e['elements'])); + default: return $e; + } + }; + return $sub($e); +} + +// --- Evaluation (structural cases + common operators) --- + +const OPERATORS = [ + 'op_Addition', 'op_Subtraction', 'op_Multiply', 'op_Division', 'op_Modulus', + 'op_UnaryNegation', 'op_Equality', 'op_Inequality', 'op_LessThan', 'op_LessThanOrEqual', + 'op_GreaterThan', 'op_GreaterThanOrEqual', 'op_BooleanAnd', 'op_BooleanOr', 'op_LogicalNot', +]; + +function applyOperator($method, $args) { + switch ($method) { + case 'op_Addition': return $args[0] + $args[1]; + case 'op_Subtraction': return $args[0] - $args[1]; + case 'op_Multiply': return $args[0] * $args[1]; + case 'op_Division': return intdiv($args[0], $args[1]); + case 'op_Modulus': return $args[0] % $args[1]; + case 'op_UnaryNegation': return -$args[0]; + case 'op_Equality': return $args[0] === $args[1]; + case 'op_Inequality': return $args[0] !== $args[1]; + case 'op_LessThan': return $args[0] < $args[1]; + case 'op_LessThanOrEqual': return $args[0] <= $args[1]; + case 'op_GreaterThan': return $args[0] > $args[1]; + case 'op_GreaterThanOrEqual': return $args[0] >= $args[1]; + case 'op_BooleanAnd': return $args[0] && $args[1]; + case 'op_BooleanOr': return $args[0] || $args[1]; + case 'op_LogicalNot': return !$args[0]; + default: throw new \Exception("Cannot evaluate method: $method"); + } +} + +function evaluate($e) { + $eval = function ($e, $env) use (&$eval) { + switch ($e['tag']) { + case 'Value': return $e['value']; + case 'Var': return $env[$e['var']['name']]; + case 'Lambda': + $var = $e['var']; $body = $e['body']; + return function ($arg) use (&$eval, $var, $body, $env) { + $newEnv = $env; $newEnv[$var['name']] = $arg; + return $eval($body, $newEnv); + }; + case 'Application': + $f = $eval($e['func'], $env); + return $f($eval($e['arg'], $env)); + case 'Let': + $newEnv = $env; $newEnv[$e['var']['name']] = $eval($e['value'], $env); + return $eval($e['body'], $newEnv); + case 'IfThenElse': + return $eval($e['guard'], $env) ? $eval($e['thenExpr'], $env) : $eval($e['elseExpr'], $env); + case 'Sequential': + $eval($e['first'], $env); + return $eval($e['second'], $env); + case 'NewTuple': + return array_map(function ($el) use (&$eval, $env) { return $eval($el, $env); }, $e['elements']); + case 'Call': + $evalArgs = array_map(function ($a) use (&$eval, $env) { return $eval($a, $env); }, $e['args']); + return applyOperator($e['method'], $evalArgs); + case 'TupleGet': + return $eval($e['expr'], $env)[$e['index']]; + default: + throw new \Exception("Cannot evaluate expression: " . $e['tag']); + } + }; + return $eval($e, []); +} diff --git a/tests/Php/Fable.Tests.Php.fsproj b/tests/Php/Fable.Tests.Php.fsproj index 509b2813e4..8556323ce8 100644 --- a/tests/Php/Fable.Tests.Php.fsproj +++ b/tests/Php/Fable.Tests.Php.fsproj @@ -31,6 +31,7 @@ + diff --git a/tests/Php/TestQuotation.fs b/tests/Php/TestQuotation.fs new file mode 100644 index 0000000000..3299f7458b --- /dev/null +++ b/tests/Php/TestQuotation.fs @@ -0,0 +1,203 @@ +module Fable.Tests.Quotation + +open Util.Testing +open Microsoft.FSharp.Quotations +open Microsoft.FSharp.Quotations.Patterns +open Microsoft.FSharp.Linq.RuntimeHelpers + +type QuotTestConfig = + static member Version = 42 + +[] +let ``test Simple integer value quotation`` () = + let q = <@ 42 @> + match q with + | Value(v, _t) -> equal 42 (v :?> int) + | _ -> failwith "Expected Value" + +[] +let ``test Boolean value quotation`` () = + let q = <@ true @> + match q with + | Value(v, _t) -> equal true (v :?> bool) + | _ -> failwith "Expected Value" + +[] +let ``test String value quotation`` () = + let q = <@ "hello" @> + match q with + | Value(v, _t) -> equal "hello" (v :?> string) + | _ -> failwith "Expected Value" + +[] +let ``test Lambda quotation`` () = + let q = <@ fun x -> x + 1 @> + match q with + | Lambda(v, _body) -> equal "x" v.Name + | _ -> failwith "Expected Lambda" + +[] +let ``test Let binding quotation`` () = + let q = <@ let x = 5 in x @> + match q with + | Let(v, Value(value, _), _) -> + equal "x" v.Name + equal 5 (value :?> int) + | _ -> failwith "Expected Let" + +[] +let ``test IfThenElse quotation`` () = + let q = <@ if true then 1 else 2 @> + match q with + | IfThenElse(_, _, _) -> () + | _ -> failwith "Expected IfThenElse" + +[] +let ``test Application quotation`` () = + let q = <@ (fun x -> x) 42 @> + match q with + | Application(Lambda _, Value _) -> () + | _ -> failwith "Expected Application of Lambda" + +[] +let ``test Evaluate simple value`` () = + let result = LeafExpressionConverter.EvaluateQuotation <@ 42 @> + equal 42 (result :?> int) + +[] +let ``test Evaluate boolean value`` () = + let result = LeafExpressionConverter.EvaluateQuotation <@ true @> + equal true (result :?> bool) + +[] +let ``test Evaluate string value`` () = + let result = LeafExpressionConverter.EvaluateQuotation <@ "hello" @> + equal "hello" (result :?> string) + +[] +let ``test Evaluate addition`` () = + let result = LeafExpressionConverter.EvaluateQuotation <@ 1 + 2 @> + equal 3 (result :?> int) + +[] +let ``test Evaluate let binding`` () = + let result = LeafExpressionConverter.EvaluateQuotation <@ let x = 10 in x + 5 @> + equal 15 (result :?> int) + +[] +let ``test Evaluate if then else true branch`` () = + let result = LeafExpressionConverter.EvaluateQuotation <@ if true then 1 else 2 @> + equal 1 (result :?> int) + +[] +let ``test Evaluate if then else false branch`` () = + let result = LeafExpressionConverter.EvaluateQuotation <@ if false then 1 else 2 @> + equal 2 (result :?> int) + +[] +let ``test Evaluate lambda application`` () = + let result = LeafExpressionConverter.EvaluateQuotation <@ (fun x -> x + 1) 5 @> + equal 6 (result :?> int) + +// --- Pattern matching: new node types --- + +[] +let ``test NewTuple quotation`` () = + let q = <@ (1, 2, 3) @> + match q with + | NewTuple(exprs) -> equal 3 exprs.Length + | _ -> failwith "Expected NewTuple" + +[] +let ``test Sequential quotation`` () = + let q = <@ (); 42 @> + match q with + | Sequential(_, Value(v, _)) -> equal 42 (v :?> int) + | _ -> failwith "Expected Sequential" + +[] +let ``test Var quotation`` () = + let q = <@ fun x -> x @> + match q with + | Lambda(v, Var(v2)) -> equal v.Name v2.Name + | _ -> failwith "Expected Lambda with Var body" + +// --- Option property-getter accessor (OptionValue) deconstructs as PropertyGet, matching +// real F# quotations. NOTE: cannot currently be executed — there is no PHP test runner +// (`./build.sh test php` is not implemented) and no PHP interpreter in this environment; +// these tests are added for coverage once a runner exists. --- + +[] +let ``test Option.Value access inside a quotation is a PropertyGet`` () = + let q = <@ fun (o: int option) -> o.Value @> + + match q with + | Lambda(_, PropertyGet(Some _, _, [])) -> () + | _ -> failwith "Expected Lambda with PropertyGet body" + +[] +let ``test Static property access inside a quotation is a PropertyGet with no instance`` () = + // Guards against the "novalue" no-instance sentinel (used for static property getters) + // leaking out as a raw node instead of being normalized to None. + let q = <@ QuotTestConfig.Version @> + + match q with + | PropertyGet(None, _, []) -> () + | _ -> failwith "Expected PropertyGet with no instance" + +// --- Evaluation: more operators --- + +[] +let ``test Evaluate subtraction`` () = + let result = LeafExpressionConverter.EvaluateQuotation <@ 10 - 3 @> + equal 7 (result :?> int) + +[] +let ``test Evaluate multiplication`` () = + let result = LeafExpressionConverter.EvaluateQuotation <@ 6 * 7 @> + equal 42 (result :?> int) + +[] +let ``test Evaluate comparison`` () = + let result = LeafExpressionConverter.EvaluateQuotation <@ 5 > 3 @> + equal true (result :?> bool) + +[] +let ``test Evaluate nested let bindings`` () = + let result = LeafExpressionConverter.EvaluateQuotation <@ let x = 3 in let y = 4 in x * y @> + equal 12 (result :?> int) + +[] +let ``test Evaluate nested lambda`` () = + let result = LeafExpressionConverter.EvaluateQuotation <@ (fun x -> (fun y -> x + y)) 3 4 @> + equal 7 (result :?> int) + +[] +let ``test Evaluate tuple`` () = + let result = LeafExpressionConverter.EvaluateQuotation <@ (1, 2) @> + let t = result :?> (int * int) + equal (1, 2) t + +// --- FSharpExpr instance methods --- + +[] +let ``test Expr.GetFreeVars returns empty for closed expr`` () = + let q = <@ fun x -> x + 1 @> + let freeVars = q.GetFreeVars() |> Seq.length + equal 0 freeVars + +// --- Pattern matches lower to IfThenElse (DecisionTree/Test handling) --- + +[] +let ``test Option match deconstructs to IfThenElse`` () = + let q = <@ fun (o: int option) -> match o with Some v -> v | None -> 0 @> + match q with + | Lambda(_, IfThenElse(_, _, _)) -> () + | _ -> failwith "Expected Lambda with IfThenElse body" + +[] +let ``test Literal match deconstructs to IfThenElse`` () = + let q = <@ fun (x: int) -> match x with 0 -> "zero" | _ -> "other" @> + match q with + | Lambda(_, IfThenElse(_, _, _)) -> () + | _ -> failwith "Expected Lambda with IfThenElse body" From b3a7a61a3f1f63def574eee02f6493edde86c01a Mon Sep 17 00:00:00 2001 From: Tuomas Hietanen Date: Tue, 14 Jul 2026 22:32:18 +0100 Subject: [PATCH 4/4] ci: allow php scope in PR title lint --- .github/workflows/conventional-pr-title.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/conventional-pr-title.yml b/.github/workflows/conventional-pr-title.yml index 95bceb6d8e..ebe476494d 100644 --- a/.github/workflows/conventional-pr-title.yml +++ b/.github/workflows/conventional-pr-title.yml @@ -21,4 +21,4 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: scopes: | - (?:all|(?:js|ts|dart|python|rust|beam)(?:/(?:js|ts|dart|python|rust|beam))*) + (?:all|(?:js|ts|dart|python|rust|beam|php)(?:/(?:js|ts|dart|python|rust|beam|php))*)