Skip to content

feat(all): F# quotations type improvements and Rust target#4774

Closed
Thorium wants to merge 2 commits into
fable-compiler:mainfrom
Thorium:fable/quotation-fidelity
Closed

feat(all): F# quotations type improvements and Rust target#4774
Thorium wants to merge 2 commits into
fable-compiler:mainfrom
Thorium:fable/quotation-fidelity

Conversation

@Thorium

@Thorium Thorium commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Improves quotations in the shared emitter (with better type-info) and adds quotation support to the Rust and PHP targets. Motivated by compiling F# query (<@ @>) expressions, e.g. toward a future TypeProviders (like SQLProvider) port to Python/Rust.

Shared emitter (QuotationEmitter.fs) — benefits all targets:

  • typeToString: render all numeric kinds, char/regex/guid/datetime/ datetimeoffset/timespan, option/voption/list/array/seq/nullable, declared-type names, generics and delegates instead of "obj".
  • Call nodes record the declaring type; Get OptionValue/ListHead/ListTail map to property-getter calls.
  • DecisionTree inlined into IfThenElse/Let; Test nodes (union/option/list/ type) represented via union-tag comparison / getter calls.
  • mkNull constructor for null instances/values (no raw null literal, so statically typed languages compile); is_call maps it back to None.

mk_call kept backward-compatible (3 and 4 params on Beam); mkNull added to each.

Rust target (new): route Quote through the emitter (Fable2Rust), wire Replacements to tryQuotationCall, and add a transpiled F# runtime (QuotationTypes.fs + Quotation.fs) exposing FSharpExpr/FSharpVar at Microsoft.FSharp.Quotations and the quotation_ module. Construction + deconstruction verified via cargo; arithmetic evaluate works (let/lambda eval pending an upstream getZero fix for Rc).

Check the tests for more use-case examples.

…runtimes

Improves quotations in the shared emitter and adds quotation
support to the Rust and PHP targets. Motivated by compiling F# query
(<@ @>) expressions, e.g. toward a future TypeProviders (like SQLProvider) port to Python/Rust.

Shared emitter (QuotationEmitter.fs) — benefits all targets:
- typeToString: render all numeric kinds, char/regex/guid/datetime/
  datetimeoffset/timespan, option/voption/list/array/seq/nullable,
  declared-type names, generics and delegates instead of "obj".
- Call nodes record the declaring type; Get OptionValue/ListHead/ListTail
  map to property-getter calls.
- DecisionTree inlined into IfThenElse/Let; Test nodes (union/option/list/
  type) represented via union-tag comparison / getter calls.
- mkNull constructor for null instances/values (no raw null literal, so
  statically typed languages compile); is_call maps it back to None.

mk_call kept backward-compatible (3 and 4 params on Beam);
mkNull added to each.

Rust target (new): route Quote through the emitter (Fable2Rust), wire
Replacements to tryQuotationCall, and add a transpiled F# runtime
(QuotationTypes.fs + Quotation.fs) exposing FSharpExpr/FSharpVar at
Microsoft.FSharp.Quotations and the quotation_ module. Construction +
deconstruction verified via cargo; arithmetic evaluate works (let/lambda
eval pending an upstream getZero fix for Rc<dyn Any>).

Check the tests for more use-case examples.
@Thorium Thorium changed the title feat(quotations): F# quotation type improvements and Rust target feat(all): F# quotations type improvements and Rust target Jul 13, 2026

@MangelMaxime MangelMaxime left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking roughly at the diff, I see several issues.

Not all the changes are covered for example I see changes made for JavaScript but no tests added for that target.

Also, in general we try to keep the tests between target in sync and I don't think this is the case. For example, I see

let private lambdaVarName (e: Expr) =
    match e with
    | Lambda(v, _body) -> v.Name
    | _ -> "?"

let private letVarName (e: Expr) =
    match e with
    | Let(v, _value, _body) -> v.Name
    | _ -> "?"

let private isIfThenElseNode (e: Expr) =
    match e with
    | IfThenElse(_g, _t, _e) -> true
    | _ -> false

In tests/Rust/tests/src/QuotationTests.fs but unless I am mistaken I don't see that in other tests for example Python ones. So there is a variation here somewhere. Plus seems to have gained quotations support in this PRs but didn't get all the Python tests migrated.

@Thorium

Thorium commented Jul 14, 2026

Copy link
Copy Markdown
Contributor Author

Feedback addressed: Rust seems to be a bit rusty, so I think it needs a separate PR to fix some of its Todos in code, but the other test-suites should now be consistent across the targets.

Edit: I have done Rust fixes to https://github.com/Thorium/Fable/tree/rust-todo-fixes but as I know you prefer small commits, it'll be a separate PR later.

@dbrattli

Copy link
Copy Markdown
Collaborator

Overview

Three things bundled into one PR:

  1. Shared quotation emitter (QuotationEmitter.fs): richer typeToString, declaring-type on Call nodes, DecisionTree inlining, Test node support, and a mkNull constructor replacing raw null literals.
  2. New CapturingQuotation flag in FSharp2Fable.Context that disables replacements/emit/import/inlining for member calls inside <@ @>.
  3. New targets: Rust (F#-authored runtime, transpiled) and PHP (hand-written quotation.php), plus an unrelated Map.find/tryFind fix in fable-library-rust.

The direction is good — routing PHP/Rust through the shared emitter is exactly the "keep targets aligned" convention in AGENTS.md, and the F#-authored Rust runtime is a nice trick. But there are correctness gaps and a couple of things I don't think should ship as-is.

Blocking / high-confidence issues

declaringType is plumbed everywhere but unreachable from F#. call_declaring_type / callDeclaringType accessors were added to all four runtimes, but Replacements.Util.fs's quotationPatterns has no case that maps to them — nothing in tryQuotationCall references declaringType. So the whole point of the change (SQLProvider-style translation needs to know which List.map it's looking at) is not yet achievable from user code. Either wire it into the Call pattern (real F# Patterns.Call yields a MethodInfo with DeclaringType) or drop the accessors until there's a consumer.

Rust Expr.Call(...) is broken. quotationExprs emits mkCall with 3 args (Replacements.Util.fs:1553), but the Rust runtime's mkCall takes 4 curried params with no default. Beam solved this with mk_call/3mk_call/4; TS/Python/PHP use default parameters. Rust will produce a partial application, not an FSharpExpr. Add a 3-arg overload, or make the emitter always pass 4.

Rust evaluate drops the tuple index: | ExprTupleGet(inner, _) -> eval env inner returns the whole tuple instead of element index. Compare PHP ($eval(...)[$e['index']]). Not covered by the new Rust tests.

Rust isCall may not compile the way you expect. The comment in applyOperator explicitly says "Fable-Rust would emit string literals as match patterns, which isn't valid Rust; an if-chain compiles to string == comparisons" — yet isCall uses exactly that: | ExprValue(_, "null") -> None. If the applyOperator note is accurate, this pattern is either invalid or silently never matches, which would make every static call on Rust report a non-None instance. Could you confirm what Rust this actually emits? If it doesn't match, use an if-chain here too.

PHP is entirely untested. There is no PHP test runner in src/Fable.Build/Test/ and no PHP CI job, so tests/Php/TestQuotation.fs never runs and quotation.php is unverified. Concretely visible gaps in quotation.php:

  • No exprToString, so q.ToString() (wired in quotationExprs) calls an undefined function.
  • substitute doesn't recurse into Call.instance (TS does).
  • applyOperator uses intdiv for op_Division (breaks on floats), and the OPERATORS const is dead.

I'd either verify the PHP path by hand against generated output and say so in the PR, or split PHP out.

Design concerns

CapturingQuotation is a broad change to a shared file. The guard sits ahead of every other case in makeCallWithArgInfo, so it also intercepts constructors, interface/abstract dispatch, delegate Invoke, and module values — for module values the old path returned funcExpr directly, the new one wraps in makeCall unconditionally. It happens to be harmless today because emitQuotedExpr discards the callee and only reads info.MemberRef, but that's an implicit coupling worth a comment. It also means the emitter's Operation branch is now largely dead code for user quotations (operators arrive as Call nodes instead). Also worth checking: FableTransforms still runs over the Quote body, and uncurrying/beta-reduction on calls to members that no longer resolve to real output could rewrite them. A quicktest on <@ List.map (fun x -> x + 1) [1] @> for JS/Python would be reassuring.

Test nodes abuse the declaringType slot. TypeTest emits mkCall(target, "op_TypeTest", [], typeToString typ) — a fake method name with the tested type smuggled into the declaring-type field. Real F# quotations have a TypeTest node. A dedicated mkTypeTest would cost one constructor per runtime and avoid a landmine for anyone who later reads declaringType generically.

DecisionTree inlining duplicates shared targets. Every DecisionTreeSuccess reference re-inlines the whole target body, so an N-way match with a shared default emits N copies of the mk*-call tree into generated code. Fine for small quotations; worth a comment noting the blow-up. Also List.item idx targets inside the visitor is O(n²) — use an array.

Smaller notes

  • The Option case in typeToString renders as a Fantomas-mangled multi-line interpolation. Hoist it: let suffix = if isStruct then "voption" else "option".
  • DelegateType and curried LambdaType both stringify to a -> b -> c — indistinguishable.
  • Rust mkNull stores box 0 with a type tag, so isValue on a genuine null/None/[] literal hands back 0. Documented in the comment, but it's a real semantic hole.
  • Rust applyOperator unbox<int> for all comparisons — <@ "a" = "b" @> or float comparison will fail at runtime.
  • The Map.fs fix is a genuine, valuable bug fix (Unchecked.defaultofgetZero/mem::zeroed panicking for Rc-backed values) with a good regression test — but it's unrelated to quotations and would land better as its own PR. Note tryGetValue still has the same defect for external callers.
  • Adding QuotationTypes.fs/Quotation.fs unconditionally to Fable.Library.Rust.fsproj grows every Rust binary. Is there a feature-gate convention in that lib worth following?

Test coverage

Structural tests (Lambda(_, IfThenElse ...)) are added consistently across Beam/JS/Python/PHP, which is the right instinct. Gaps: nothing exercises the new declaringType (unreachable — see above), nothing exercises TypeTest/ListTest, and no test covers Get(ListHead/ListTail/OptionValue) producing get_Head/get_Value calls. The Rust suite skips Value deconstruction entirely, which is where the mkNull/box 0 design is weakest.

@Thorium

Thorium commented Jul 14, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the feedback. I'll address the issues and split these into multiple smaller features again.
They will be along the lines of:

  • Quotations (shared emitter)
  • Quotations and tests (Rust)
  • Quotations and tests (Dart)
  • Quotations and tests (PHP)
  • Reflection (Rust)
  • Other Rust bug fixes and missing features
  • Environment on Dart/Erlang/PHP as well

@Thorium

Thorium commented Jul 14, 2026

Copy link
Copy Markdown
Contributor Author

Your PR feedback is now addressed. I am sorry I couldn't make the PRs independent; there are dependencies. If that feels messy, this picture hopefully helps:

image

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants