elm_rust_binding/
lib.rs

1mod elm_type;
2mod error;
3
4use std::{
5    cell::RefCell, convert::identity, fs, marker::PhantomData, path::PathBuf, process::Command,
6};
7
8pub use error::{Error, Result};
9use rustyscript::{
10    deno_core::serde::{de::DeserializeOwned, Serialize},
11     Module, ModuleHandle, Runtime,
12};
13use uuid::Uuid;
14
15thread_local! {
16    static RUNTIME: RefCell<Runtime> = RefCell::new(Runtime::new(Default::default())
17        .expect("V8 Javascript Runtime initialization failed"));
18}
19
20/// The main entrypoint for this crate.
21///
22/// Represents a directory with Elm files inside of it.
23pub struct ElmRoot {
24    root_path: PathBuf,
25    debug: bool,
26}
27
28macro_rules! log {
29    ($self: expr, $($arg:tt)*) => {
30        if $self.debug {
31            println!($($arg)*)
32        }
33    };
34}
35
36impl ElmRoot {
37    /// Create an `ElmRoot` for a given directory.
38    /// The directory should NOT be the one with the elm.json file in it,
39    /// but the directory with the .elm files in it (with the default elm.json file, this is usually ./src).
40    ///
41    /// The reason for this is: You may choose any source-directories in your elm.json you like,
42    /// so we would need to parse elm.json and check all directories for the specified module.
43    pub fn new<P>(path: P) -> Result<Self>
44    where
45        PathBuf: From<P>,
46    {
47        Ok(Self {
48            root_path: PathBuf::from(path),
49            debug: false,
50        })
51    }
52
53    /// Set the `ElmRoot` to debug mode.
54    ///
55    /// This has two effects:
56    /// - Some println! logs to describe what the crate is doing
57    /// - The temporarily created files are not deleted
58    pub fn debug(self) -> Self {
59        Self {
60            debug: true,
61            ..self
62        }
63    }
64
65    /// Prepare an Elm function for execution.
66    ///
67    /// The given function name should be in the same form as you would call it in your Elm project when
68    /// not using import aliases or unqualified imports.
69    ///
70    /// E.g. if your function `myFun` is defined in the module `MyModule.Submodule`, you would pass in `MyModule.Submodule.myFun`.
71    ///
72    /// The input and output types intended to be passed in subsequent calls have to be known at this point,
73    /// either by type inference or by explitely specifying them. The reason for this is that we generate a wrapper
74    /// application module for the requested function which needs type annotations (at least the type annotation for the port cannot be inferred).
75    pub fn prepare<I, O>(&self, fully_qualified_function: &str) -> Result<ElmFunctionHandle<I, O>>
76    where
77        I: DeserializeOwned,
78        O: DeserializeOwned,
79    {
80        // 0. Extract timestamp because of potential file creation/deletion conflicts
81        let seed = Uuid::now_v7().as_u128();
82        log!(self, "Running with seed: {seed}");
83        // 1. Generate a binding file via the template
84        let input_type = elm_type::convert::<I>(elm_type::wrap_in_round_brackets)?;
85        log!(self, "Inferred input type: {input_type}");
86
87        let output_type = elm_type::convert::<O>(identity)?;
88        log!(self, "Inferred output type: {output_type}");
89
90        let qualified_segments = fully_qualified_function.split('.').collect::<Vec<_>>();
91        let Some((function_name, module_path_segments)) = qualified_segments.split_last() else {
92            return Err(Error::InvalidElmCall(fully_qualified_function.to_owned()));
93        };
94        log!(self, "Inferred function name: {function_name}");
95
96        let module_name = module_path_segments.join(".");
97        log!(self, "Inferred module name: {module_name}");
98
99        let mut binding_module_name = qualified_segments.join("_");
100        binding_module_name.push_str("_Binding");
101        binding_module_name.push_str(&seed.to_string());
102        log!(self, "Inferred binding module name: {binding_module_name}");
103
104        let binding_elm = BINDING_TEMPLATE
105            .replace("{{ module_path }}", &module_name)
106            .replace("{{ function_name }}", function_name)
107            .replace("{{ file_name }}", &binding_module_name)
108            .replace("{{ input_type }}", &input_type)
109            .replace("{{ output_type }}", &output_type);
110
111        let file_name = binding_module_name.clone() + ".elm";
112        let file_path = self.root_path.join(&file_name);
113
114        fs::write(&file_path, binding_elm).map_err(Error::map_disk_error(file_path.clone()))?;
115
116        // 2. Call the elm-compiler via the CLI to compile the binding file
117        let binding_js_file_name = binding_module_name.clone() + ".js";
118        let elm_compile_result = Command::new("elm")
119            .current_dir(&self.root_path)
120            .arg("make")
121            .arg(&file_name)
122            .arg(format!("--output={binding_js_file_name}"))
123            .arg("--optimize")
124            .output();
125        if !self.debug {
126            fs::remove_file(&file_path).map_err(Error::map_disk_error(file_path.clone()))?;
127        }
128        match elm_compile_result {
129            Ok(ok) => {
130                if !ok.stderr.is_empty() {
131                    return Err(Error::InvalidElmCall(format!(
132                        "The elm binding failed to compile: {}",
133                        String::from_utf8_lossy(&ok.stderr)
134                    )));
135                }
136            }
137            Err(error) => {
138                return Err(Error::InvalidElmCall(format!(
139                    "Failed to invoke elm compiler: {error}"
140                )))
141            }
142        }
143
144        let compiled_binding_file_path = self.root_path.join(binding_js_file_name);
145        let compiled_binding_result = fs::read_to_string(&compiled_binding_file_path);
146        if !self.debug {
147            fs::remove_file(&compiled_binding_file_path)
148                .map_err(Error::map_disk_error(compiled_binding_file_path.clone()))?;
149        }
150        let compiled_binding = compiled_binding_result
151            .map_err(Error::map_disk_error(compiled_binding_file_path.clone()))?;
152
153        // 3. Make the compiled JS esm compatible
154        let to_esm = Module::new("to-esm.js", TO_ESM_JS);
155        let esm_compiled_binding: String = RUNTIME.with_borrow_mut(|runtime| {
156            let handle = runtime.load_module(&to_esm)?;
157            let result: String = runtime.call_entrypoint(&handle, &[compiled_binding])?;
158            Ok::<_, Error>(result)
159        })?;
160        if self.debug {
161            let esm_binding_path = self
162                .root_path
163                .join(format!("{binding_module_name}-esm.mjs"));
164            fs::write(&esm_binding_path, esm_compiled_binding.clone())
165                .map_err(Error::map_disk_error(esm_binding_path))?;
166        }
167        // 4. Load the esm into rustyscript/deno
168        let debug_extras = if self.debug {
169            "console.log('Calling elm binding with', flags);"
170        } else {
171            ""
172        };
173        let wrapper = Module::new(
174            "run.js",
175            &RUN_JS_TEMPLATE
176                .replace("{{ binding_module_name }}", &binding_module_name)
177                .replace("{{ debug_extras }}", debug_extras),
178        );
179        let binding_module = Module::new("./binding.js", &esm_compiled_binding);
180        let module_handle = RUNTIME
181            .with_borrow_mut(|runtime| runtime.load_modules(&wrapper, vec![&binding_module]))?;
182
183        Ok(ElmFunctionHandle {
184            module: module_handle,
185            _type: Default::default(),
186        })
187    }
188}
189
190/// A handle to an Elm function. The only thing you can do with this is `call` it.
191/// The main reason this is here, is to only do the `prepare` step once.
192pub struct ElmFunctionHandle<I, O> {
193    module: ModuleHandle,
194    _type: PhantomData<(I, O)>,
195}
196
197impl<I, O> ElmFunctionHandle<I, O>
198where
199    I: Serialize,
200    O: DeserializeOwned,
201{
202    /// Calls the elm function with the given input and return the output.
203    pub fn call(&self, input: I) -> Result<O> {
204        let output =
205            RUNTIME.with_borrow_mut(|runtime| runtime.call_entrypoint(&self.module, &[input]))?;
206        Ok(output)
207    }
208}
209
210const BINDING_TEMPLATE: &str = include_str!("./templates/Binding.elm.template");
211const RUN_JS_TEMPLATE: &str = include_str!("./templates/run.js.template");
212const TO_ESM_JS: &str = include_str!("./templates/to-esm.mjs");
213
214#[doc = include_str!("../README.md")]
215struct _ReadMe;