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::{
5    BuiltinCompletionPolicy, BuiltinDescriptor, BuiltinErrorDescriptor, BuiltinOutputMode,
6    BuiltinParamArity, BuiltinParamDescriptor, BuiltinParamType, BuiltinSignatureDescriptor,
7    CharArray, StringArray, Tensor, Value,
8};
9use runmat_macros::runtime_builtin;
10
11use crate::builtins::common::path_state::{
12    current_path_string, set_path_string, PATH_LIST_SEPARATOR,
13};
14use crate::builtins::common::spec::{
15    BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
16    ReductionNaN, ResidencyPolicy, ShapeRequirements,
17};
18use crate::{build_runtime_error, gather_if_needed_async, BuiltinResult, RuntimeError};
19
20#[runmat_macros::register_gpu_spec(builtin_path = "crate::builtins::io::repl_fs::path")]
21pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
22    name: "path",
23    op_kind: GpuOpKind::Custom("io"),
24    supported_precisions: &[],
25    broadcast: BroadcastSemantics::None,
26    provider_hooks: &[],
27    constant_strategy: ConstantStrategy::InlineLiteral,
28    residency: ResidencyPolicy::GatherImmediately,
29    nan_mode: ReductionNaN::Include,
30    two_pass_threshold: None,
31    workgroup_size: None,
32    accepts_nan_mode: false,
33    notes: "Search-path management is a host-only operation; GPU inputs are gathered before processing.",
34};
35
36#[runmat_macros::register_fusion_spec(builtin_path = "crate::builtins::io::repl_fs::path")]
37pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
38    name: "path",
39    shape: ShapeRequirements::Any,
40    constant_strategy: ConstantStrategy::InlineLiteral,
41    elementwise: None,
42    reduction: None,
43    emits_nan: false,
44    notes: "I/O builtins are not eligible for fusion; metadata registered for introspection completeness.",
45};
46
47const BUILTIN_NAME: &str = "path";
48
49const PATH_OUTPUT_PREVIOUS: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
50    name: "oldpath",
51    ty: BuiltinParamType::StringScalar,
52    arity: BuiltinParamArity::Required,
53    default: None,
54    description: "Previous search path string.",
55}];
56const PATH_INPUTS_NONE: [BuiltinParamDescriptor; 0] = [];
57const PATH_INPUTS_PATH1: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
58    name: "path1",
59    ty: BuiltinParamType::StringScalar,
60    arity: BuiltinParamArity::Required,
61    default: None,
62    description: "Replacement path string.",
63}];
64const PATH_INPUTS_PATH1_PATH2: [BuiltinParamDescriptor; 2] = [
65    BuiltinParamDescriptor {
66        name: "path1",
67        ty: BuiltinParamType::StringScalar,
68        arity: BuiltinParamArity::Required,
69        default: None,
70        description: "Left path fragment.",
71    },
72    BuiltinParamDescriptor {
73        name: "path2",
74        ty: BuiltinParamType::StringScalar,
75        arity: BuiltinParamArity::Required,
76        default: None,
77        description: "Right path fragment.",
78    },
79];
80const PATH_SIGNATURES: [BuiltinSignatureDescriptor; 3] = [
81    BuiltinSignatureDescriptor {
82        label: "oldpath = path()",
83        inputs: &PATH_INPUTS_NONE,
84        outputs: &PATH_OUTPUT_PREVIOUS,
85    },
86    BuiltinSignatureDescriptor {
87        label: "oldpath = path(path1)",
88        inputs: &PATH_INPUTS_PATH1,
89        outputs: &PATH_OUTPUT_PREVIOUS,
90    },
91    BuiltinSignatureDescriptor {
92        label: "oldpath = path(path1, path2)",
93        inputs: &PATH_INPUTS_PATH1_PATH2,
94        outputs: &PATH_OUTPUT_PREVIOUS,
95    },
96];
97
98const PATH_ERROR_INVALID_INPUT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
99    code: "RM.PATH.INVALID_INPUT",
100    identifier: None,
101    when: "Path arguments are not character vectors or string scalars.",
102    message: "path: arguments must be character vectors or string scalars",
103};
104const PATH_ERROR_TOO_MANY_INPUTS: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
105    code: "RM.PATH.TOO_MANY_INPUTS",
106    identifier: None,
107    when: "More than two positional arguments are provided.",
108    message: "path: too many input arguments",
109};
110const PATH_ERRORS: [BuiltinErrorDescriptor; 2] =
111    [PATH_ERROR_INVALID_INPUT, PATH_ERROR_TOO_MANY_INPUTS];
112pub const PATH_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
113    signatures: &PATH_SIGNATURES,
114    output_mode: BuiltinOutputMode::Fixed,
115    completion_policy: BuiltinCompletionPolicy::Public,
116    errors: &PATH_ERRORS,
117};
118
119fn path_error(message: impl Into<String>) -> RuntimeError {
120    build_runtime_error(message)
121        .with_builtin(BUILTIN_NAME)
122        .build()
123}
124
125fn map_control_flow(err: RuntimeError) -> RuntimeError {
126    let identifier = err.identifier().map(str::to_string);
127    let mut builder = build_runtime_error(format!("{BUILTIN_NAME}: {}", err.message()))
128        .with_builtin(BUILTIN_NAME)
129        .with_source(err);
130    if let Some(identifier) = identifier {
131        builder = builder.with_identifier(identifier);
132    }
133    builder.build()
134}
135
136#[runtime_builtin(
137    name = "path",
138    category = "io/repl_fs",
139    summary = "Query or replace the active MATLAB search path string.",
140    keywords = "path,search path,matlab path,addpath,rmpath",
141    accel = "cpu",
142    suppress_auto_output = true,
143    type_resolver(crate::builtins::io::type_resolvers::path_type),
144    descriptor(crate::builtins::io::repl_fs::path::PATH_DESCRIPTOR),
145    builtin_path = "crate::builtins::io::repl_fs::path"
146)]
147async fn path_builtin(args: Vec<Value>) -> crate::BuiltinResult<Value> {
148    let gathered = gather_arguments(&args).await?;
149    match gathered.len() {
150        0 => Ok(path_value()),
151        1 => set_single_argument(&gathered[0]),
152        2 => set_two_arguments(&gathered[0], &gathered[1]),
153        _ => Err(path_error(PATH_ERROR_TOO_MANY_INPUTS.message)),
154    }
155}
156
157fn path_value() -> Value {
158    char_array_value(&current_path_string())
159}
160
161fn set_single_argument(arg: &Value) -> BuiltinResult<Value> {
162    let previous = current_path_string();
163    let new_path = extract_text(arg)?;
164    set_path_string(&new_path);
165    Ok(char_array_value(&previous))
166}
167
168fn set_two_arguments(first: &Value, second: &Value) -> BuiltinResult<Value> {
169    let previous = current_path_string();
170    let path1 = extract_text(first)?;
171    let path2 = extract_text(second)?;
172    let combined = combine_paths(&path1, &path2);
173    set_path_string(&combined);
174    Ok(char_array_value(&previous))
175}
176
177fn combine_paths(left: &str, right: &str) -> String {
178    match (left.is_empty(), right.is_empty()) {
179        (true, true) => String::new(),
180        (false, true) => left.to_string(),
181        (true, false) => right.to_string(),
182        (false, false) => {
183            let mut combined = String::with_capacity(left.len() + right.len() + 1);
184            combined.push_str(left);
185            combined.push(PATH_LIST_SEPARATOR);
186            combined.push_str(right);
187            combined
188        }
189    }
190}
191
192fn extract_text(value: &Value) -> BuiltinResult<String> {
193    match value {
194        Value::String(text) => Ok(text.clone()),
195        Value::StringArray(StringArray { data, .. }) => {
196            if data.len() != 1 {
197                Err(path_error(PATH_ERROR_INVALID_INPUT.message))
198            } else {
199                Ok(data[0].clone())
200            }
201        }
202        Value::CharArray(chars) => {
203            if chars.rows != 1 {
204                return Err(path_error(PATH_ERROR_INVALID_INPUT.message));
205            }
206            Ok(chars.data.iter().collect())
207        }
208        Value::Tensor(tensor) => tensor_to_string(tensor),
209        Value::GpuTensor(_) => Err(path_error(PATH_ERROR_INVALID_INPUT.message)),
210        _ => Err(path_error(PATH_ERROR_INVALID_INPUT.message)),
211    }
212}
213
214fn tensor_to_string(tensor: &Tensor) -> BuiltinResult<String> {
215    if tensor.shape.len() > 2 {
216        return Err(path_error(PATH_ERROR_INVALID_INPUT.message));
217    }
218
219    let rows = tensor.rows();
220    if rows > 1 {
221        return Err(path_error(PATH_ERROR_INVALID_INPUT.message));
222    }
223
224    let mut text = String::with_capacity(tensor.data.len());
225    for &code in &tensor.data {
226        if !code.is_finite() {
227            return Err(path_error(PATH_ERROR_INVALID_INPUT.message));
228        }
229        let rounded = code.round();
230        if (code - rounded).abs() > 1e-6 {
231            return Err(path_error(PATH_ERROR_INVALID_INPUT.message));
232        }
233        let int_code = rounded as i64;
234        if !(0..=0x10FFFF).contains(&int_code) {
235            return Err(path_error(PATH_ERROR_INVALID_INPUT.message));
236        }
237        let ch = char::from_u32(int_code as u32)
238            .ok_or_else(|| path_error(PATH_ERROR_INVALID_INPUT.message))?;
239        text.push(ch);
240    }
241
242    Ok(text)
243}
244
245async fn gather_arguments(args: &[Value]) -> BuiltinResult<Vec<Value>> {
246    let mut out = Vec::with_capacity(args.len());
247    for value in args {
248        out.push(
249            gather_if_needed_async(value)
250                .await
251                .map_err(map_control_flow)?,
252        );
253    }
254    Ok(out)
255}
256
257fn char_array_value(text: &str) -> Value {
258    Value::CharArray(CharArray::new_row(text))
259}
260
261#[cfg(test)]
262pub(crate) mod tests {
263    use super::super::REPL_FS_TEST_LOCK;
264    use super::*;
265    use crate::builtins::common::path_search::search_directories;
266    use crate::builtins::common::path_state::set_path_string;
267    use std::convert::TryFrom;
268    use tempfile::tempdir;
269
270    fn path_builtin(args: Vec<Value>) -> BuiltinResult<Value> {
271        futures::executor::block_on(super::path_builtin(args))
272    }
273
274    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
275    #[test]
276    fn path_descriptor_signatures_cover_core_forms() {
277        let labels: Vec<&str> = PATH_DESCRIPTOR
278            .signatures
279            .iter()
280            .map(|sig| sig.label)
281            .collect();
282        assert!(labels.contains(&"oldpath = path()"));
283        assert!(labels.contains(&"oldpath = path(path1)"));
284        assert!(labels.contains(&"oldpath = path(path1, path2)"));
285    }
286
287    struct PathGuard {
288        previous: String,
289    }
290
291    impl PathGuard {
292        fn new() -> Self {
293            Self {
294                previous: current_path_string(),
295            }
296        }
297    }
298
299    impl Drop for PathGuard {
300        fn drop(&mut self) {
301            set_path_string(&self.previous);
302        }
303    }
304
305    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
306    #[test]
307    fn path_returns_char_array() {
308        let _lock = REPL_FS_TEST_LOCK
309            .lock()
310            .unwrap_or_else(|poison| poison.into_inner());
311        let _guard = PathGuard::new();
312
313        let value = path_builtin(Vec::new()).expect("path");
314        match value {
315            Value::CharArray(CharArray { rows, .. }) => assert_eq!(rows, 1),
316            other => panic!("expected CharArray, got {other:?}"),
317        }
318    }
319
320    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
321    #[test]
322    fn path_sets_new_value_and_returns_previous() {
323        let _lock = REPL_FS_TEST_LOCK
324            .lock()
325            .unwrap_or_else(|poison| poison.into_inner());
326        let guard = PathGuard::new();
327        let previous = guard.previous.clone();
328
329        let temp = tempdir().expect("tempdir");
330        let dir_str = temp.path().to_string_lossy().into_owned();
331        let new_value = Value::CharArray(CharArray::new_row(&dir_str));
332        let returned = path_builtin(vec![new_value]).expect("path set");
333        let returned_str = String::try_from(&returned).expect("convert");
334        assert_eq!(returned_str, previous);
335
336        let current =
337            String::try_from(&path_builtin(Vec::new()).expect("path")).expect("convert current");
338        assert_eq!(current, dir_str);
339    }
340
341    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
342    #[test]
343    fn path_accepts_string_scalar() {
344        let _lock = REPL_FS_TEST_LOCK
345            .lock()
346            .unwrap_or_else(|poison| poison.into_inner());
347        let guard = PathGuard::new();
348        let previous = guard.previous.clone();
349
350        let new_value = Value::String("runmat/path/string".to_string());
351        let returned = path_builtin(vec![new_value]).expect("path set");
352        let returned_str = String::try_from(&returned).expect("convert");
353        assert_eq!(returned_str, previous);
354
355        let current =
356            String::try_from(&path_builtin(Vec::new()).expect("path")).expect("convert current");
357        assert_eq!(current, "runmat/path/string");
358    }
359
360    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
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    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
382    #[test]
383    fn path_combines_two_arguments() {
384        let _lock = REPL_FS_TEST_LOCK
385            .lock()
386            .unwrap_or_else(|poison| poison.into_inner());
387        let _guard = PathGuard::new();
388
389        let dir1 = tempdir().expect("dir1");
390        let dir2 = tempdir().expect("dir2");
391        let dir1_str = dir1.path().to_string_lossy().to_string();
392        let dir2_str = dir2.path().to_string_lossy().to_string();
393        let path1 = Value::CharArray(CharArray::new_row(&dir1_str));
394        let path2 = Value::CharArray(CharArray::new_row(&dir2_str));
395        let _returned = path_builtin(vec![path1, path2]).expect("path set");
396
397        let current =
398            String::try_from(&path_builtin(Vec::new()).expect("path")).expect("convert current");
399        let expected = format!(
400            "{}{sep}{}",
401            dir1.path().to_string_lossy(),
402            dir2.path().to_string_lossy(),
403            sep = PATH_LIST_SEPARATOR
404        );
405        assert_eq!(current, expected);
406    }
407
408    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
409    #[test]
410    fn path_rejects_multi_row_char_array() {
411        let _lock = REPL_FS_TEST_LOCK
412            .lock()
413            .unwrap_or_else(|poison| poison.into_inner());
414        let _guard = PathGuard::new();
415
416        let chars = CharArray::new(vec!['a', 'b', 'c', 'd'], 2, 2).expect("char array");
417        let err = path_builtin(vec![Value::CharArray(chars)]).expect_err("expected error");
418        assert_eq!(err.message(), PATH_ERROR_INVALID_INPUT.message);
419    }
420
421    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
422    #[test]
423    fn path_rejects_multi_element_string_array() {
424        let _lock = REPL_FS_TEST_LOCK
425            .lock()
426            .unwrap_or_else(|poison| poison.into_inner());
427        let _guard = PathGuard::new();
428
429        let array = StringArray::new(vec!["a".into(), "b".into()], vec![1, 2]).expect("array");
430        let err = path_builtin(vec![Value::StringArray(array)]).expect_err("expected error");
431        assert_eq!(err.message(), PATH_ERROR_INVALID_INPUT.message);
432    }
433
434    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
435    #[test]
436    fn path_rejects_invalid_argument_types() {
437        let _lock = REPL_FS_TEST_LOCK
438            .lock()
439            .unwrap_or_else(|poison| poison.into_inner());
440        let _guard = PathGuard::new();
441
442        let err = path_builtin(vec![Value::Num(1.0)]).expect_err("expected error");
443        assert!(err.message().contains("path: arguments"));
444    }
445
446    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
447    #[test]
448    fn path_updates_search_directories() {
449        let _lock = REPL_FS_TEST_LOCK
450            .lock()
451            .unwrap_or_else(|poison| poison.into_inner());
452        let _guard = PathGuard::new();
453
454        let temp = tempdir().expect("tempdir");
455        let dir = temp.path().to_string_lossy().into_owned();
456        let _ = path_builtin(vec![Value::CharArray(CharArray::new_row(&dir))]).expect("path");
457
458        let search = search_directories("path test").expect("search directories");
459        let search_strings: Vec<String> = search
460            .iter()
461            .map(|p| p.to_string_lossy().into_owned())
462            .collect();
463        assert!(
464            search_strings.iter().any(|entry| entry == &dir),
465            "search path should include newly added directory"
466        );
467    }
468}