1use 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(¤t_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}