Skip to content

lue-bird/elm-syntax-to-rust

Repository files navigation

Print elm-syntax declarations as rust code. To try it out, you can run this script.

import Elm.Parser
import ElmSyntaxToRust

"""module Sample exposing (..)

plus2 : Float -> Float
plus2 n =
    n + ([ 2.0 ] |> List.sum)
"""
    |> Elm.Parser.parseToFile
    |> Result.mapError (\_ -> "failed to parse elm source code")
    |> Result.map
        (\syntaxModule ->
            [ syntaxModule ]
                |> ElmSyntaxToRust.modules
                |> .declarations
                |> ElmSyntaxToRust.rustDeclarationsToModuleString
        )
-->
Ok """...
pub fn sample_plus2<'a>(allocator: &'a Bump, n: f64) -> f64 {
    std::ops::Add::add(n, elm::list_sum_float(elm::list(allocator, [2_f64])))
}
"""

be aware

  • not supported are
    • ports that use non-json values like port sendMessage : String -> Cmd msg, glsl, == on a generic value, mutually recursive phantom types
    • elm/file, elm/http, elm/browser, elm-explorations/markdown, elm-explorations/webgl, elm-explorations/benchmark, elm/regex (nothing in std), elm-explorations/linear-algebra (std::simd only available in nightly)
    • Task, Process, Platform.Task, Platform.ProcessId, Platform.Router, Platform.sendToApp, Platform.sendToSelf, Random.generate, Time.now, Time.every, Time.here, Time.getZoneName, Bytes.getHostEndianness
    • extensible record types outside of module-level value/function declarations. For example, these declarations might not work:
      -- in variant value
      type Named rec = Named { rec | name : String }
      -- in let type, annotated or not
      let getName : { r | name : name } -> name
      Allowed is only record extension in module-level value/functions, annotated or not:
      userId : { u | name : String, server : Domain } -> String
      In the non-allowed cases listed above, we assume that you intended to use a regular record type with only the extension fields which can lead to rust compile errors if you actually pass in additional fields.
    • elm's toLocale[Case] functions will just behave like toCase
    • elm's VirtualDom/Html/Svg.lazyN functions will still exist for compatibility but they will behave just like constructing them eagerly
  • dependencies cannot internally use the same module names as the transpiled project
  • the resulting code might not be readable or even conventionally formatted and comments are not preserved

Please report any issues you notice <3

why rust?

  • it runs fast natively and as wasm
  • it feels like a superset of elm which makes transpiling and "ffi" easier
  • it's overall a very polished and active language with a good standard library and good tooling

how do I use the transpiled output?

An example can be found in example-hello-world/.

In your elm project, add cargo.toml

[package]
name = "your-project-name"
edition = "2024"              # not lower
[dependencies]
bumpalo = "3.19.0"

and a file src/main.rs that uses elm.rs:

mod elm
print(elm::your_module_your_function("yourInput"))

where elm::your_module_your_function(firstArgument, secondArgument) is the transpiled elm function Your.Module.yourFunction firstArgument secondArgument. (If the value/function contains extensible records, search for elm::your_module_your_function_ with the underscore to see the different specialized options)

Run with

cargo run

If something unexpected happened, please report an issue.

If you find rust takes like 2+ seconds to build and you want a faster feedback cycle, follow the tips listed in "Optimizing Build Performance", most importantly "Use an alternative codegen backend". In my experience this cuts down build times by around 8 times :).

In the transpiled code, you will find these types:

  • elm Bool (True or False) → rust bool (true or false), Char ('a') → char ('a'), ( Bool, Char )( bool, char )
  • elm Ints will be of type i64. Create and match by appending _i64 to any number literal or using as i64
  • elm Floats will be of type f64. Create and match by appending _f64 to any number literal or using as f64
  • elm Strings (like "a") will be of type elm::StringString. Create from literals or other string slices with (elm::StringString::One("a")). Match with your_string if elm::string_equals_str(your_string, "some string")
  • elm Array as (like Array.fromList [ 'a' ]) will be of type Rc<Vec<A>> (alias elm::ArrayArray<A>). Create new values with (std::rc::Rc::new(vec!['a'])). To match, use e.g. match array.as_slice() { [] => ..., [_, ..] => ... etc }
  • elm records like { y : Float, x : Float } will be of type elm::GeneratedXY<f64, f64> with the fields sorted and can be constructed and matched with elm::GeneratedXY { x: _, y: _ }. record.x access also works
  • a transpiled elm app does not run itself. An elm main Platform.worker program type will literally just consist of fields init, update and subscriptions where subscriptions/commands are returned as a list of elm::PlatformSubSingle/elm::PlatformCmdSingle with possible elm subscriptions/commands in a choice type. It's then your responsibility as "the platform" to perform effects, create events and manage the state. For an example see example-worker-blocking/ & example-worker-concurrent/

how does the transpiled code handle memory?

Most transpiled functions require a reference to an allocator to be passed as the first argument. When the called function or an indirectly called function then creates a new List for example, it will use the given allocator. bumpalo specifically is required because rusts allocator APIs are not stabilized, yet. Also note that regular lifetime end + Drop stuff will still occur sometimes.

So overall, if you intend to let the transpiled code handle a memory-hungry long-running computation, it might run out of memory. Use it for classic arena-friendly loop steps like state → interface, request → response etc.

If you want elm to control the application state, I recommend taking a look at /example-worker-blocking/, specifically the struct ElmStatePersistent in src/main.rs. You will typically have the transpiled type for the elm state which has a temporary lifetime bound to a given allocator and a separate custom "persistent" rust type which only contains owned types – and conversion functions between the two.

improvement ideas

  • keep types closer to elm in more places (and do not expand aliases everywhere which leads to large and hard to understand transpiled types)
  • try and benchmark switching String representation from One &str | Append String String to Rc<Vec<&str>> or &dyn Fn(String) -> String to avoid massive nesting = indirection = expensive memory lookup (+ alloc and dealloc but lesser so)
  • if lambda is called with a function, always inline that function
  • possible optimization: make JsonValue lazy at field values and Array level
  • your idea 👀

About

transpile elm to rust

Topics

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages