Skip to main content

elm_rust_binding/
lib.rs

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