runmat_runtime/builtins/io/repl_fs/
path.rs1use runmat_builtins::{CharArray, StringArray, Tensor, Value};
5use runmat_macros::runtime_builtin;
6
7use crate::builtins::common::path_state::{
8 current_path_string, set_path_string, PATH_LIST_SEPARATOR,
9};
10use crate::builtins::common::spec::{
11 BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
12 ReductionNaN, ResidencyPolicy, ShapeRequirements,
13};
14#[cfg(feature = "doc_export")]
15use crate::register_builtin_doc_text;
16use crate::{gather_if_needed, register_builtin_fusion_spec, register_builtin_gpu_spec};
17
18const ERROR_ARG_TYPE: &str = "path: arguments must be character vectors or string scalars";
19
20#[cfg(feature = "doc_export")]
21pub const DOC_MD: &str = r#"---
22title: "path"
23category: "io/repl_fs"
24keywords: ["path", "search path", "matlab path", "addpath", "rmpath"]
25summary: "Query or replace the MATLAB search path used by RunMat."
26references:
27 - https://www.mathworks.com/help/matlab/ref/path.html
28gpu_support:
29 elementwise: false
30 reduction: false
31 precisions: []
32 broadcasting: "none"
33 notes: "Runs entirely on the CPU. gpuArray text inputs are gathered automatically before processing."
34fusion:
35 elementwise: false
36 reduction: false
37 max_inputs: 2
38 constants: "inline"
39requires_feature: null
40tested:
41 unit: "builtins::io::repl_fs::path::tests"
42 integration: "builtins::io::repl_fs::path::tests::path_updates_search_directories"
43---
44
45# What does the `path` function do in MATLAB / RunMat?
46`path` reads or rewrites the MATLAB search path string that RunMat uses when resolving scripts, functions, and data files. The value mirrors MATLAB's notion of the path: a character row vector whose entries are separated by the platform-specific `pathsep` character.
47
48## How does the `path` function behave in MATLAB / RunMat?
49- `path()` (no inputs) returns the current search path as a character row vector. Each directory is separated by `pathsep` (`;` on Windows, `:` on Linux/macOS). The current working folder (`pwd`) is implicit and therefore is not included in the string.
50- `old = path(newPath)` replaces the stored path with `newPath` and returns the previous value so it can be restored later. `newPath` may be a character row vector, a string scalar, or a 1-by-N double array of character codes. Whitespace at the ends of entries is preserved, matching MATLAB.
51- `old = path(path1, path2)` sets the path to `path1` followed by `path2`. When both inputs are non-empty they are joined with `pathsep`; empty inputs are ignored so `path("", path2)` simply applies `path2`.
52- All inputs must be character vectors or string scalars. Single-element string arrays are accepted. Multirow char arrays, multi-element string arrays, numeric arrays that cannot be interpreted as character codes, and other value types raise `path: arguments must be character vectors or string scalars`.
53- Calling `path("")` clears the stored path while leaving `pwd` as the highest-priority location, just like MATLAB. The new value is stored in-process and mirrored to the `RUNMAT_PATH` environment variable, so `exist`, `which`, `dir`, and other filesystem-aware builtins observe the change immediately.
54
55## `path` Function GPU Execution Behaviour
56`path` operates entirely on host-side state. If an argument lives on the GPU, RunMat gathers it back to the CPU before validation. No acceleration provider hooks are required and no GPU kernels are launched.
57
58## GPU residency in RunMat (Do I need `gpuArray`?)
59No. The MATLAB path is a host-only configuration. RunMat automatically gathers any `gpuArray` text inputs, applies the request on the CPU, and returns the result as a character array. Explicitly creating `gpuArray` strings provides no benefit.
60
61## Examples of using the `path` function in MATLAB / RunMat
62
63### Display the current MATLAB search path
64```matlab
65p = path();
66disp(p);
67```
68Expected output:
69```matlab
70/Users/alex/runmat/toolbox:/Users/alex/runmat/user % Actual directories vary by installation
71```
72
73### Temporarily replace the MATLAB path
74```matlab
75old = path("C:\tools\runmat-addons");
76% ... run code that relies on the temporary path ...
77path(old); % Restore the previous path
78```
79Expected output:
80```matlab
81old =
82 '/Users/alex/runmat/toolbox:/Users/alex/runmat/user'
83```
84
85### Append folders to the end of the search path
86```matlab
87extra = "C:\projects\analysis";
88old = path(path(), extra);
89```
90Expected output:
91```matlab
92path()
93ans =
94 'C:\tools\runmat-addons;C:\projects\analysis'
95```
96
97### Prepend folders ahead of the existing path
98```matlab
99extra = "/opt/runmat/toolbox";
100old = path(extra, path());
101```
102Expected output:
103```matlab
104path()
105ans =
106 '/opt/runmat/toolbox:/Users/alex/runmat/toolbox:/Users/alex/runmat/user'
107```
108
109### Combine generated folder lists
110```matlab
111tooling = genpath("submodules/tooling");
112old = path(tooling, path());
113```
114Expected output:
115```matlab
116% The directories returned by genpath now appear ahead of the previous path entries.
117```
118
119## FAQ
120- **Does `path` include the current folder?** MATLAB automatically searches the current folder (`pwd`) before consulting the stored path. RunMat follows this rule; the character vector returned by `path` reflects the explicit path entries, while `pwd` remains an implicit priority.
121- **Can I clear the path completely?** Yes. Call `path("")` to remove all explicit entries. The current folder is still searched first.
122- **How do I append to the path without losing the existing value?** Use `path(path(), newEntry)` to append or `path(newEntry, path())` to prepend. Both return the previous value so you can restore it later.
123- **Where is the path stored?** RunMat keeps the value in memory and updates the `RUNMAT_PATH` environment variable. External tooling that reads `RUNMAT_PATH` will therefore observe the latest configuration.
124- **Do other builtins see the new path immediately?** Yes. `exist`, `which`, `run`, and other filesystem-aware builtins query the shared path state on each call.
125
126## See Also
127[addpath](https://www.mathworks.com/help/matlab/ref/addpath.html), [rmpath](https://www.mathworks.com/help/matlab/ref/rmpath.html), [genpath](https://www.mathworks.com/help/matlab/ref/genpath.html), [which](../../introspection/which), [exist](./exist)
128
129## Source & Feedback
130- Source: [`crates/runmat-runtime/src/builtins/io/repl_fs/path.rs`](https://github.com/runmat-org/runmat/blob/main/crates/runmat-runtime/src/builtins/io/repl_fs/path.rs)
131- Found an issue? [Open a GitHub ticket](https://github.com/runmat-org/runmat/issues/new/choose) with steps to reproduce.
132"#;
133
134pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
135 name: "path",
136 op_kind: GpuOpKind::Custom("io"),
137 supported_precisions: &[],
138 broadcast: BroadcastSemantics::None,
139 provider_hooks: &[],
140 constant_strategy: ConstantStrategy::InlineLiteral,
141 residency: ResidencyPolicy::GatherImmediately,
142 nan_mode: ReductionNaN::Include,
143 two_pass_threshold: None,
144 workgroup_size: None,
145 accepts_nan_mode: false,
146 notes: "Search-path management is a host-only operation; GPU inputs are gathered before processing.",
147};
148
149register_builtin_gpu_spec!(GPU_SPEC);
150
151pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
152 name: "path",
153 shape: ShapeRequirements::Any,
154 constant_strategy: ConstantStrategy::InlineLiteral,
155 elementwise: None,
156 reduction: None,
157 emits_nan: false,
158 notes: "I/O builtins are not eligible for fusion; metadata registered for completeness.",
159};
160
161register_builtin_fusion_spec!(FUSION_SPEC);
162
163#[cfg(feature = "doc_export")]
164register_builtin_doc_text!("path", DOC_MD);
165
166#[runtime_builtin(
167 name = "path",
168 category = "io/repl_fs",
169 summary = "Query or replace the MATLAB search path used by RunMat.",
170 keywords = "path,search path,matlab path,addpath,rmpath",
171 accel = "cpu"
172)]
173fn path_builtin(args: Vec<Value>) -> Result<Value, String> {
174 let gathered = gather_arguments(&args)?;
175 match gathered.len() {
176 0 => Ok(path_value()),
177 1 => set_single_argument(&gathered[0]),
178 2 => set_two_arguments(&gathered[0], &gathered[1]),
179 _ => Err("path: too many input arguments".to_string()),
180 }
181}
182
183fn path_value() -> Value {
184 char_array_value(¤t_path_string())
185}
186
187fn set_single_argument(arg: &Value) -> Result<Value, String> {
188 let previous = current_path_string();
189 let new_path = extract_text(arg)?;
190 set_path_string(&new_path);
191 Ok(char_array_value(&previous))
192}
193
194fn set_two_arguments(first: &Value, second: &Value) -> Result<Value, String> {
195 let previous = current_path_string();
196 let path1 = extract_text(first)?;
197 let path2 = extract_text(second)?;
198 let combined = combine_paths(&path1, &path2);
199 set_path_string(&combined);
200 Ok(char_array_value(&previous))
201}
202
203fn combine_paths(left: &str, right: &str) -> String {
204 match (left.is_empty(), right.is_empty()) {
205 (true, true) => String::new(),
206 (false, true) => left.to_string(),
207 (true, false) => right.to_string(),
208 (false, false) => {
209 let mut combined = String::with_capacity(left.len() + right.len() + 1);
210 combined.push_str(left);
211 combined.push(PATH_LIST_SEPARATOR);
212 combined.push_str(right);
213 combined
214 }
215 }
216}
217
218fn extract_text(value: &Value) -> Result<String, String> {
219 match value {
220 Value::String(text) => Ok(text.clone()),
221 Value::StringArray(StringArray { data, .. }) => {
222 if data.len() != 1 {
223 Err(ERROR_ARG_TYPE.to_string())
224 } else {
225 Ok(data[0].clone())
226 }
227 }
228 Value::CharArray(chars) => {
229 if chars.rows != 1 {
230 return Err(ERROR_ARG_TYPE.to_string());
231 }
232 Ok(chars.data.iter().collect())
233 }
234 Value::Tensor(tensor) => tensor_to_string(tensor),
235 Value::GpuTensor(_) => Err(ERROR_ARG_TYPE.to_string()),
236 _ => Err(ERROR_ARG_TYPE.to_string()),
237 }
238}
239
240fn tensor_to_string(tensor: &Tensor) -> Result<String, String> {
241 if tensor.shape.len() > 2 {
242 return Err(ERROR_ARG_TYPE.to_string());
243 }
244
245 let rows = tensor.rows();
246 if rows > 1 {
247 return Err(ERROR_ARG_TYPE.to_string());
248 }
249
250 let mut text = String::with_capacity(tensor.data.len());
251 for &code in &tensor.data {
252 if !code.is_finite() {
253 return Err(ERROR_ARG_TYPE.to_string());
254 }
255 let rounded = code.round();
256 if (code - rounded).abs() > 1e-6 {
257 return Err(ERROR_ARG_TYPE.to_string());
258 }
259 let int_code = rounded as i64;
260 if !(0..=0x10FFFF).contains(&int_code) {
261 return Err(ERROR_ARG_TYPE.to_string());
262 }
263 let ch = char::from_u32(int_code as u32).ok_or_else(|| ERROR_ARG_TYPE.to_string())?;
264 text.push(ch);
265 }
266
267 Ok(text)
268}
269
270fn gather_arguments(args: &[Value]) -> Result<Vec<Value>, String> {
271 let mut out = Vec::with_capacity(args.len());
272 for value in args {
273 out.push(gather_if_needed(value).map_err(|err| format!("path: {err}"))?);
274 }
275 Ok(out)
276}
277
278fn char_array_value(text: &str) -> Value {
279 Value::CharArray(CharArray::new_row(text))
280}
281
282#[cfg(test)]
283mod tests {
284 use super::super::REPL_FS_TEST_LOCK;
285 use super::*;
286 use crate::builtins::common::path_search::search_directories;
287 use crate::builtins::common::path_state::set_path_string;
288 use std::convert::TryFrom;
289 use tempfile::tempdir;
290
291 struct PathGuard {
292 previous: String,
293 }
294
295 impl PathGuard {
296 fn new() -> Self {
297 Self {
298 previous: current_path_string(),
299 }
300 }
301 }
302
303 impl Drop for PathGuard {
304 fn drop(&mut self) {
305 set_path_string(&self.previous);
306 }
307 }
308
309 #[test]
310 fn path_returns_char_array() {
311 let _lock = REPL_FS_TEST_LOCK
312 .lock()
313 .unwrap_or_else(|poison| poison.into_inner());
314 let _guard = PathGuard::new();
315
316 let value = path_builtin(Vec::new()).expect("path");
317 match value {
318 Value::CharArray(CharArray { rows, .. }) => assert_eq!(rows, 1),
319 other => panic!("expected CharArray, got {other:?}"),
320 }
321 }
322
323 #[test]
324 fn path_sets_new_value_and_returns_previous() {
325 let _lock = REPL_FS_TEST_LOCK
326 .lock()
327 .unwrap_or_else(|poison| poison.into_inner());
328 let guard = PathGuard::new();
329 let previous = guard.previous.clone();
330
331 let temp = tempdir().expect("tempdir");
332 let dir_str = temp.path().to_string_lossy().into_owned();
333 let new_value = Value::CharArray(CharArray::new_row(&dir_str));
334 let returned = path_builtin(vec![new_value]).expect("path set");
335 let returned_str = String::try_from(&returned).expect("convert");
336 assert_eq!(returned_str, previous);
337
338 let current =
339 String::try_from(&path_builtin(Vec::new()).expect("path")).expect("convert current");
340 assert_eq!(current, dir_str);
341 }
342
343 #[test]
344 fn path_accepts_string_scalar() {
345 let _lock = REPL_FS_TEST_LOCK
346 .lock()
347 .unwrap_or_else(|poison| poison.into_inner());
348 let guard = PathGuard::new();
349 let previous = guard.previous.clone();
350
351 let new_value = Value::String("runmat/path/string".to_string());
352 let returned = path_builtin(vec![new_value]).expect("path set");
353 let returned_str = String::try_from(&returned).expect("convert");
354 assert_eq!(returned_str, previous);
355
356 let current =
357 String::try_from(&path_builtin(Vec::new()).expect("path")).expect("convert current");
358 assert_eq!(current, "runmat/path/string");
359 }
360
361 #[test]
362 fn path_accepts_tensor_codes() {
363 let _lock = REPL_FS_TEST_LOCK
364 .lock()
365 .unwrap_or_else(|poison| poison.into_inner());
366 let guard = PathGuard::new();
367 let previous = guard.previous.clone();
368
369 let text = "tensor-path";
370 let codes: Vec<f64> = text.chars().map(|ch| ch as u32 as f64).collect();
371 let tensor = Tensor::new(codes, vec![1, text.len()]).expect("tensor");
372 let returned = path_builtin(vec![Value::Tensor(tensor)]).expect("path set");
373 let returned_str = String::try_from(&returned).expect("convert");
374 assert_eq!(returned_str, previous);
375
376 let current =
377 String::try_from(&path_builtin(Vec::new()).expect("path")).expect("convert current");
378 assert_eq!(current, text);
379 }
380
381 #[test]
382 fn path_combines_two_arguments() {
383 let _lock = REPL_FS_TEST_LOCK
384 .lock()
385 .unwrap_or_else(|poison| poison.into_inner());
386 let _guard = PathGuard::new();
387
388 let dir1 = tempdir().expect("dir1");
389 let dir2 = tempdir().expect("dir2");
390 let dir1_str = dir1.path().to_string_lossy().to_string();
391 let dir2_str = dir2.path().to_string_lossy().to_string();
392 let path1 = Value::CharArray(CharArray::new_row(&dir1_str));
393 let path2 = Value::CharArray(CharArray::new_row(&dir2_str));
394 let _returned = path_builtin(vec![path1, path2]).expect("path set");
395
396 let current =
397 String::try_from(&path_builtin(Vec::new()).expect("path")).expect("convert current");
398 let expected = format!(
399 "{}{sep}{}",
400 dir1.path().to_string_lossy(),
401 dir2.path().to_string_lossy(),
402 sep = PATH_LIST_SEPARATOR
403 );
404 assert_eq!(current, expected);
405 }
406
407 #[test]
408 fn path_rejects_multi_row_char_array() {
409 let _lock = REPL_FS_TEST_LOCK
410 .lock()
411 .unwrap_or_else(|poison| poison.into_inner());
412 let _guard = PathGuard::new();
413
414 let chars = CharArray::new(vec!['a', 'b', 'c', 'd'], 2, 2).expect("char array");
415 let err = path_builtin(vec![Value::CharArray(chars)]).expect_err("expected error");
416 assert_eq!(err, ERROR_ARG_TYPE);
417 }
418
419 #[test]
420 fn path_rejects_multi_element_string_array() {
421 let _lock = REPL_FS_TEST_LOCK
422 .lock()
423 .unwrap_or_else(|poison| poison.into_inner());
424 let _guard = PathGuard::new();
425
426 let array = StringArray::new(vec!["a".into(), "b".into()], vec![1, 2]).expect("array");
427 let err = path_builtin(vec![Value::StringArray(array)]).expect_err("expected error");
428 assert_eq!(err, ERROR_ARG_TYPE);
429 }
430
431 #[test]
432 fn path_rejects_invalid_argument_types() {
433 let _lock = REPL_FS_TEST_LOCK
434 .lock()
435 .unwrap_or_else(|poison| poison.into_inner());
436 let _guard = PathGuard::new();
437
438 let err = path_builtin(vec![Value::Num(1.0)]).expect_err("expected error");
439 assert!(err.contains("path: arguments"));
440 }
441
442 #[test]
443 fn path_updates_search_directories() {
444 let _lock = REPL_FS_TEST_LOCK
445 .lock()
446 .unwrap_or_else(|poison| poison.into_inner());
447 let _guard = PathGuard::new();
448
449 let temp = tempdir().expect("tempdir");
450 let dir = temp.path().to_string_lossy().into_owned();
451 let _ = path_builtin(vec![Value::CharArray(CharArray::new_row(&dir))]).expect("path");
452
453 let search = search_directories("path test").expect("search directories");
454 let search_strings: Vec<String> = search
455 .iter()
456 .map(|p| p.to_string_lossy().into_owned())
457 .collect();
458 assert!(
459 search_strings.iter().any(|entry| entry == &dir),
460 "search path should include newly added directory"
461 );
462 }
463
464 #[test]
465 #[cfg(feature = "doc_export")]
466 fn doc_examples_present() {
467 let blocks = crate::builtins::common::test_support::doc_examples(DOC_MD);
468 assert!(!blocks.is_empty());
469 }
470}