runmat_runtime/builtins/io/repl_fs/
addpath.rs1#[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 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}