1#![cfg_attr(
22 feature = "lua",
23 doc = r"
24## Lua API
25
26Enable the `lua` feature to embed the same byte-preserving normalization API
27into an existing [`mlua::Lua`] state. [`lua::create_module`] builds the API table
28without registering a global, while [`lua::register_module`] installs it as the
29default `dream_path` global.
30
31The `lua` feature does not choose a Lua backend. Engine/application crates own
32that decision and should enable exactly one shared [`mlua`] runtime for the final
33dependency graph. `DreamWeave` recommends `LuaJIT` in 5.2 compatibility mode and
34does not currently test these bindings against other Lua runtimes. If a host
35chooses another backend, it owns that compatibility burden. For standalone
36documentation and local smoke tests, the `standalone-lua` feature enables `lua`
37plus `mlua`'s `luajit52` and `vendored` features.
38
39The Lua API treats Lua strings as raw path bytes, preserving invalid UTF-8 and
40embedded NUL bytes. It is embed-only: this crate does not provide a `cdylib` Lua
41module loader, and hosts that already own a different Lua runtime should bind the
42Rust byte API themselves.
43"
44)]
45#![cfg_attr(
46 not(feature = "lua"),
47 doc = r#"
48## Lua API
49
50Lua bindings are available behind the `lua` feature. Build documentation with
51`--features standalone-lua` to include links to the embedded Lua API.
52
53The `lua` feature does not choose a Lua backend. Engine/application crates should
54enable exactly one shared `mlua` runtime for the final dependency graph.
55`DreamWeave` recommends `LuaJIT` in 5.2 compatibility mode and does not currently
56test these bindings against other Lua runtimes.
57"#
58)]
59
60use std::{borrow::Borrow, str::Utf8Error};
61
62use bstr::{BStr, BString, ByteSlice as _};
63
64pub use bstr;
65
66#[cfg(feature = "lua")]
67pub mod lua;
68
69#[derive(Clone, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
77pub struct NormalizedPath(BString);
78
79impl NormalizedPath {
80 #[must_use]
82 pub fn new(path: impl AsRef<[u8]>) -> Self {
83 Self(BString::from(normalize_path(path.as_ref())))
84 }
85
86 pub fn try_from_normalized_bytes(path: Vec<u8>) -> Result<Self, Vec<u8>> {
97 if is_normalized_path(&path) {
98 Ok(Self(BString::from(path)))
99 } else {
100 Err(path)
101 }
102 }
103
104 #[must_use]
112 pub fn from_normalized_bytes_unchecked(path: Vec<u8>) -> Self {
113 debug_assert!(is_normalized_path(&path));
114 Self(BString::from(path))
115 }
116
117 #[must_use]
119 pub fn as_bstr(&self) -> &BStr {
120 self.0.as_bstr()
121 }
122
123 #[must_use]
125 pub fn as_bytes(&self) -> &[u8] {
126 &self.0
127 }
128
129 pub fn to_str(&self) -> Result<&str, Utf8Error> {
139 std::str::from_utf8(self.as_bytes())
140 }
141
142 #[must_use]
147 pub fn file_name(&self) -> Option<&BStr> {
148 file_name_normalized(self.as_bytes())
149 }
150
151 #[must_use]
156 pub fn parent(&self) -> Option<&BStr> {
157 parent_normalized(self.as_bytes())
158 }
159
160 #[must_use]
165 pub fn extension(&self) -> Option<&BStr> {
166 extension_normalized(self.as_bytes())
167 }
168
169 #[must_use]
171 pub fn is_empty(&self) -> bool {
172 self.0.is_empty()
173 }
174
175 #[must_use]
177 pub fn len(&self) -> usize {
178 self.0.len()
179 }
180}
181
182impl AsRef<[u8]> for NormalizedPath {
183 fn as_ref(&self) -> &[u8] {
184 self.as_bytes()
185 }
186}
187
188impl AsRef<BStr> for NormalizedPath {
189 fn as_ref(&self) -> &BStr {
190 self.as_bstr()
191 }
192}
193
194impl Borrow<[u8]> for NormalizedPath {
195 fn borrow(&self) -> &[u8] {
196 self.as_bytes()
197 }
198}
199
200impl Borrow<BStr> for NormalizedPath {
201 fn borrow(&self) -> &BStr {
202 self.as_bstr()
203 }
204}
205
206impl From<&[u8]> for NormalizedPath {
207 fn from(path: &[u8]) -> Self {
208 Self::new(path)
209 }
210}
211
212impl From<&str> for NormalizedPath {
213 fn from(path: &str) -> Self {
214 Self::new(path)
215 }
216}
217
218impl From<&BStr> for NormalizedPath {
219 fn from(path: &BStr) -> Self {
220 Self::new(path)
221 }
222}
223
224impl From<Vec<u8>> for NormalizedPath {
225 fn from(path: Vec<u8>) -> Self {
226 Self::new(path)
227 }
228}
229
230impl From<String> for NormalizedPath {
231 fn from(path: String) -> Self {
232 Self::new(path)
233 }
234}
235
236impl From<BString> for NormalizedPath {
237 fn from(path: BString) -> Self {
238 Self::new(path)
239 }
240}
241
242impl From<NormalizedPath> for BString {
243 fn from(path: NormalizedPath) -> Self {
244 path.0
245 }
246}
247
248impl From<NormalizedPath> for Vec<u8> {
249 fn from(path: NormalizedPath) -> Self {
250 path.0.into()
251 }
252}
253
254#[must_use]
262pub fn is_normalized_path(path: &[u8]) -> bool {
263 let mut previous_was_separator = true;
264 for &byte in path {
265 match byte {
266 b'\\' | b'A'..=b'Z' => return false,
267 b'/' if previous_was_separator => return false,
268 b'/' => previous_was_separator = true,
269 _ => previous_was_separator = false,
270 }
271 }
272 true
273}
274
275#[must_use]
282pub fn file_name_normalized(path: &[u8]) -> Option<&BStr> {
283 let bytes = without_trailing_separator(path);
284 if bytes.is_empty() {
285 return None;
286 }
287 let start = bytes
288 .iter()
289 .rposition(|byte| *byte == b'/')
290 .map_or(0, |pos| pos + 1);
291 Some(bytes[start..].as_bstr())
292}
293
294#[must_use]
300pub fn parent_normalized(path: &[u8]) -> Option<&BStr> {
301 let bytes = without_trailing_separator(path);
302 let end = bytes.iter().rposition(|byte| *byte == b'/')?;
303 Some(bytes[..end].as_bstr())
304}
305
306#[must_use]
312pub fn extension_normalized(path: &[u8]) -> Option<&BStr> {
313 if path.ends_with(b"/") {
314 return None;
315 }
316 let file_name = file_name_normalized(path)?.as_bytes();
317 let dot = file_name.iter().rposition(|byte| *byte == b'.')?;
318 if dot == 0 || dot + 1 == file_name.len() {
319 return None;
320 }
321 Some(file_name[dot + 1..].as_bstr())
322}
323
324#[must_use]
326pub fn normalize_path(path: impl AsRef<[u8]>) -> Vec<u8> {
327 let path = path.as_ref();
328 let mut out = Vec::with_capacity(path.len());
329 normalize_path_into(&mut out, path);
330 out
331}
332
333#[must_use]
339pub fn normalize_path_owned(mut path: Vec<u8>) -> Vec<u8> {
340 normalize_path_in_place(&mut path);
341 path
342}
343
344pub fn normalize_path_in_place(path: &mut Vec<u8>) {
350 let mut write = 0;
351 let mut previous_was_separator = true;
352 for read in 0..path.len() {
353 let byte = match path[read] {
354 b'\\' => b'/',
355 b'A'..=b'Z' => path[read] + 32,
356 byte => byte,
357 };
358 if byte == b'/' && previous_was_separator {
359 continue;
360 }
361 path[write] = byte;
362 write += 1;
363 previous_was_separator = byte == b'/';
364 }
365 path.truncate(write);
366}
367
368pub fn normalize_path_into(out: &mut Vec<u8>, path: &[u8]) {
376 out.clear();
377 out.reserve(path.len());
378 for byte in path.iter().copied() {
379 let byte = match byte {
380 b'\\' => b'/',
381 b'A'..=b'Z' => byte + 32,
382 _ => byte,
383 };
384 if byte == b'/' && (out.is_empty() || out.last() == Some(&b'/')) {
385 continue;
386 }
387 out.push(byte);
388 }
389}
390
391fn without_trailing_separator(bytes: &[u8]) -> &[u8] {
392 bytes.strip_suffix(b"/").unwrap_or(bytes)
393}
394
395#[cfg(test)]
396mod tests {
397 use std::collections::HashMap;
398
399 use bstr::{BStr, BString};
400
401 use super::{
402 NormalizedPath, extension_normalized, file_name_normalized, is_normalized_path,
403 normalize_path, normalize_path_in_place, normalize_path_into, normalize_path_owned,
404 parent_normalized,
405 };
406
407 #[test]
408 fn leaves_empty_path_empty() {
409 assert_eq!(normalize_path(b""), b"");
410 }
411
412 #[test]
413 fn removes_leading_separators() {
414 assert_eq!(normalize_path(b"/foo"), b"foo");
415 assert_eq!(normalize_path(b"///foo//bar"), b"foo/bar");
416 }
417
418 #[test]
419 fn all_separators_normalize_to_empty() {
420 assert_eq!(normalize_path(b"/"), b"");
421 assert_eq!(normalize_path(br"\\\/"), b"");
422 }
423
424 #[test]
425 fn keeps_non_leading_trailing_separator() {
426 assert_eq!(normalize_path(b"foo/"), b"foo/");
427 assert_eq!(normalize_path(br"foo\\"), b"foo/");
428 }
429
430 #[test]
431 fn folds_backslashes_and_ascii_case() {
432 assert_eq!(normalize_path(br"FOO\BaR"), b"foo/bar");
433 }
434
435 #[test]
436 fn collapses_repeated_separators_after_backslash_folding() {
437 assert_eq!(normalize_path(br"foo\\//bar"), b"foo/bar");
438 }
439
440 #[test]
441 fn preserves_non_ascii_bytes() {
442 assert_eq!(normalize_path("Café/Ä".as_bytes()), "café/Ä".as_bytes());
443 }
444
445 #[test]
446 fn only_ascii_uppercase_is_folded() {
447 assert_eq!(normalize_path(b"ABC[\\]^_`XYZ"), b"abc[/]^_`xyz");
448 }
449
450 #[test]
451 fn preserves_invalid_utf8_bytes() {
452 assert_eq!(normalize_path(b"DIR/\xff/FILE"), b"dir/\xff/file");
453 }
454
455 #[test]
456 fn preserves_nul_bytes() {
457 assert_eq!(normalize_path(b"FOO\0BAR"), b"foo\0bar");
458 assert_eq!(normalize_path(b"DIR/\0/FILE"), b"dir/\0/file");
459 }
460
461 #[test]
462 fn does_not_resolve_dot_segments() {
463 assert_eq!(normalize_path(b"A/./B"), b"a/./b");
464 assert_eq!(normalize_path(b"A/../B"), b"a/../b");
465 assert_eq!(
466 NormalizedPath::new(b"Foo/../BAR").parent(),
467 Some(BStr::new(b"foo/.."))
468 );
469 }
470
471 #[test]
472 fn does_not_preserve_uri_or_host_path_syntax() {
473 assert_eq!(normalize_path(b"HTTP://Foo/Bar"), b"http:/foo/bar");
474 assert_eq!(normalize_path(br"C:\Foo"), b"c:/foo");
475 assert_eq!(
476 normalize_path(br"\\Server\Share\File"),
477 b"server/share/file"
478 );
479 }
480
481 #[test]
482 fn trailing_separator_remains_part_of_key() {
483 let file = NormalizedPath::new("textures/foo.dds");
484 let directory_like = NormalizedPath::new("textures/foo.dds/");
485
486 assert_ne!(file, directory_like);
487 assert_eq!(directory_like.as_bytes(), b"textures/foo.dds/");
488 }
489
490 #[test]
491 fn normalization_is_idempotent() {
492 let once = normalize_path(br"//Foo\\BAR///baz");
493 let twice = normalize_path(&once);
494 assert_eq!(once, twice);
495 }
496
497 #[test]
498 fn normalization_invariants_hold_for_byte_corpus() {
499 let mut cases: Vec<Vec<u8>> = (0u8..=u8::MAX).map(|byte| vec![byte]).collect();
500 cases.extend([
501 b"".to_vec(),
502 br"//Foo\\BAR///baz".to_vec(),
503 b"HTTP://Foo/Bar".to_vec(),
504 br"C:\Foo".to_vec(),
505 br"\\Server\Share\File".to_vec(),
506 b"A/./B".to_vec(),
507 b"A/../B".to_vec(),
508 b"DIR/\0/FILE".to_vec(),
509 b"DIR/\xff/FILE".to_vec(),
510 br"/A//B\\C/".to_vec(),
511 ]);
512
513 for case in cases {
514 let normalized = normalize_path(&case);
515 assert!(
516 is_normalized_path(&normalized),
517 "normalized output failed predicate: {case:?}"
518 );
519 assert_eq!(
520 normalize_path(&normalized),
521 normalized,
522 "normalization was not idempotent: {case:?}"
523 );
524 assert!(
525 !normalized.contains(&b'\\'),
526 "backslash survived normalization: {case:?}"
527 );
528 assert!(
529 !normalized.iter().any(u8::is_ascii_uppercase),
530 "uppercase ASCII survived normalization: {case:?}"
531 );
532 assert!(
533 !normalized.starts_with(b"/"),
534 "leading slash survived normalization: {case:?}"
535 );
536 assert!(
537 !normalized.windows(2).any(|window| window == b"//"),
538 "repeated slash survived normalization: {case:?}"
539 );
540 }
541 }
542
543 #[test]
544 fn detects_already_normalized_paths() {
545 assert!(is_normalized_path(b""));
546 assert!(is_normalized_path(b"textures/foo.dds"));
547 assert!(is_normalized_path(b"textures/foo/"));
548 assert!(is_normalized_path(b"textures/\xff/file"));
549 assert!(is_normalized_path(b"foo\0bar"));
550 assert!(is_normalized_path(b"foo/../bar"));
551 assert!(is_normalized_path(b"c:/foo"));
552
553 assert!(!is_normalized_path(b"/textures/foo.dds"));
554 assert!(!is_normalized_path(b"textures//foo.dds"));
555 assert!(!is_normalized_path(br"textures\foo.dds"));
556 assert!(!is_normalized_path(b"textures/FOO.dds"));
557 }
558
559 #[test]
560 fn normalize_into_reuses_and_clears_output() {
561 let mut out = b"stale".to_vec();
562 let capacity = out.capacity();
563 normalize_path_into(&mut out, br"/Foo\Bar");
564 assert_eq!(out, b"foo/bar");
565 assert!(out.capacity() >= capacity);
566 }
567
568 #[test]
569 fn normalize_owned_and_in_place_reuse_existing_storage() {
570 let mut path = br"//Textures\\Foo///BAR.DDS".to_vec();
571 let capacity = path.capacity();
572 normalize_path_in_place(&mut path);
573 assert_eq!(path, b"textures/foo/bar.dds");
574 assert_eq!(path.capacity(), capacity);
575
576 assert_eq!(
577 normalize_path_owned(br"//Meshes\\Door.NIF".to_vec()),
578 b"meshes/door.nif"
579 );
580 }
581
582 #[test]
583 fn normalized_path_exposes_bytes_and_length() {
584 let path = NormalizedPath::new(br"/Meshes\Thing.NIF");
585 assert_eq!(path.as_bytes(), b"meshes/thing.nif");
586 assert_eq!(path.as_bstr(), b"meshes/thing.nif".as_slice());
587 assert_eq!(path.len(), b"meshes/thing.nif".len());
588 assert!(!path.is_empty());
589 }
590
591 #[test]
592 fn normalized_path_reports_utf8_only_when_valid() {
593 assert_eq!(
594 NormalizedPath::new("Textures/Foo.DDS").to_str(),
595 Ok("textures/foo.dds")
596 );
597 assert!(NormalizedPath::new(b"textures/\xff.dds").to_str().is_err());
598 assert_eq!(NormalizedPath::new(b"A\0B").to_str(), Ok("a\0b"));
599 }
600
601 #[test]
602 fn normalized_path_exposes_virtual_components() {
603 let path = NormalizedPath::new(br"/Textures/Architecture/Wall.DDS");
604 assert_eq!(path.parent(), Some(BStr::new(b"textures/architecture")));
605 assert_eq!(path.file_name(), Some(BStr::new(b"wall.dds")));
606 assert_eq!(path.extension(), Some(BStr::new(b"dds")));
607
608 let directory_like = NormalizedPath::new("textures/foo/");
609 assert_eq!(directory_like.parent(), Some(BStr::new(b"textures")));
610 assert_eq!(directory_like.file_name(), Some(BStr::new(b"foo")));
611 assert_eq!(directory_like.extension(), None);
612 }
613
614 #[test]
615 fn normalized_path_extension_is_byte_literal() {
616 assert_eq!(
617 NormalizedPath::new("foo.tar.gz").extension(),
618 Some(BStr::new(b"gz"))
619 );
620 assert_eq!(NormalizedPath::new(".hidden").extension(), None);
621 assert_eq!(NormalizedPath::new("foo.").extension(), None);
622 assert_eq!(NormalizedPath::new("foo.dds/").extension(), None);
623 assert_eq!(
624 NormalizedPath::new(b"foo.\xff").extension(),
625 Some(BStr::new(b"\xff"))
626 );
627 }
628
629 #[test]
630 fn normalized_component_helpers_operate_on_borrowed_bytes() {
631 let path = b"textures/architecture/wall.dds";
632
633 assert_eq!(
634 parent_normalized(path),
635 Some(BStr::new(b"textures/architecture"))
636 );
637 assert_eq!(file_name_normalized(path), Some(BStr::new(b"wall.dds")));
638 assert_eq!(extension_normalized(path), Some(BStr::new(b"dds")));
639 assert_eq!(extension_normalized(b"textures/foo.dds/"), None);
640 }
641
642 #[test]
643 fn checked_normalized_constructor_rejects_unnormalized_bytes() {
644 let path = NormalizedPath::try_from_normalized_bytes(b"textures/foo.dds".to_vec())
645 .expect("path is already normalized");
646 assert_eq!(path.as_bytes(), b"textures/foo.dds");
647 assert_eq!(
648 NormalizedPath::try_from_normalized_bytes(b"textures/foo/".to_vec())
649 .expect("trailing separator is normalized")
650 .as_bytes(),
651 b"textures/foo/"
652 );
653
654 for path in [
655 b"Textures/Foo.DDS".as_slice(),
656 b"/textures/foo.dds".as_slice(),
657 b"textures//foo.dds".as_slice(),
658 br"textures\foo.dds".as_slice(),
659 ] {
660 let rejected = NormalizedPath::try_from_normalized_bytes(path.to_vec())
661 .expect_err("path is not normalized");
662 assert_eq!(rejected, path);
663 }
664 }
665
666 #[test]
667 fn normalized_path_borrows_as_normalized_bytes_for_lookup() {
668 let mut values = HashMap::new();
669 values.insert(NormalizedPath::new(br"/Meshes\Thing.NIF"), 7);
670
671 assert_eq!(values.get(b"meshes/thing.nif".as_slice()), Some(&7));
672 assert_eq!(values.get(BStr::new(b"meshes/thing.nif")), Some(&7));
673 }
674
675 #[test]
676 fn normalized_path_as_ref_supports_byte_and_bstr_views() {
677 let path = NormalizedPath::new(br"/Textures\Foo.DDS");
678 let bytes: &[u8] = path.as_ref();
679 let bstr: &BStr = path.as_ref();
680
681 assert_eq!(bytes, b"textures/foo.dds");
682 assert_eq!(bstr, b"textures/foo.dds".as_slice());
683 }
684
685 #[test]
686 fn normalized_path_converts_into_owned_byte_strings() {
687 let path = NormalizedPath::new(br"/Icons\Foo.TGA");
688 let bstring = BString::from(path.clone());
689 let bytes = Vec::<u8>::from(path);
690
691 assert_eq!(bstring, b"icons/foo.tga".as_slice());
692 assert_eq!(bytes, b"icons/foo.tga");
693 }
694
695 #[test]
696 fn from_impls_normalize() {
697 let bstring = BString::from(b"/Foo".to_vec());
698
699 assert_eq!(NormalizedPath::from("/Foo").as_bytes(), b"foo");
700 assert_eq!(NormalizedPath::from(b"/Foo".as_slice()).as_bytes(), b"foo");
701 assert_eq!(NormalizedPath::from(BStr::new(&bstring)).as_bytes(), b"foo");
702 assert_eq!(
703 NormalizedPath::from(String::from("/Foo")).as_bytes(),
704 b"foo"
705 );
706 assert_eq!(NormalizedPath::from(b"/Foo".to_vec()).as_bytes(), b"foo");
707 assert_eq!(
708 NormalizedPath::from(BString::from(b"/Foo".to_vec())).as_bytes(),
709 b"foo"
710 );
711 }
712}