Skip to main content

yamlscript/
lib.rs

1//! Rust binding/API for the libys shared library.
2//!
3//! # Loading a `YAMLScript` file
4//! The [`YAMLScript::load`] function is the main entrypoint of the library.
5//! It allows loading `YAMLScript` and returns a JSON object.
6//! `serde_json` is used to deserialize the JSON.
7//! One can either use `serde_json::Value` as a return type or a custom `serde::Deserialize`able
8//! type.
9//!
10//! ## Using `serde_json::Value`
11//! ```
12//! // Create an instance of a YAMLScript object.
13//! // This object holds data from the library and all execution context.
14//! let ys = yamlscript::YAMLScript::new().unwrap();
15//! // Load some YAMLScript.
16//! let data = ys.load::<serde_json::Value>(
17//!         r#"
18//! !ys-0:
19//! key:: inc(42)
20//! "#
21//!     )
22//!     .unwrap();
23//!
24//! // Our YAMLScript returns a `serde_json::Value` which holds an object.
25//! let data = data.as_object().unwrap();
26//! // We have a `key` field which holds the value 43.
27//! assert_eq!(data.get("key").unwrap().as_u64().unwrap(), 43);
28//! ```
29//!
30//! ## Using a user-defined type
31//! ```
32//! # use serde::Deserialize;
33//! #
34//! #[derive(Deserialize)]
35//! struct Foo {
36//!   key: u64,
37//! }
38//!
39//! // Create an instance of a YAMLScript object.
40//! // This object holds data from the library and all execution context.
41//! let ys = yamlscript::YAMLScript::new().unwrap();
42//! // Load some YAMLScript and deserialize as `Foo`.
43//! let foo = ys.load::<Foo>(
44//!         r#"
45//! !ys-0:
46//! key:: inc(42)
47//! "#
48//!     )
49//!     .unwrap();
50//!
51//! // Our `foo` object has its key field set to 43.
52//! assert_eq!(foo.key, 43);
53//! ```
54
55#![warn(clippy::pedantic)]
56
57use std::path::Path;
58
59use dlopen::symbor::Library;
60use libc::{c_int, c_void as void};
61
62mod error;
63
64pub use error::Error;
65use serde::Deserialize;
66
67use crate::error::LibYSError;
68
69/// The name of the `YAMLScript` library to load.
70const LIBYS_BASENAME: &str = "libys";
71
72/// The version of the yamlscript library this bindings works with.
73const LIBYS_VERSION: &str = "0.2.29";
74
75/// The extension of the `YAMLScript` library. On Linux, it's a `.so` file.
76#[cfg(target_os = "linux")]
77const LIBYS_EXTENSION: &str = "so";
78/// The extension of the `YAMLScript` library. On MacOS, it's a `.dylib` file.
79#[cfg(target_os = "macos")]
80const LIBYS_EXTENSION: &str = "dylib";
81/// The extension of the `YAMLScript` library. On Windows, it's a `.dll` file.
82#[cfg(target_os = "windows")]
83const LIBYS_EXTENSION: &str = "dll";
84// This should be the extension of the library file, but the platform is unsupported.
85#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
86compile_error!(
87    "Unsupported platform {} for yamlscript.",
88    std::env::consts::OS
89);
90
91/// The file name of the `YAMLScript` library.
92/// Windows uses an unversioned file name, matching the Python binding.
93fn libys_filename() -> String {
94    if cfg!(target_os = "windows") {
95        format!("{LIBYS_BASENAME}.{LIBYS_EXTENSION}")
96    } else {
97        format!("{LIBYS_BASENAME}.{LIBYS_EXTENSION}.{LIBYS_VERSION}")
98    }
99}
100
101/// A wrapper around libys.
102pub struct YAMLScript {
103    /// A handle to the opened dynamic library.
104    _handle: Library,
105    /// A `GraalVM` isolate.
106    _isolate: *mut void,
107    /// A `GraalVM` isolate thread.
108    isolate_thread: *mut void,
109    /// Pointer to the function in `GraalVM` to create the isolate and its thread.
110    _create_isolate_fn: CreateIsolateFn,
111    /// Pointer to the function in `GraalVM` to free an isolate thread.
112    tear_down_isolate_fn: TearDownIsolateFn,
113    /// Pointer to the `load_ys_to_json` function in libys.
114    load_ys_to_json_fn: LoadYsToJsonFn,
115}
116
117/// Prototype of the `graal_create_isolate` function.
118type CreateIsolateFn = unsafe extern "C" fn(*mut void, *const *mut void, *const *mut void) -> c_int;
119/// Prototype of the `graal_tear_down_isolate` function.
120type TearDownIsolateFn = unsafe extern "C" fn(*mut void) -> c_int;
121/// Prototype of the `load_ys_to_json` function.
122type LoadYsToJsonFn = unsafe extern "C" fn(*mut void, *const u8) -> *mut i8;
123
124impl YAMLScript {
125    /// Create a new instance of a `YAMLScript` loader.
126    ///
127    /// # Errors
128    /// This function may return an error if we fail to open the library
129    /// Namely, it returns [`Error::NotFound`] if the library cannot be found.
130    #[allow(clippy::crosspointer_transmute)]
131    pub fn new() -> Result<Self, Error> {
132        // Open library and create pointers the library needs.
133        let handle = Self::open_library()?;
134        let isolate = std::ptr::null_mut();
135        let isolate_thread = std::ptr::null_mut();
136
137        // Fetch symbols.
138        let create_isolate_fn =
139            unsafe { handle.ptr_or_null::<CreateIsolateFn>("graal_create_isolate")? };
140        let tear_down_isolate_fn =
141            unsafe { handle.ptr_or_null::<TearDownIsolateFn>("graal_tear_down_isolate")? };
142        let load_ys_to_json_fn =
143            unsafe { handle.ptr_or_null::<LoadYsToJsonFn>("load_ys_to_json")? };
144
145        // Check for null-ness.
146        if create_isolate_fn.is_null()
147            || tear_down_isolate_fn.is_null()
148            || load_ys_to_json_fn.is_null()
149        {
150            return Err(Error::Load(dlopen::Error::NullSymbol));
151        }
152
153        // We are doing 2 things here:
154        //   1. Remove the borrow of the function pointers on the `Library`.
155        //   2. Convert them to the correct Rust type.
156        //
157        // 1. By copying the pointers, we remove the borrow `PtrOrNull` has on `Library`.
158        //    Without this, we would be unable to move `handle` to return our `Self` at the end of
159        //    this function, because the `Library` would be moved while still borrowed.
160        //    We have checked the pointers to be valid and they are stored alongside the `Library`
161        //    and hence do not outlive it.
162        // 2. Rust considers that the type `fn()` is akin to a function pointer.
163        //    When we retrieve a `*const fn()`, this is thus a pointer to a function pointer.
164        //    We need to transmute the pointer to pointer to a regular pointer.
165        //    This means converting a pointer to its pointee, which clippy does not like at all,
166        //    but it is valid in this context.
167        //    Note that we dereference (`*`) the `_fn` bindings to retrieve the raw pointer from
168        //    the `PtrOrNull` wrapper
169        //    There is no pointer dereferencement here.
170        let create_isolate_fn: CreateIsolateFn = unsafe { std::mem::transmute(*create_isolate_fn) };
171        let tear_down_isolate_fn: TearDownIsolateFn =
172            unsafe { std::mem::transmute(*tear_down_isolate_fn) };
173        let load_ys_to_json_fn: LoadYsToJsonFn =
174            unsafe { std::mem::transmute(*load_ys_to_json_fn) };
175
176        let x = unsafe {
177            (create_isolate_fn)(
178                std::ptr::null_mut(),
179                &raw const isolate,
180                &raw const isolate_thread,
181            )
182        };
183        if x != 0 {
184            return Err(Error::GraalVM(x));
185        }
186
187        Ok(Self {
188            _handle: handle,
189            _isolate: isolate,
190            isolate_thread,
191            _create_isolate_fn: create_isolate_fn,
192            tear_down_isolate_fn,
193            load_ys_to_json_fn,
194        })
195    }
196
197    /// Load a `YAMLScript` string, returning the result deserialized.
198    ///
199    /// # Errors
200    /// This function returns an error if the input string is invalid (contains a nil-byte) or if
201    /// the `YAMLScript` engine has returned an error.
202    pub fn load<T>(&self, ys: &str) -> Result<T, Error>
203    where
204        T: serde::de::DeserializeOwned,
205    {
206        // Library responds with a JSON string.
207        // Parse it.
208        let raw = unsafe { std::ffi::CStr::from_ptr(self.load_raw(ys, self.isolate_thread)?) }
209            .to_str()?;
210        let response = serde_json::from_str::<YsResponse<T>>(raw)?;
211
212        // Check for errors.
213        match response {
214            YsResponse::Data(value) => Ok(value),
215            YsResponse::Error(err) => Err(Error::YAMLScript(err)),
216        }
217    }
218
219    /// Load a `YAMLScript` string, returning the raw buffer from the library.
220    ///
221    /// # Errors
222    /// This function returns an error if the input string is invalid (contains a nil-byte).
223    ///
224    /// If the yamlscript engine returned an error, this function will succeed.
225    fn load_raw(&self, ys: &str, isolate_thread: *mut void) -> Result<*mut i8, Error> {
226        // We need to create a `CString` because `ys` is not necessarily nil-terminated.
227        let input = std::ffi::CString::new(ys)
228            .map_err(|_| Error::Ffi("load: input contains a nil-byte".to_string()))?;
229        let json = unsafe { (self.load_ys_to_json_fn)(isolate_thread, input.as_bytes().as_ptr()) };
230        if json.is_null() {
231            Err(Error::Ffi(
232                "load_ys_to_json: returned a null pointer".to_string(),
233            ))
234        } else {
235            Ok(json)
236        }
237    }
238
239    /// Open the library found at the first matching path in the library
240    /// search path (`PATH` on Windows, `LD_LIBRARY_PATH` elsewhere).
241    fn open_library() -> Result<Library, Error> {
242        let mut first_error = None;
243        let env_name = if cfg!(target_os = "windows") {
244            "PATH"
245        } else {
246            "LD_LIBRARY_PATH"
247        };
248        let path_sep = if cfg!(target_os = "windows") {
249            ';'
250        } else {
251            ':'
252        };
253        let library_path = std::env::var(env_name).map_err(|_| Error::NotFound)?;
254
255        // Additionally look in `/usr/local/lib` (not on Windows) and
256        // `${HOME}/.local/lib`.
257        let mut additional_paths = vec![];
258        if !cfg!(target_os = "windows") {
259            additional_paths.push("/usr/local/lib".to_string());
260        }
261        if let Ok(home) = std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE")) {
262            additional_paths.push(format!("{home}/.local/lib"));
263        }
264
265        // Iterate over segments of the library search path.
266        for path in library_path
267            .split(path_sep)
268            .chain(additional_paths.iter().map(String::as_str))
269        {
270            // Try to open the library, if it exists.
271            let path = Path::new(path).join(libys_filename());
272            if !path.is_file() {
273                continue;
274            }
275            let library = Library::open(path);
276
277            // Store the error that happened if we haven't encountered one.
278            // The error we store always happened while trying to open an existing
279            // `libys.so` file.
280            // It should be helpful.
281            match library {
282                Ok(x) => return Ok(x),
283                Err(x) => {
284                    if first_error.is_none() {
285                        first_error = Some(x);
286                    }
287                }
288            }
289        }
290
291        // If `first_error` wasn't assigned, we found no matching path.
292        match first_error {
293            Some(x) => Err(x.into()),
294            None => Err(Error::NotFound),
295        }
296    }
297}
298
299impl Drop for YAMLScript {
300    fn drop(&mut self) {
301        let res = unsafe { (self.tear_down_isolate_fn)(self.isolate_thread) };
302        if res != 0 {
303            eprintln!("Warning: Failed to tear down yamlscript's GraalVM isolate");
304        }
305    }
306}
307
308/// A response from the yamlscript library.
309///
310/// The library reports success with an object resembling:
311/// ```json
312/// {
313///   "data": {
314///     // JSON value after evaluating the yamlscript
315///   }
316/// }
317/// ```
318///
319/// The library reports failure with an object resembling:
320/// ```json
321/// {
322///   "error": {
323///     // An error object (see [`LibYSError`]).
324///   }
325/// }
326/// ```
327///
328/// Upon success, we can directly deserialize the `data` object into the target type.
329/// Upon error, we deserialize the error as [`LibYSError`] so that it can be inspected.
330/// We however do not provide any inspection helpers.
331#[derive(Deserialize)]
332enum YsResponse<T> {
333    /// A JSON object containing the result of evaluating the yamlscript.
334    #[serde(rename = "data")]
335    Data(T),
336    /// An error object.
337    #[serde(rename = "error")]
338    Error(LibYSError),
339}