artichoke_load_path/rubylib.rs
1//! A Ruby source loader that resolves sources relative to paths given in a
2//! `RUBYLIB` environment variable.
3
4use std::collections::hash_map::{Entry, HashMap};
5use std::env;
6use std::ffi::OsStr;
7use std::fs::{self, File};
8use std::io;
9use std::path::{Path, PathBuf};
10
11use same_file::Handle;
12
13/// A Ruby source code loader that searches in paths given by the `RUBYLIB`
14/// environment variable.
15///
16/// MRI Ruby allows manipulating the [require] search path by setting the
17/// `RUBYLIB` environment variable before launching the Ruby CLI. The `RUBYLIB`
18/// variable is read on start-up and is expected to contain a platform-native
19/// path separator-delimited list of file system paths.
20///
21/// This loader will attempt to resolve relative paths in any of the paths given
22/// in `RUBYLIB`. Absolute paths are rejected by this loader.
23///
24/// This loader tracks the files it has loaded, which MRI refers to as "loaded
25/// features". This loader deduplicates loaded features be detecting whether the
26/// given path [resolves to the same file] as a previously loaded path.
27///
28/// The `RUBYLIB` environment variable or other sequence of paths is parsed when
29/// this loader is created and is immutable.
30///
31/// This loader resolves files in the search paths in the order the directories
32/// appear in the `RUBYLIB` environment variable. Paths earlier in the sequence
33/// have higher priority.
34///
35/// ```no_run
36/// # use std::ffi::OsStr;
37/// # use std::path::Path;
38/// # use artichoke_load_path::Rubylib;
39/// # fn example() -> Option<()> {
40/// // Grab the load paths from the `RUBYLIB` environment variable. If the
41/// // variable is empty or unset, `None` is returned.
42/// //
43/// // Relative paths in `RUBYLIB` are resolved relative to the current process's
44/// // current working directory.
45/// let env_loader = Rubylib::new()?;
46///
47/// // Search `/home/artichoke/src` first, only attempting to search
48/// // `/usr/share/artichoke` if no file is found in `/home/artichoke/src`.
49/// //
50/// // The relative path `./_lib` is resolved relative to the given working
51/// // directory.
52/// let fixed_loader = Rubylib::with_rubylib_and_cwd(
53/// OsStr::new("/home/artichoke/src:/usr/share/artichoke:./_lib"),
54/// Path::new("/home/artichoke"),
55/// )?;
56/// # Some(())
57/// # }
58/// # example().unwrap();
59/// ```
60///
61/// [require]: https://ruby-doc.org/core-3.1.2/Kernel.html#method-i-require
62/// [resolves to the same file]: same_file
63#[derive(Debug, PartialEq, Eq)]
64#[cfg_attr(docsrs, doc(cfg(feature = "rubylib-native-file-system-loader")))]
65pub struct Rubylib {
66 /// Fixed set of paths on the host file system to search for Ruby sources.
67 load_paths: Box<[PathBuf]>,
68 loaded_features: HashMap<Handle, PathBuf>,
69}
70
71impl Rubylib {
72 /// Create a new native file system loader that searches the file system for
73 /// Ruby sources at the paths specified by the `RUBYLIB` environment
74 /// variable.
75 ///
76 /// The `RUBYLIB` environment variable is resolved once at the time this
77 /// method is called and the resolved load path is immutable.
78 ///
79 /// If any of the paths in the `RUBYLIB` environment variable are not
80 /// absolute paths, they are absolutized relative to the current process's
81 /// [current working directory] at the time this method is called.
82 ///
83 /// This source loader grants access to the host file system. The `Rubylib`
84 /// loader does not support native extensions.
85 ///
86 /// This method returns [`None`] if there are errors resolving the
87 /// `RUBYLIB` environment variable, if the `RUBYLIB` environment variable is
88 /// not set, if the current working directory cannot be retrieved, or if the
89 /// `RUBYLIB` environment variable does not contain any paths.
90 ///
91 /// [current working directory]: env::current_dir
92 #[inline]
93 #[must_use]
94 pub fn new() -> Option<Self> {
95 let rubylib = env::var_os("RUBYLIB")?;
96 let cwd = env::current_dir().ok()?;
97 Self::with_rubylib_and_cwd(&rubylib, &cwd)
98 }
99
100 /// Create a new native file system loader that searches the file system for
101 /// Ruby sources at the paths specified by the given `rubylib` platform
102 /// string. `rubylib` is expected to be a set of file system paths that are
103 /// delimited by the platform path separator.
104 ///
105 /// The resolved load path is immutable.
106 ///
107 /// If any of the paths in the given `rubylib` are not absolute paths, they
108 /// are absolutized relative to the current process's [current working
109 /// directory] at the time this method is called.
110 ///
111 /// This source loader grants access to the host file system. The `Rubylib`
112 /// loader does not support native extensions.
113 ///
114 /// This method returns [`None`] if the current working directory cannot be
115 /// retrieved or if the given `rubylib` does not contain any paths.
116 ///
117 /// [current working directory]: env::current_dir
118 #[inline]
119 #[must_use]
120 pub fn with_rubylib(rubylib: &OsStr) -> Option<Self> {
121 let cwd = env::current_dir().ok()?;
122 Self::with_rubylib_and_cwd(rubylib, &cwd)
123 }
124
125 /// Create a new native file system loader that searches the file system for
126 /// Ruby sources at the paths specified by the given `rubylib` platform
127 /// string. `rubylib` is expected to be a set of file system paths that are
128 /// delimited by the platform path separator.
129 ///
130 /// The resolved load path is immutable.
131 ///
132 /// If any of the paths in the given `rubylib` are not absolute paths, they
133 /// are absolutized relative to the given current working directory at the
134 /// time this method is called.
135 ///
136 /// This source loader grants access to the host file system. The `Rubylib`
137 /// loader does not support native extensions.
138 ///
139 /// This method returns [`None`] if the given `rubylib` does not contain any
140 /// paths.
141 #[inline]
142 #[must_use]
143 pub fn with_rubylib_and_cwd(rubylib: &OsStr, cwd: &Path) -> Option<Self> {
144 let load_paths = env::split_paths(rubylib)
145 .map(|load_path| cwd.join(load_path))
146 .collect::<Box<[_]>>();
147
148 // If the `RUBYLIB` env variable is empty or otherwise results in no
149 // search paths being resolved, return `None` so the `Rubylib` loader is
150 // not used.
151 if load_paths.is_empty() {
152 return None;
153 }
154 // Individual source files that the Ruby interpreter requires and loads
155 // are called "features" and are exposed in a virtual global variable
156 // called `$LOADED_FEATURES` or `$"`.
157 let loaded_features = HashMap::new();
158
159 Some(Self {
160 load_paths,
161 loaded_features,
162 })
163 }
164
165 /// Check whether `path` points to a file in the virtual file system and
166 /// return the absolute path if it exists.
167 ///
168 /// Returns [`Some`] if the file system object pointed to by `path` exists.
169 /// If `path` is relative, it is joined to each path in the `RUBYLIB`
170 /// environment variable at the time this loader was initialized.
171 ///
172 /// This method is infallible and will return [`None`] for non-existent
173 /// paths.
174 #[inline]
175 #[must_use]
176 pub fn resolve_file(&self, path: &Path) -> Option<PathBuf> {
177 // The `Rubylib` loader only loads relative paths in `RUBYLIB`.
178 if path.is_absolute() {
179 return None;
180 }
181 for load_path in &*self.load_paths {
182 let path = load_path.join(path);
183 if File::open(&path).is_ok() {
184 return Some(path);
185 }
186 }
187 None
188 }
189
190 /// Check whether `path` points to a file in the virtual file system.
191 ///
192 /// Returns `true` if the file system object pointed to by `path` exists and
193 /// is a readable file. If `path` is relative, it is absolutized relative
194 /// to each path in the `RUBYLIB` environment variable at the time this
195 /// loader was initialized.
196 ///
197 /// This method is infallible and will return `false` for non-existent
198 /// paths.
199 #[inline]
200 #[must_use]
201 pub fn is_file(&self, path: &Path) -> bool {
202 // The `Rubylib` loader only loads relative paths in `RUBYLIB`.
203 if path.is_absolute() {
204 return false;
205 }
206 for load_path in &*self.load_paths {
207 let path = load_path.join(path);
208 if File::open(path).is_ok() {
209 return true;
210 }
211 }
212 false
213 }
214
215 /// Read file contents for the file at `path`.
216 ///
217 /// Returns a byte vec of complete file contents. If `path` is relative, it
218 /// is absolutized relative to each path in the `RUBYLIB` environment
219 /// variable at the time this loader was initialized.
220 ///
221 /// # Errors
222 ///
223 /// If `path` does not exist, an [`io::Error`] with error kind
224 /// [`io::ErrorKind::NotFound`] is returned.
225 #[inline]
226 pub fn read_file(&self, path: &Path) -> io::Result<Vec<u8>> {
227 // The `Rubylib` loader only loads relative paths in `RUBYLIB`.
228 if path.is_absolute() {
229 return Err(io::Error::new(
230 io::ErrorKind::Other,
231 "Only relative paths can be loaded from RUBYLIB",
232 ));
233 }
234 for load_path in &*self.load_paths {
235 let path = load_path.join(path);
236 if let Ok(contents) = fs::read(path) {
237 return Ok(contents);
238 }
239 }
240 Err(io::Error::new(
241 io::ErrorKind::NotFound,
242 "path not found in RUBYLIB load paths",
243 ))
244 }
245
246 /// Check whether a file at `path` has been required already.
247 ///
248 /// Returns `true` if the path is a loaded feature, false otherwise. If
249 /// `path` is relative, it is absolutized relative to each path in the
250 /// `RUBYLIB` environment variable at the time this loader was initialized.
251 ///
252 /// This method is infallible and will return `false` for non-existent
253 /// paths.
254 #[inline]
255 #[must_use]
256 pub fn is_required(&self, path: &Path) -> Option<bool> {
257 // The `Rubylib` loader only loads relative paths in `RUBYLIB`.
258 if path.is_absolute() {
259 return None;
260 }
261 for load_path in &*self.load_paths {
262 let path = load_path.join(path);
263 if let Ok(handle) = Handle::from_path(path) {
264 return Some(self.loaded_features.contains_key(&handle));
265 }
266 }
267 None
268 }
269
270 /// Mark a source at `path` as required on the interpreter.
271 ///
272 /// This metadata is used by [`Kernel#require`] and friends to enforce that
273 /// Ruby sources are only loaded into the interpreter once to limit side
274 /// effects.
275 ///
276 /// If `path` is relative, it is absolutized relative to each path in the
277 /// `RUBYLIB` environment variable at the time this loader was initialized.
278 ///
279 /// This method is infallible and will return `false` for non-existent
280 /// paths.
281 ///
282 /// # Errors
283 ///
284 /// If `path` does not exist, an [`io::Error`] with error kind
285 /// [`io::ErrorKind::NotFound`] is returned.
286 ///
287 /// [`Kernel#require`]: https://ruby-doc.org/core-3.1.2/Kernel.html#method-i-require
288 #[inline]
289 pub fn mark_required(&mut self, path: &Path) -> io::Result<()> {
290 for load_path in &*self.load_paths {
291 let path = load_path.join(path);
292 if let Ok(handle) = Handle::from_path(&path) {
293 match self.loaded_features.entry(handle) {
294 Entry::Occupied(_) => {
295 return Err(io::Error::new(io::ErrorKind::Other, "file is already required"));
296 }
297 Entry::Vacant(entry) => {
298 entry.insert(path);
299 return Ok(());
300 }
301 }
302 }
303 }
304 Err(io::Error::new(
305 io::ErrorKind::NotFound,
306 "file not found in RUBYLIB load path",
307 ))
308 }
309}