runmat_runtime/builtins/io/repl_fs/
path.rs

1//! MATLAB-compatible `path` builtin for inspecting and updating the RunMat
2//! search path.
3
4use 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(&current_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}