lune_utils/path/luau.rs
1/*!
2 Utilities for working with Luau module paths.
3*/
4
5use std::{
6 ffi::OsStr,
7 fmt,
8 path::{Path, PathBuf},
9};
10
11use mlua::prelude::*;
12
13use super::constants::{FILE_EXTENSIONS, FILE_NAME_INIT};
14use super::std::append_extension;
15
16/**
17 A file path for Luau, which has been resolved to either a valid file or directory.
18
19 Not to be confused with [`LuauModulePath`]. This is the path
20 **on the filesystem**, and not the abstracted module path.
21*/
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub enum LuauFilePath {
24 /// A resolved and valid file path.
25 File(PathBuf),
26 /// A resolved and valid directory path.
27 Directory(PathBuf),
28}
29
30impl LuauFilePath {
31 fn resolve(module: impl AsRef<Path>) -> Result<Self, LuaNavigateError> {
32 let module = module.as_ref();
33
34 // Modules named "init" are ambiguous and not allowed
35 if module
36 .file_name()
37 .is_some_and(|n| n == OsStr::new(FILE_NAME_INIT))
38 {
39 return Err(LuaNavigateError::Ambiguous);
40 }
41
42 let mut found = None;
43
44 // Try files first
45 for ext in FILE_EXTENSIONS {
46 let candidate = append_extension(module, ext);
47 if candidate.is_file() && found.replace(candidate).is_some() {
48 return Err(LuaNavigateError::Ambiguous);
49 }
50 }
51
52 // Try directories with init files in them
53 if module.is_dir() {
54 let init = Path::new(FILE_NAME_INIT);
55 for ext in FILE_EXTENSIONS {
56 let candidate = module.join(append_extension(init, ext));
57 if candidate.is_file() && found.replace(candidate).is_some() {
58 return Err(LuaNavigateError::Ambiguous);
59 }
60 }
61
62 // If we have not found any luau / lua files, and we also did not find
63 // any init files in this directory, we still found a valid directory
64 if found.is_none() {
65 return Ok(Self::Directory(module.to_path_buf()));
66 }
67 }
68
69 // We have now narrowed down our resulting module
70 // path to be exactly one valid path, or no path
71 found.map(Self::File).ok_or(LuaNavigateError::NotFound)
72 }
73
74 #[must_use]
75 pub const fn is_file(&self) -> bool {
76 matches!(self, Self::File(_))
77 }
78
79 #[must_use]
80 pub const fn is_dir(&self) -> bool {
81 matches!(self, Self::Directory(_))
82 }
83
84 #[must_use]
85 pub fn as_file(&self) -> Option<&Path> {
86 match self {
87 Self::File(path) => Some(path),
88 Self::Directory(_) => None,
89 }
90 }
91
92 #[must_use]
93 pub fn as_dir(&self) -> Option<&Path> {
94 match self {
95 Self::File(_) => None,
96 Self::Directory(path) => Some(path),
97 }
98 }
99}
100
101impl AsRef<Path> for LuauFilePath {
102 fn as_ref(&self) -> &Path {
103 match self {
104 Self::File(path) | Self::Directory(path) => path.as_ref(),
105 }
106 }
107}
108
109impl fmt::Display for LuauFilePath {
110 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
111 match self {
112 Self::Directory(path) | Self::File(path) => path.display().fmt(f),
113 }
114 }
115}
116
117/**
118 A resolved module path for Luau, containing both:
119
120 - The **source** Luau module path.
121 - The **target** filesystem path.
122
123 Note the separation here - the source is not necessarily a valid filesystem path,
124 and the target is not necessarily a valid Luau module path for require-by-string.
125
126 See [`LuauFilePath`] and [`LuauModulePath::resolve`] for more information.
127*/
128#[derive(Debug, Clone, PartialEq, Eq)]
129pub struct LuauModulePath {
130 // The originating module path
131 source: PathBuf,
132 // The target filesystem path
133 target: LuauFilePath,
134}
135
136impl LuauModulePath {
137 /**
138 Strips Luau file extensions and potential init segments from a given path.
139
140 This is the opposite operation of [`LuauModulePath::resolve`] and is generally
141 useful for converting between paths in a CLI or other similar use cases - but
142 should *never* be used to implement `require` resolution.
143
144 Does not use any filesystem calls and will not panic.
145 */
146 #[must_use]
147 pub fn strip(path: impl Into<PathBuf>) -> PathBuf {
148 let mut path: PathBuf = path.into();
149
150 if path
151 .extension()
152 .and_then(|e| e.to_str())
153 .is_some_and(|e| FILE_EXTENSIONS.contains(&e))
154 {
155 path = path.with_extension("");
156 }
157
158 if path
159 .file_name()
160 .and_then(|e| e.to_str())
161 .is_some_and(|f| f == FILE_NAME_INIT)
162 {
163 path.pop();
164 }
165
166 path
167 }
168
169 /**
170 Resolves an existing file or directory path for the given *module* path.
171
172 Given a *module* path "path/to/module", these files will be searched:
173
174 - `path/to/module.luau`
175 - `path/to/module.lua`
176 - `path/to/module/init.luau`
177 - `path/to/module/init.lua`
178
179 If the given path ("path/to/module") is a directory instead,
180 and it exists, it will be returned without any modifications.
181
182 # Errors
183
184 - If the given module path is ambiguous.
185 - If the given module path does not resolve to a valid file or directory.
186 */
187 pub fn resolve(module: impl Into<PathBuf>) -> Result<Self, LuaNavigateError> {
188 let source = module.into();
189 let target = LuauFilePath::resolve(&source)?;
190 Ok(Self { source, target })
191 }
192
193 /**
194 Returns the source Luau module path.
195 */
196 #[must_use]
197 pub fn source(&self) -> &Path {
198 &self.source
199 }
200
201 /**
202 Returns the target filesystem file path.
203 */
204 #[must_use]
205 pub fn target(&self) -> &LuauFilePath {
206 &self.target
207 }
208}
209
210impl fmt::Display for LuauModulePath {
211 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
212 self.source().display().fmt(f)
213 }
214}