runmat_runtime/builtins/io/repl_fs/
path.rs1use 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(¤t_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}