Skip to main content

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};
14use crate::{build_runtime_error, gather_if_needed_async, BuiltinResult, RuntimeError};
15
16const ERROR_ARG_TYPE: &str = "path: arguments must be character vectors or string scalars";
17
18#[runmat_macros::register_gpu_spec(builtin_path = "crate::builtins::io::repl_fs::path")]
19pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
20    name: "path",
21    op_kind: GpuOpKind::Custom("io"),
22    supported_precisions: &[],
23    broadcast: BroadcastSemantics::None,
24    provider_hooks: &[],
25    constant_strategy: ConstantStrategy::InlineLiteral,
26    residency: ResidencyPolicy::GatherImmediately,
27    nan_mode: ReductionNaN::Include,
28    two_pass_threshold: None,
29    workgroup_size: None,
30    accepts_nan_mode: false,
31    notes: "Search-path management is a host-only operation; GPU inputs are gathered before processing.",
32};
33
34#[runmat_macros::register_fusion_spec(builtin_path = "crate::builtins::io::repl_fs::path")]
35pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
36    name: "path",
37    shape: ShapeRequirements::Any,
38    constant_strategy: ConstantStrategy::InlineLiteral,
39    elementwise: None,
40    reduction: None,
41    emits_nan: false,
42    notes: "I/O builtins are not eligible for fusion; metadata registered for introspection completeness.",
43};
44
45const BUILTIN_NAME: &str = "path";
46
47fn path_error(message: impl Into<String>) -> RuntimeError {
48    build_runtime_error(message)
49        .with_builtin(BUILTIN_NAME)
50        .build()
51}
52
53fn map_control_flow(err: RuntimeError) -> RuntimeError {
54    let identifier = err.identifier().map(str::to_string);
55    let mut builder = build_runtime_error(format!("{BUILTIN_NAME}: {}", err.message()))
56        .with_builtin(BUILTIN_NAME)
57        .with_source(err);
58    if let Some(identifier) = identifier {
59        builder = builder.with_identifier(identifier);
60    }
61    builder.build()
62}
63
64#[runtime_builtin(
65    name = "path",
66    category = "io/repl_fs",
67    summary = "Query or replace the MATLAB search path used by RunMat.",
68    keywords = "path,search path,matlab path,addpath,rmpath",
69    accel = "cpu",
70    suppress_auto_output = true,
71    type_resolver(crate::builtins::io::type_resolvers::path_type),
72    builtin_path = "crate::builtins::io::repl_fs::path"
73)]
74async fn path_builtin(args: Vec<Value>) -> crate::BuiltinResult<Value> {
75    let gathered = gather_arguments(&args).await?;
76    match gathered.len() {
77        0 => Ok(path_value()),
78        1 => set_single_argument(&gathered[0]),
79        2 => set_two_arguments(&gathered[0], &gathered[1]),
80        _ => Err(path_error("path: too many input arguments")),
81    }
82}
83
84fn path_value() -> Value {
85    char_array_value(&current_path_string())
86}
87
88fn set_single_argument(arg: &Value) -> BuiltinResult<Value> {
89    let previous = current_path_string();
90    let new_path = extract_text(arg)?;
91    set_path_string(&new_path);
92    Ok(char_array_value(&previous))
93}
94
95fn set_two_arguments(first: &Value, second: &Value) -> BuiltinResult<Value> {
96    let previous = current_path_string();
97    let path1 = extract_text(first)?;
98    let path2 = extract_text(second)?;
99    let combined = combine_paths(&path1, &path2);
100    set_path_string(&combined);
101    Ok(char_array_value(&previous))
102}
103
104fn combine_paths(left: &str, right: &str) -> String {
105    match (left.is_empty(), right.is_empty()) {
106        (true, true) => String::new(),
107        (false, true) => left.to_string(),
108        (true, false) => right.to_string(),
109        (false, false) => {
110            let mut combined = String::with_capacity(left.len() + right.len() + 1);
111            combined.push_str(left);
112            combined.push(PATH_LIST_SEPARATOR);
113            combined.push_str(right);
114            combined
115        }
116    }
117}
118
119fn extract_text(value: &Value) -> BuiltinResult<String> {
120    match value {
121        Value::String(text) => Ok(text.clone()),
122        Value::StringArray(StringArray { data, .. }) => {
123            if data.len() != 1 {
124                Err(path_error(ERROR_ARG_TYPE))
125            } else {
126                Ok(data[0].clone())
127            }
128        }
129        Value::CharArray(chars) => {
130            if chars.rows != 1 {
131                return Err(path_error(ERROR_ARG_TYPE));
132            }
133            Ok(chars.data.iter().collect())
134        }
135        Value::Tensor(tensor) => tensor_to_string(tensor),
136        Value::GpuTensor(_) => Err(path_error(ERROR_ARG_TYPE)),
137        _ => Err(path_error(ERROR_ARG_TYPE)),
138    }
139}
140
141fn tensor_to_string(tensor: &Tensor) -> BuiltinResult<String> {
142    if tensor.shape.len() > 2 {
143        return Err(path_error(ERROR_ARG_TYPE));
144    }
145
146    let rows = tensor.rows();
147    if rows > 1 {
148        return Err(path_error(ERROR_ARG_TYPE));
149    }
150
151    let mut text = String::with_capacity(tensor.data.len());
152    for &code in &tensor.data {
153        if !code.is_finite() {
154            return Err(path_error(ERROR_ARG_TYPE));
155        }
156        let rounded = code.round();
157        if (code - rounded).abs() > 1e-6 {
158            return Err(path_error(ERROR_ARG_TYPE));
159        }
160        let int_code = rounded as i64;
161        if !(0..=0x10FFFF).contains(&int_code) {
162            return Err(path_error(ERROR_ARG_TYPE));
163        }
164        let ch = char::from_u32(int_code as u32).ok_or_else(|| path_error(ERROR_ARG_TYPE))?;
165        text.push(ch);
166    }
167
168    Ok(text)
169}
170
171async fn gather_arguments(args: &[Value]) -> BuiltinResult<Vec<Value>> {
172    let mut out = Vec::with_capacity(args.len());
173    for value in args {
174        out.push(
175            gather_if_needed_async(value)
176                .await
177                .map_err(map_control_flow)?,
178        );
179    }
180    Ok(out)
181}
182
183fn char_array_value(text: &str) -> Value {
184    Value::CharArray(CharArray::new_row(text))
185}
186
187#[cfg(test)]
188pub(crate) mod tests {
189    use super::super::REPL_FS_TEST_LOCK;
190    use super::*;
191    use crate::builtins::common::path_search::search_directories;
192    use crate::builtins::common::path_state::set_path_string;
193    use std::convert::TryFrom;
194    use tempfile::tempdir;
195
196    fn path_builtin(args: Vec<Value>) -> BuiltinResult<Value> {
197        futures::executor::block_on(super::path_builtin(args))
198    }
199
200    struct PathGuard {
201        previous: String,
202    }
203
204    impl PathGuard {
205        fn new() -> Self {
206            Self {
207                previous: current_path_string(),
208            }
209        }
210    }
211
212    impl Drop for PathGuard {
213        fn drop(&mut self) {
214            set_path_string(&self.previous);
215        }
216    }
217
218    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
219    #[test]
220    fn path_returns_char_array() {
221        let _lock = REPL_FS_TEST_LOCK
222            .lock()
223            .unwrap_or_else(|poison| poison.into_inner());
224        let _guard = PathGuard::new();
225
226        let value = path_builtin(Vec::new()).expect("path");
227        match value {
228            Value::CharArray(CharArray { rows, .. }) => assert_eq!(rows, 1),
229            other => panic!("expected CharArray, got {other:?}"),
230        }
231    }
232
233    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
234    #[test]
235    fn path_sets_new_value_and_returns_previous() {
236        let _lock = REPL_FS_TEST_LOCK
237            .lock()
238            .unwrap_or_else(|poison| poison.into_inner());
239        let guard = PathGuard::new();
240        let previous = guard.previous.clone();
241
242        let temp = tempdir().expect("tempdir");
243        let dir_str = temp.path().to_string_lossy().into_owned();
244        let new_value = Value::CharArray(CharArray::new_row(&dir_str));
245        let returned = path_builtin(vec![new_value]).expect("path set");
246        let returned_str = String::try_from(&returned).expect("convert");
247        assert_eq!(returned_str, previous);
248
249        let current =
250            String::try_from(&path_builtin(Vec::new()).expect("path")).expect("convert current");
251        assert_eq!(current, dir_str);
252    }
253
254    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
255    #[test]
256    fn path_accepts_string_scalar() {
257        let _lock = REPL_FS_TEST_LOCK
258            .lock()
259            .unwrap_or_else(|poison| poison.into_inner());
260        let guard = PathGuard::new();
261        let previous = guard.previous.clone();
262
263        let new_value = Value::String("runmat/path/string".to_string());
264        let returned = path_builtin(vec![new_value]).expect("path set");
265        let returned_str = String::try_from(&returned).expect("convert");
266        assert_eq!(returned_str, previous);
267
268        let current =
269            String::try_from(&path_builtin(Vec::new()).expect("path")).expect("convert current");
270        assert_eq!(current, "runmat/path/string");
271    }
272
273    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
274    #[test]
275    fn path_accepts_tensor_codes() {
276        let _lock = REPL_FS_TEST_LOCK
277            .lock()
278            .unwrap_or_else(|poison| poison.into_inner());
279        let guard = PathGuard::new();
280        let previous = guard.previous.clone();
281
282        let text = "tensor-path";
283        let codes: Vec<f64> = text.chars().map(|ch| ch as u32 as f64).collect();
284        let tensor = Tensor::new(codes, vec![1, text.len()]).expect("tensor");
285        let returned = path_builtin(vec![Value::Tensor(tensor)]).expect("path set");
286        let returned_str = String::try_from(&returned).expect("convert");
287        assert_eq!(returned_str, previous);
288
289        let current =
290            String::try_from(&path_builtin(Vec::new()).expect("path")).expect("convert current");
291        assert_eq!(current, text);
292    }
293
294    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
295    #[test]
296    fn path_combines_two_arguments() {
297        let _lock = REPL_FS_TEST_LOCK
298            .lock()
299            .unwrap_or_else(|poison| poison.into_inner());
300        let _guard = PathGuard::new();
301
302        let dir1 = tempdir().expect("dir1");
303        let dir2 = tempdir().expect("dir2");
304        let dir1_str = dir1.path().to_string_lossy().to_string();
305        let dir2_str = dir2.path().to_string_lossy().to_string();
306        let path1 = Value::CharArray(CharArray::new_row(&dir1_str));
307        let path2 = Value::CharArray(CharArray::new_row(&dir2_str));
308        let _returned = path_builtin(vec![path1, path2]).expect("path set");
309
310        let current =
311            String::try_from(&path_builtin(Vec::new()).expect("path")).expect("convert current");
312        let expected = format!(
313            "{}{sep}{}",
314            dir1.path().to_string_lossy(),
315            dir2.path().to_string_lossy(),
316            sep = PATH_LIST_SEPARATOR
317        );
318        assert_eq!(current, expected);
319    }
320
321    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
322    #[test]
323    fn path_rejects_multi_row_char_array() {
324        let _lock = REPL_FS_TEST_LOCK
325            .lock()
326            .unwrap_or_else(|poison| poison.into_inner());
327        let _guard = PathGuard::new();
328
329        let chars = CharArray::new(vec!['a', 'b', 'c', 'd'], 2, 2).expect("char array");
330        let err = path_builtin(vec![Value::CharArray(chars)]).expect_err("expected error");
331        assert_eq!(err.message(), ERROR_ARG_TYPE);
332    }
333
334    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
335    #[test]
336    fn path_rejects_multi_element_string_array() {
337        let _lock = REPL_FS_TEST_LOCK
338            .lock()
339            .unwrap_or_else(|poison| poison.into_inner());
340        let _guard = PathGuard::new();
341
342        let array = StringArray::new(vec!["a".into(), "b".into()], vec![1, 2]).expect("array");
343        let err = path_builtin(vec![Value::StringArray(array)]).expect_err("expected error");
344        assert_eq!(err.message(), ERROR_ARG_TYPE);
345    }
346
347    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
348    #[test]
349    fn path_rejects_invalid_argument_types() {
350        let _lock = REPL_FS_TEST_LOCK
351            .lock()
352            .unwrap_or_else(|poison| poison.into_inner());
353        let _guard = PathGuard::new();
354
355        let err = path_builtin(vec![Value::Num(1.0)]).expect_err("expected error");
356        assert!(err.message().contains("path: arguments"));
357    }
358
359    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
360    #[test]
361    fn path_updates_search_directories() {
362        let _lock = REPL_FS_TEST_LOCK
363            .lock()
364            .unwrap_or_else(|poison| poison.into_inner());
365        let _guard = PathGuard::new();
366
367        let temp = tempdir().expect("tempdir");
368        let dir = temp.path().to_string_lossy().into_owned();
369        let _ = path_builtin(vec![Value::CharArray(CharArray::new_row(&dir))]).expect("path");
370
371        let search = search_directories("path test").expect("search directories");
372        let search_strings: Vec<String> = search
373            .iter()
374            .map(|p| p.to_string_lossy().into_owned())
375            .collect();
376        assert!(
377            search_strings.iter().any(|entry| entry == &dir),
378            "search path should include newly added directory"
379        );
380    }
381}