Skip to main content

runmat_runtime/builtins/io/repl_fs/
addpath.rs

1//! MATLAB-compatible `addpath` builtin for manipulating the RunMat search path.
2
3#[cfg(test)]
4use runmat_builtins::CellArray;
5use runmat_builtins::{CharArray, StringArray, Tensor, Value};
6use runmat_macros::runtime_builtin;
7
8use crate::builtins::common::fs::{expand_user_path, path_to_string};
9use crate::builtins::common::path_state::{
10    current_path_segments, current_path_string, set_path_string, PATH_LIST_SEPARATOR,
11};
12use crate::builtins::common::spec::{
13    BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
14    ReductionNaN, ResidencyPolicy, ShapeRequirements,
15};
16use crate::{build_runtime_error, gather_if_needed_async, BuiltinResult, RuntimeError};
17
18use runmat_filesystem as vfs;
19use std::collections::HashSet;
20use std::path::{Component, Path, PathBuf};
21
22const ERROR_ARG_TYPE: &str =
23    "addpath: folder names must be character vectors, string scalars, string arrays, or cell arrays of character vectors";
24const ERROR_TOO_FEW_ARGS: &str = "addpath: at least one folder must be specified";
25const ERROR_POSITION_REPEATED: &str =
26    "addpath: position option must be '-begin' or '-end' and may only appear once";
27
28#[runmat_macros::register_gpu_spec(builtin_path = "crate::builtins::io::repl_fs::addpath")]
29pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
30    name: "addpath",
31    op_kind: GpuOpKind::Custom("io"),
32    supported_precisions: &[],
33    broadcast: BroadcastSemantics::None,
34    provider_hooks: &[],
35    constant_strategy: ConstantStrategy::InlineLiteral,
36    residency: ResidencyPolicy::GatherImmediately,
37    nan_mode: ReductionNaN::Include,
38    two_pass_threshold: None,
39    workgroup_size: None,
40    accepts_nan_mode: false,
41    notes: "Search-path manipulation is a host-only operation; GPU inputs are gathered before processing.",
42};
43
44#[runmat_macros::register_fusion_spec(builtin_path = "crate::builtins::io::repl_fs::addpath")]
45pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
46    name: "addpath",
47    shape: ShapeRequirements::Any,
48    constant_strategy: ConstantStrategy::InlineLiteral,
49    elementwise: None,
50    reduction: None,
51    emits_nan: false,
52    notes: "IO builtins are not eligible for fusion; metadata registered for completeness.",
53};
54
55const BUILTIN_NAME: &str = "addpath";
56
57fn addpath_error(message: impl Into<String>) -> RuntimeError {
58    build_runtime_error(message)
59        .with_builtin(BUILTIN_NAME)
60        .build()
61}
62
63fn map_control_flow(err: RuntimeError) -> RuntimeError {
64    let identifier = err.identifier().map(str::to_string);
65    let mut builder = build_runtime_error(format!("{BUILTIN_NAME}: {}", err.message()))
66        .with_builtin(BUILTIN_NAME)
67        .with_source(err);
68    if let Some(identifier) = identifier {
69        builder = builder.with_identifier(identifier);
70    }
71    builder.build()
72}
73
74#[derive(Clone, Copy, PartialEq, Eq)]
75enum InsertPosition {
76    Begin,
77    End,
78}
79
80struct AddPathSpec {
81    directories: Vec<String>,
82    position: InsertPosition,
83    _frozen: bool,
84}
85
86#[runtime_builtin(
87    name = "addpath",
88    category = "io/repl_fs",
89    summary = "Add folders to the MATLAB search path used by RunMat.",
90    keywords = "addpath,search path,matlab path,-begin,-end,-frozen",
91    accel = "cpu",
92    suppress_auto_output = true,
93    type_resolver(crate::builtins::io::type_resolvers::addpath_type),
94    builtin_path = "crate::builtins::io::repl_fs::addpath"
95)]
96async fn addpath_builtin(args: Vec<Value>) -> crate::BuiltinResult<Value> {
97    if args.is_empty() {
98        return Err(addpath_error(ERROR_TOO_FEW_ARGS));
99    }
100
101    let gathered = gather_arguments(&args).await?;
102    let previous = current_path_string();
103    let spec = parse_arguments(&gathered).await?;
104    apply_addpath(spec).await?;
105    Ok(char_array_value(&previous))
106}
107
108async fn gather_arguments(args: &[Value]) -> BuiltinResult<Vec<Value>> {
109    let mut out = Vec::with_capacity(args.len());
110    for value in args {
111        out.push(
112            gather_if_needed_async(value)
113                .await
114                .map_err(map_control_flow)?,
115        );
116    }
117    Ok(out)
118}
119
120async fn parse_arguments(args: &[Value]) -> BuiltinResult<AddPathSpec> {
121    let mut position = InsertPosition::Begin;
122    let mut position_set = false;
123    let mut frozen = false;
124    let mut directories = Vec::new();
125
126    for value in args {
127        collect_strings(value, &mut directories).await?;
128    }
129
130    if directories.is_empty() {
131        return Err(addpath_error(ERROR_TOO_FEW_ARGS));
132    }
133
134    let mut resolved = Vec::new();
135    for token in directories {
136        let trimmed = token.trim();
137        if trimmed.is_empty() {
138            continue;
139        }
140        match parse_option(trimmed) {
141            Some(AddPathOption::Begin) => {
142                if position_set {
143                    return Err(addpath_error(ERROR_POSITION_REPEATED));
144                }
145                position = InsertPosition::Begin;
146                position_set = true;
147            }
148            Some(AddPathOption::End) => {
149                if position_set {
150                    return Err(addpath_error(ERROR_POSITION_REPEATED));
151                }
152                position = InsertPosition::End;
153                position_set = true;
154            }
155            Some(AddPathOption::Frozen) => {
156                frozen = true;
157            }
158            None => {
159                for segment in split_path_list(trimmed) {
160                    resolved.push(segment);
161                }
162            }
163        }
164    }
165
166    if resolved.is_empty() {
167        return Err(addpath_error(ERROR_TOO_FEW_ARGS));
168    }
169
170    Ok(AddPathSpec {
171        directories: resolved,
172        position,
173        _frozen: frozen,
174    })
175}
176
177enum AddPathOption {
178    Begin,
179    End,
180    Frozen,
181}
182
183fn parse_option(text: &str) -> Option<AddPathOption> {
184    let lowered = text.trim().to_ascii_lowercase();
185    match lowered.as_str() {
186        "-begin" => Some(AddPathOption::Begin),
187        "-end" => Some(AddPathOption::End),
188        "-frozen" => Some(AddPathOption::Frozen),
189        _ => None,
190    }
191}
192
193async fn apply_addpath(spec: AddPathSpec) -> BuiltinResult<()> {
194    let mut existing = current_path_segments();
195    let mut seen = HashSet::new();
196    let mut additions = Vec::new();
197
198    for raw in spec.directories {
199        let normalized = normalize_directory(&raw).await?;
200        let key = path_identity(&normalized);
201        if seen.insert(key.clone()) {
202            existing.retain(|entry| path_identity(entry) != key);
203            additions.push(normalized);
204        }
205    }
206
207    if additions.is_empty() {
208        return Ok(());
209    }
210
211    let final_segments = match spec.position {
212        InsertPosition::Begin => {
213            let mut combined = additions;
214            combined.extend(existing);
215            combined
216        }
217        InsertPosition::End => {
218            let mut combined = existing;
219            combined.extend(additions);
220            combined
221        }
222    };
223
224    let final_segments = final_segments
225        .into_iter()
226        .filter(|segment| !segment.is_empty())
227        .collect::<Vec<_>>();
228
229    // Preserve empty path (clears search path) if all entries were removed.
230    let new_path = if final_segments.is_empty() {
231        String::new()
232    } else {
233        join_segments(&final_segments)
234    };
235
236    set_path_string(&new_path);
237    Ok(())
238}
239
240#[async_recursion::async_recursion(?Send)]
241async fn collect_strings(value: &Value, output: &mut Vec<String>) -> BuiltinResult<()> {
242    match value {
243        Value::String(text) => {
244            output.push(text.clone());
245            Ok(())
246        }
247        Value::StringArray(StringArray { data, .. }) => {
248            for entry in data {
249                output.push(entry.clone());
250            }
251            Ok(())
252        }
253        Value::CharArray(chars) => {
254            if chars.rows == 1 {
255                output.push(chars.data.iter().collect());
256                return Ok(());
257            }
258            for row in 0..chars.rows {
259                let mut line = String::with_capacity(chars.cols);
260                for col in 0..chars.cols {
261                    line.push(chars.data[row * chars.cols + col]);
262                }
263                output.push(line.trim_end().to_string());
264            }
265            Ok(())
266        }
267        Value::Tensor(tensor) => {
268            output.push(tensor_to_string(tensor)?);
269            Ok(())
270        }
271        Value::Cell(cell) => {
272            for ptr in &cell.data {
273                let inner = (**ptr).clone();
274                let gathered = gather_if_needed_async(&inner)
275                    .await
276                    .map_err(map_control_flow)?;
277                collect_strings(&gathered, output).await?;
278            }
279            Ok(())
280        }
281        Value::GpuTensor(_) => Err(addpath_error(ERROR_ARG_TYPE)),
282        _ => Err(addpath_error(ERROR_ARG_TYPE)),
283    }
284}
285
286fn split_path_list(text: &str) -> Vec<String> {
287    text.split(PATH_LIST_SEPARATOR)
288        .map(|segment| segment.trim())
289        .filter(|segment| !segment.is_empty())
290        .map(|segment| segment.to_string())
291        .collect()
292}
293
294async fn normalize_directory(raw: &str) -> BuiltinResult<String> {
295    let trimmed = raw.trim();
296    if trimmed.is_empty() {
297        return Err(addpath_error(ERROR_ARG_TYPE));
298    }
299
300    if trimmed.eq_ignore_ascii_case("pathdef") || trimmed.eq_ignore_ascii_case("pathdef.m") {
301        return Err(addpath_error(
302            "addpath: loading pathdef.m is not implemented yet",
303        ));
304    }
305
306    let expanded = expand_user_path(trimmed, "addpath").map_err(addpath_error)?;
307    let path = Path::new(&expanded);
308    let joined = if path.is_absolute() {
309        path.to_path_buf()
310    } else {
311        vfs::current_dir()
312            .map_err(|_| addpath_error("addpath: unable to resolve current directory"))?
313            .join(path)
314    };
315    let normalized = normalize_pathbuf(&joined);
316
317    let metadata = vfs::metadata_async(&normalized)
318        .await
319        .map_err(|_| addpath_error(format!("addpath: folder '{trimmed}' not found")))?;
320    if !metadata.is_dir() {
321        return Err(addpath_error(format!(
322            "addpath: '{trimmed}' is not a folder"
323        )));
324    }
325
326    Ok(path_to_string(&normalized))
327}
328
329fn normalize_pathbuf(path: &Path) -> PathBuf {
330    let mut normalized = PathBuf::new();
331    for component in path.components() {
332        match component {
333            Component::Prefix(prefix) => {
334                normalized.push(prefix.as_os_str());
335            }
336            Component::RootDir => {
337                normalized.push(component.as_os_str());
338            }
339            Component::CurDir => {}
340            Component::ParentDir => {
341                normalized.pop();
342            }
343            Component::Normal(part) => {
344                normalized.push(part);
345            }
346        }
347    }
348    if normalized.as_os_str().is_empty() {
349        path.to_path_buf()
350    } else {
351        normalized
352    }
353}
354
355fn tensor_to_string(tensor: &Tensor) -> BuiltinResult<String> {
356    if tensor.shape.len() > 2 {
357        return Err(addpath_error(ERROR_ARG_TYPE));
358    }
359    if tensor.rows() > 1 {
360        return Err(addpath_error(ERROR_ARG_TYPE));
361    }
362    let mut text = String::with_capacity(tensor.data.len());
363    for &code in &tensor.data {
364        if !code.is_finite() {
365            return Err(addpath_error(ERROR_ARG_TYPE));
366        }
367        let rounded = code.round();
368        if (code - rounded).abs() > 1e-6 {
369            return Err(addpath_error(ERROR_ARG_TYPE));
370        }
371        let int_code = rounded as i64;
372        if !(0..=0x10FFFF).contains(&int_code) {
373            return Err(addpath_error(ERROR_ARG_TYPE));
374        }
375        let ch = char::from_u32(int_code as u32).ok_or_else(|| addpath_error(ERROR_ARG_TYPE))?;
376        text.push(ch);
377    }
378    Ok(text)
379}
380
381fn path_identity(path: &str) -> String {
382    #[cfg(windows)]
383    {
384        path.replace('/', "\\").to_ascii_lowercase()
385    }
386    #[cfg(not(windows))]
387    {
388        path.to_string()
389    }
390}
391
392fn join_segments(segments: &[String]) -> String {
393    let mut joined = String::new();
394    for (idx, segment) in segments.iter().enumerate() {
395        if idx > 0 {
396            joined.push(PATH_LIST_SEPARATOR);
397        }
398        joined.push_str(segment);
399    }
400    joined
401}
402
403fn char_array_value(text: &str) -> Value {
404    Value::CharArray(CharArray::new_row(text))
405}
406
407#[cfg(test)]
408pub(crate) mod tests {
409    use super::super::REPL_FS_TEST_LOCK;
410    use super::*;
411    use crate::builtins::common::path_state::set_path_string;
412    use crate::builtins::common::path_state::{current_path_segments, PATH_LIST_SEPARATOR};
413    use std::convert::TryFrom;
414    use std::fs;
415    use tempfile::tempdir;
416
417    fn addpath_builtin(args: Vec<Value>) -> BuiltinResult<Value> {
418        futures::executor::block_on(super::addpath_builtin(args))
419    }
420
421    struct PathGuard {
422        previous: String,
423    }
424
425    impl PathGuard {
426        fn new() -> Self {
427            Self {
428                previous: current_path_string(),
429            }
430        }
431    }
432
433    impl Drop for PathGuard {
434        fn drop(&mut self) {
435            set_path_string(&self.previous);
436        }
437    }
438
439    fn canonical(dir: &Path) -> String {
440        let normalized = normalize_pathbuf(dir);
441        path_to_string(&normalized)
442    }
443
444    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
445    #[test]
446    fn addpath_prepends_by_default() {
447        let _lock = REPL_FS_TEST_LOCK
448            .lock()
449            .unwrap_or_else(|poison| poison.into_inner());
450        let _guard = PathGuard::new();
451
452        let base_dir = tempdir().expect("tempdir");
453        let extra_dir = tempdir().expect("extra dir");
454
455        let base_string = path_to_string(base_dir.path());
456        set_path_string(&base_string);
457
458        let input = Value::CharArray(CharArray::new_row(
459            extra_dir.path().to_string_lossy().as_ref(),
460        ));
461        let returned = addpath_builtin(vec![input]).expect("addpath");
462        let returned_str = String::try_from(&returned).expect("convert");
463        assert_eq!(returned_str, base_string);
464
465        let segments = current_path_segments();
466        let expected_front = canonical(extra_dir.path());
467        assert_eq!(segments.first().unwrap(), &expected_front);
468    }
469
470    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
471    #[test]
472    fn addpath_removes_duplicates() {
473        let _lock = REPL_FS_TEST_LOCK
474            .lock()
475            .unwrap_or_else(|poison| poison.into_inner());
476        let _guard = PathGuard::new();
477
478        let first = tempdir().expect("first");
479        let second = tempdir().expect("second");
480        let first_str = canonical(first.path());
481        let second_str = canonical(second.path());
482        let combined = format!(
483            "{first}{sep}{second}",
484            first = first_str,
485            second = second_str,
486            sep = PATH_LIST_SEPARATOR
487        );
488        set_path_string(&combined);
489
490        let arg = Value::String(first_str.clone());
491        addpath_builtin(vec![arg]).expect("addpath");
492
493        let segments = current_path_segments();
494        assert_eq!(segments[0], first_str);
495        assert_eq!(segments[1], second_str);
496        assert_eq!(segments.iter().filter(|p| *p == &first_str).count(), 1);
497    }
498
499    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
500    #[test]
501    fn addpath_respects_end_option() {
502        let _lock = REPL_FS_TEST_LOCK
503            .lock()
504            .unwrap_or_else(|poison| poison.into_inner());
505        let _guard = PathGuard::new();
506
507        let first = tempdir().expect("first");
508        let second = tempdir().expect("second");
509        set_path_string(&canonical(first.path()));
510
511        let args = vec![
512            Value::String(second.path().to_string_lossy().into_owned()),
513            Value::String("-end".to_string()),
514        ];
515        addpath_builtin(args).expect("addpath");
516
517        let segments = current_path_segments();
518        assert_eq!(segments.last().unwrap(), &canonical(second.path()));
519    }
520
521    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
522    #[test]
523    fn addpath_handles_string_array_and_cell_input() {
524        let _lock = REPL_FS_TEST_LOCK
525            .lock()
526            .unwrap_or_else(|poison| poison.into_inner());
527        let _guard = PathGuard::new();
528
529        let dir1 = tempdir().expect("dir1");
530        let dir2 = tempdir().expect("dir2");
531
532        set_path_string("");
533
534        let strings =
535            StringArray::new(vec![dir1.path().to_string_lossy().into_owned()], vec![1, 1])
536                .expect("string array");
537        let cell = CellArray::new(
538            vec![Value::String(dir2.path().to_string_lossy().into_owned())],
539            1,
540            1,
541        )
542        .expect("cell");
543
544        addpath_builtin(vec![Value::StringArray(strings), Value::Cell(cell)]).expect("addpath");
545
546        let segments = current_path_segments();
547        assert_eq!(segments.len(), 2);
548        assert_eq!(segments[0], canonical(dir1.path()));
549        assert_eq!(segments[1], canonical(dir2.path()));
550    }
551
552    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
553    #[test]
554    fn addpath_supports_multi_row_char_arrays() {
555        let _lock = REPL_FS_TEST_LOCK
556            .lock()
557            .unwrap_or_else(|poison| poison.into_inner());
558        let _guard = PathGuard::new();
559
560        let dir1 = tempdir().expect("dir1");
561        let dir2 = tempdir().expect("dir2");
562
563        let one = dir1.path().to_string_lossy();
564        let two = dir2.path().to_string_lossy();
565        let len_one = one.chars().count();
566        let len_two = two.chars().count();
567        let max_len = len_one.max(len_two);
568        let mut data = Vec::with_capacity(2 * max_len);
569        let mut push_row = |text: &str, length: usize| {
570            data.extend(text.chars());
571            data.extend(std::iter::repeat_n(' ', max_len - length));
572        };
573        push_row(&one, len_one);
574        push_row(&two, len_two);
575        let char_array = CharArray::new(data, 2, max_len).expect("char array");
576        addpath_builtin(vec![Value::CharArray(char_array)]).expect("addpath");
577
578        let segments = current_path_segments();
579        assert_eq!(segments[0], canonical(dir1.path()));
580        assert_eq!(segments[1], canonical(dir2.path()));
581    }
582
583    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
584    #[test]
585    fn addpath_errors_on_missing_folder() {
586        let _lock = REPL_FS_TEST_LOCK
587            .lock()
588            .unwrap_or_else(|poison| poison.into_inner());
589        let _guard = PathGuard::new();
590
591        let missing = Value::String("this/folder/does/not/exist".into());
592        let err = addpath_builtin(vec![missing]).expect_err("expected error");
593        assert!(
594            err.message().contains("folder") && err.message().contains("not found"),
595            "unexpected error message: {}",
596            err.message()
597        );
598    }
599
600    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
601    #[test]
602    fn addpath_genpath_string_is_expanded() {
603        let _lock = REPL_FS_TEST_LOCK
604            .lock()
605            .unwrap_or_else(|poison| poison.into_inner());
606        let _guard = PathGuard::new();
607
608        let base = tempdir().expect("base");
609        let sub = base.path().join("sub");
610        fs::create_dir(&sub).expect("create sub");
611
612        set_path_string("");
613        let combined = format!(
614            "{}{sep}{}",
615            base.path().to_string_lossy(),
616            sub.to_string_lossy(),
617            sep = PATH_LIST_SEPARATOR
618        );
619        addpath_builtin(vec![Value::String(combined)]).expect("addpath");
620
621        let segments = current_path_segments();
622        assert_eq!(segments.len(), 2);
623        assert_eq!(segments[0], canonical(base.path()));
624        assert_eq!(segments[1], canonical(&sub));
625    }
626
627    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
628    #[test]
629    fn addpath_returns_previous_path() {
630        let _lock = REPL_FS_TEST_LOCK
631            .lock()
632            .unwrap_or_else(|poison| poison.into_inner());
633        let guard = PathGuard::new();
634
635        let dir = tempdir().expect("dir");
636        let returned = addpath_builtin(vec![Value::String(
637            dir.path().to_string_lossy().into_owned(),
638        )])
639        .expect("addpath");
640        let returned_str = String::try_from(&returned).expect("string");
641        assert_eq!(returned_str, guard.previous);
642    }
643
644    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
645    #[test]
646    fn addpath_rejects_conflicting_position_flags() {
647        let _lock = REPL_FS_TEST_LOCK
648            .lock()
649            .unwrap_or_else(|poison| poison.into_inner());
650        let _guard = PathGuard::new();
651
652        let dir = tempdir().expect("dir");
653        let args = vec![
654            Value::String(dir.path().to_string_lossy().into_owned()),
655            Value::String("-begin".into()),
656            Value::String("-end".into()),
657        ];
658        let err = addpath_builtin(args).expect_err("expected error");
659        assert!(err.message().contains("position option"));
660    }
661
662    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
663    #[test]
664    fn addpath_handles_dash_begin() {
665        let _lock = REPL_FS_TEST_LOCK
666            .lock()
667            .unwrap_or_else(|poison| poison.into_inner());
668        let _guard = PathGuard::new();
669
670        let dir1 = tempdir().expect("dir1");
671        let dir2 = tempdir().expect("dir2");
672        set_path_string(&canonical(dir2.path()));
673
674        let args = vec![
675            Value::String(dir1.path().to_string_lossy().into_owned()),
676            Value::String("-begin".into()),
677        ];
678        addpath_builtin(args).expect("addpath");
679
680        let segments = current_path_segments();
681        assert_eq!(segments[0], canonical(dir1.path()));
682        assert_eq!(segments[1], canonical(dir2.path()));
683    }
684
685    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
686    #[test]
687    fn addpath_accepts_string_containers() {
688        let _lock = REPL_FS_TEST_LOCK
689            .lock()
690            .unwrap_or_else(|poison| poison.into_inner());
691        let _guard = PathGuard::new();
692
693        set_path_string("");
694
695        let cwd = vfs::current_dir().expect("cwd");
696        let string_array = StringArray::new(vec![cwd.to_string_lossy().into_owned()], vec![1, 1])
697            .expect("string array");
698        addpath_builtin(vec![Value::StringArray(string_array)]).expect("addpath");
699        let current = current_path_string();
700        assert_eq!(current, canonical(&cwd));
701    }
702}