Skip to main content

dream_path/
lua.rs

1//! Embedded Lua bindings for byte-first path normalization.
2//!
3//! This module is available with the `lua` feature. It does not define a
4//! `cdylib` Lua module; hosts embed it into their own [`mlua::Lua`] state and
5//! choose the namespace they want.
6//!
7//! The feature selects `mlua` with vendored `LuaJIT` in 5.2 compatibility mode.
8//! Libraries should avoid enabling it transitively unless they own the embedding
9//! runtime. Hosts using a different Lua runtime should bind the Rust byte API
10//! themselves.
11//! Returned Lua strings may contain embedded NUL bytes; C hosts must use
12//! length-aware Lua APIs rather than C string length.
13
14use bstr::ByteSlice as _;
15use mlua::{Error, Lua, Result, String as LuaString, Table, Value};
16
17use crate::{NormalizedPath, is_normalized_path, normalize_path};
18
19/// Default Lua global name used by [`register_module`].
20pub const MODULE_NAME: &str = "dream_path";
21
22/// Create the `dream_path` Lua API table without registering it globally.
23///
24/// The API is intentionally thin and byte-preserving. Lua strings are treated
25/// as byte strings; invalid UTF-8 is accepted anywhere a path is accepted.
26/// Path arguments must be Lua strings. Missing or non-string arguments are Lua
27/// argument errors; missing path components are returned as `nil`.
28///
29/// # Errors
30///
31/// Returns an error if creating Lua functions or strings fails.
32pub fn create_module(lua: &Lua) -> Result<Table> {
33    let module = lua.create_table()?;
34    module.set(
35        "normalize",
36        lua.create_function(|lua, path: Value| {
37            let path = expect_string(path)?;
38            lua.create_string(normalize_path(path.as_bytes()).as_slice())
39        })?,
40    )?;
41    module.set(
42        "is_normalized",
43        lua.create_function(|_, path: Value| {
44            let path = expect_string(path)?;
45            Ok(is_normalized_path(path.as_bytes().as_ref()))
46        })?,
47    )?;
48    module.set(
49        "file_name",
50        lua.create_function(|lua, path: Value| {
51            let path = expect_string(path)?;
52            component(lua, &path, NormalizedPath::file_name)
53        })?,
54    )?;
55    module.set(
56        "parent",
57        lua.create_function(|lua, path: Value| {
58            let path = expect_string(path)?;
59            component(lua, &path, NormalizedPath::parent)
60        })?,
61    )?;
62    module.set(
63        "extension",
64        lua.create_function(|lua, path: Value| {
65            let path = expect_string(path)?;
66            component(lua, &path, NormalizedPath::extension)
67        })?,
68    )?;
69    module.set(
70        "is_utf8",
71        lua.create_function(|_, path: Value| {
72            let path = expect_string(path)?;
73            Ok(path.as_bytes().as_ref().is_utf8())
74        })?,
75    )?;
76    Ok(module)
77}
78
79/// Register the Lua API table as the `dream_path` global.
80///
81/// # Errors
82///
83/// Returns an error if creating or assigning the module table fails.
84pub fn register_module(lua: &Lua) -> Result<()> {
85    register_module_as(lua, MODULE_NAME)
86}
87
88/// Register the Lua API table under a caller-selected global name.
89///
90/// This is the dehardcoding valve for hosts that want a different namespace.
91/// `name` is used as a direct key in [`Lua::globals`]; dotted names such as
92/// `"foo.bar"` are not parsed into nested tables.
93///
94/// # Errors
95///
96/// Returns an error if `name` is empty or if creating or assigning the module
97/// table fails.
98pub fn register_module_as(lua: &Lua, name: &str) -> Result<()> {
99    if name.is_empty() {
100        return Err(Error::RuntimeError(
101            "Lua module global name must not be empty".to_owned(),
102        ));
103    }
104    let module = create_module(lua)?;
105    lua.globals().set(name, module)
106}
107
108fn component(
109    lua: &Lua,
110    path: &LuaString,
111    select: impl FnOnce(&NormalizedPath) -> Option<&bstr::BStr>,
112) -> Result<Option<LuaString>> {
113    let path = NormalizedPath::new(path.as_bytes());
114    select(&path)
115        .map(|value| lua.create_string(value.as_bytes()))
116        .transpose()
117}
118
119fn expect_string(value: Value) -> Result<LuaString> {
120    match value {
121        Value::String(value) => Ok(value),
122        value => Err(Error::FromLuaConversionError {
123            from: value.type_name(),
124            to: "string".to_owned(),
125            message: Some("path arguments must be Lua strings".to_owned()),
126        }),
127    }
128}
129
130#[cfg(test)]
131mod tests {
132    use mlua::{Lua, String as LuaString};
133
134    use super::{MODULE_NAME, register_module, register_module_as};
135
136    #[test]
137    fn module_normalizes_lua_strings_as_bytes() {
138        let lua = Lua::new();
139        register_module(&lua).expect("module registration should succeed");
140
141        let normalized: LuaString = lua
142            .load(r#"return dream_path.normalize("Textures\\Foo.DDS")"#)
143            .eval()
144            .expect("normalization should succeed");
145
146        assert_eq!(normalized.as_bytes().as_ref(), b"textures/foo.dds");
147    }
148
149    #[test]
150    fn module_preserves_invalid_utf8_bytes() {
151        let lua = Lua::new();
152        register_module(&lua).expect("module registration should succeed");
153
154        let normalized: LuaString = lua
155            .load(r#"return dream_path.normalize("DIR/\255/FILE")"#)
156            .eval()
157            .expect("normalization should succeed");
158        let is_utf8: bool = lua
159            .load(r#"return dream_path.is_utf8("DIR/\255/FILE")"#)
160            .eval()
161            .expect("UTF-8 check should succeed");
162
163        assert_eq!(normalized.as_bytes().as_ref(), b"dir/\xff/file");
164        assert!(!is_utf8);
165    }
166
167    #[test]
168    fn module_preserves_embedded_nul_bytes() {
169        let lua = Lua::new();
170        register_module(&lua).expect("module registration should succeed");
171
172        let normalized: LuaString = lua
173            .load(r#"return dream_path.normalize("A\0B")"#)
174            .eval()
175            .expect("normalization should succeed");
176
177        assert_eq!(normalized.as_bytes().as_ref(), b"a\0b");
178    }
179
180    #[test]
181    fn module_helpers_normalize_before_splitting() {
182        let lua = Lua::new();
183        register_module(&lua).expect("module registration should succeed");
184
185        let values: (LuaString, LuaString, LuaString, bool) = lua
186            .load(
187                r#"
188                return
189                    dream_path.parent("/Textures\\Architecture/Wall.DDS"),
190                    dream_path.file_name("/Textures\\Architecture/Wall.DDS"),
191                    dream_path.extension("/Textures\\Architecture/Wall.DDS"),
192                    dream_path.is_normalized("textures/architecture/wall.dds")
193                "#,
194            )
195            .eval()
196            .expect("helper calls should succeed");
197
198        assert_eq!(values.0.as_bytes().as_ref(), b"textures/architecture");
199        assert_eq!(values.1.as_bytes().as_ref(), b"wall.dds");
200        assert_eq!(values.2.as_bytes().as_ref(), b"dds");
201        assert!(values.3);
202    }
203
204    #[test]
205    fn module_helpers_return_nil_for_missing_components() {
206        let lua = Lua::new();
207        register_module(&lua).expect("module registration should succeed");
208
209        let values: (
210            Option<LuaString>,
211            Option<LuaString>,
212            Option<LuaString>,
213            Option<LuaString>,
214        ) = lua
215            .load(
216                r#"
217                return
218                    dream_path.file_name("/"),
219                    dream_path.parent("foo"),
220                    dream_path.extension(".hidden"),
221                    dream_path.extension("foo.")
222                "#,
223            )
224            .eval()
225            .expect("helper calls should succeed");
226
227        assert!(values.0.is_none());
228        assert!(values.1.is_none());
229        assert!(values.2.is_none());
230        assert!(values.3.is_none());
231    }
232
233    #[test]
234    fn module_rejects_missing_or_non_string_path_arguments() {
235        let lua = Lua::new();
236        register_module(&lua).expect("module registration should succeed");
237
238        assert!(
239            lua.load("return dream_path.normalize()")
240                .eval::<LuaString>()
241                .is_err()
242        );
243        assert!(
244            lua.load("return dream_path.normalize(nil)")
245                .eval::<LuaString>()
246                .is_err()
247        );
248        assert!(
249            lua.load("return dream_path.normalize(42)")
250                .eval::<LuaString>()
251                .is_err()
252        );
253        assert!(
254            lua.load("return dream_path.normalize({})")
255                .eval::<LuaString>()
256                .is_err()
257        );
258    }
259
260    #[test]
261    fn module_helpers_preserve_invalid_byte_extensions() {
262        let lua = Lua::new();
263        register_module(&lua).expect("module registration should succeed");
264
265        let extension: LuaString = lua
266            .load(r#"return dream_path.extension("Foo.\255")"#)
267            .eval()
268            .expect("extension should succeed");
269
270        assert_eq!(extension.as_bytes().as_ref(), b"\xff");
271    }
272
273    #[test]
274    fn module_can_be_registered_under_custom_name() {
275        let lua = Lua::new();
276        register_module_as(&lua, "paths").expect("module registration should succeed");
277
278        let normalized: LuaString = lua
279            .load(r#"return paths.normalize("Meshes\\Door.NIF")"#)
280            .eval()
281            .expect("normalization should succeed");
282        let default_global_exists: bool = lua
283            .load(format!(r"return {MODULE_NAME} ~= nil"))
284            .eval()
285            .expect("global check should succeed");
286
287        assert_eq!(normalized.as_bytes().as_ref(), b"meshes/door.nif");
288        assert!(!default_global_exists);
289    }
290
291    #[test]
292    fn module_rejects_empty_registration_name() {
293        let lua = Lua::new();
294
295        assert!(register_module_as(&lua, "").is_err());
296    }
297}