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}