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