Skip to main content

runmat_runtime/builtins/io/repl_fs/
exist.rs

1//! MATLAB-compatible `exist` builtin for RunMat.
2//!
3//! Mirrors MATLAB semantics for checking whether a variable, file, folder,
4//! builtin, class, or other entity is available in the current session.
5
6use runmat_builtins::{builtin_functions, lookup_method, Value};
7use runmat_macros::runtime_builtin;
8
9use crate::builtins::common::fs::contains_wildcards;
10use crate::builtins::common::path_search::{
11    class_file_exists as path_class_file_exists,
12    class_folder_candidates as path_class_folder_candidates,
13    directory_candidates as path_directory_candidates,
14    find_file_with_extensions as path_find_file_with_extensions, path_is_directory,
15    CLASS_M_FILE_EXTENSIONS, GENERAL_FILE_EXTENSIONS, LIB_EXTENSIONS, MEX_EXTENSIONS,
16    PCODE_EXTENSIONS, SIMULINK_EXTENSIONS, THUNK_EXTENSIONS,
17};
18use crate::builtins::common::spec::{
19    BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
20    ReductionNaN, ResidencyPolicy, ShapeRequirements,
21};
22use crate::{build_runtime_error, gather_if_needed_async, BuiltinResult, RuntimeError};
23
24const ERROR_NAME_ARG: &str = "exist: name must be a character vector or string scalar";
25const ERROR_TYPE_ARG: &str = "exist: type must be a character vector or string scalar";
26const ERROR_INVALID_TYPE: &str = "exist: invalid type. Type must be one of 'var', 'variable', 'file', 'dir', 'directory', 'folder', 'builtin', 'built-in', 'class', 'handle', 'method', 'mex', 'pcode', 'simulink', 'thunk', 'lib', 'library', or 'java'";
27
28#[runmat_macros::register_gpu_spec(builtin_path = "crate::builtins::io::repl_fs::exist")]
29pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
30    name: "exist",
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: "Filesystem and workspace lookup run on the host; arguments are gathered from the GPU when necessary.",
42};
43
44#[runmat_macros::register_fusion_spec(builtin_path = "crate::builtins::io::repl_fs::exist")]
45pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
46    name: "exist",
47    shape: ShapeRequirements::Any,
48    constant_strategy: ConstantStrategy::InlineLiteral,
49    elementwise: None,
50    reduction: None,
51    emits_nan: false,
52    notes: "I/O builtins are not eligible for fusion; metadata registered for completeness.",
53};
54
55const BUILTIN_NAME: &str = "exist";
56
57fn exist_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#[runtime_builtin(
75    name = "exist",
76    category = "io/repl_fs",
77    summary = "Determine whether a variable, file, folder, built-in, or class exists.",
78    keywords = "exist,file,dir,var,builtin,class",
79    accel = "cpu",
80    type_resolver(crate::builtins::io::type_resolvers::exist_type),
81    builtin_path = "crate::builtins::io::repl_fs::exist"
82)]
83async fn exist_builtin(name: Value, rest: Vec<Value>) -> crate::BuiltinResult<Value> {
84    if rest.len() > 1 {
85        return Err(exist_error("exist: too many input arguments"));
86    }
87
88    let name_host = gather_if_needed_async(&name)
89        .await
90        .map_err(map_control_flow)?;
91    let type_value = match rest.first() {
92        Some(value) => Some(
93            gather_if_needed_async(value)
94                .await
95                .map_err(map_control_flow)?,
96        ),
97        None => None,
98    };
99
100    let query = type_value
101        .as_ref()
102        .map(parse_type_argument)
103        .transpose()?
104        .unwrap_or(ExistQuery::Any);
105
106    let result = match query {
107        ExistQuery::Handle => exist_handle(&name_host),
108        _ => {
109            let text = value_to_string(&name_host).ok_or_else(|| exist_error(ERROR_NAME_ARG))?;
110            exist_for_query(&text, query).await?
111        }
112    };
113
114    Ok(Value::Num(result.code()))
115}
116
117#[derive(Clone, Copy, Debug, PartialEq, Eq)]
118enum ExistQuery {
119    Any,
120    Var,
121    File,
122    Dir,
123    Builtin,
124    Class,
125    Mex,
126    Pcode,
127    Method,
128    Handle,
129    Simulink,
130    Thunk,
131    Lib,
132    Java,
133}
134
135#[derive(Clone, Copy, Debug, PartialEq, Eq)]
136enum ExistResultKind {
137    NotFound,
138    Variable,
139    File,
140    Mex,
141    Simulink,
142    Builtin,
143    Pcode,
144    Directory,
145    Class,
146}
147
148impl ExistResultKind {
149    fn code(self) -> f64 {
150        match self {
151            ExistResultKind::NotFound => 0.0,
152            ExistResultKind::Variable => 1.0,
153            ExistResultKind::File => 2.0,
154            ExistResultKind::Mex => 3.0,
155            ExistResultKind::Simulink => 4.0,
156            ExistResultKind::Builtin => 5.0,
157            ExistResultKind::Pcode => 6.0,
158            ExistResultKind::Directory => 7.0,
159            ExistResultKind::Class => 8.0,
160        }
161    }
162}
163
164fn parse_type_argument(value: &Value) -> BuiltinResult<ExistQuery> {
165    let text = value_to_string(value).ok_or_else(|| exist_error(ERROR_TYPE_ARG))?;
166    match text.trim().to_ascii_lowercase().as_str() {
167        "" => Ok(ExistQuery::Any),
168        "var" | "variable" => Ok(ExistQuery::Var),
169        "file" => Ok(ExistQuery::File),
170        "dir" | "directory" | "folder" => Ok(ExistQuery::Dir),
171        "builtin" | "built-in" => Ok(ExistQuery::Builtin),
172        "class" => Ok(ExistQuery::Class),
173        "mex" => Ok(ExistQuery::Mex),
174        "pcode" | "p" => Ok(ExistQuery::Pcode),
175        "handle" => Ok(ExistQuery::Handle),
176        "method" => Ok(ExistQuery::Method),
177        "simulink" => Ok(ExistQuery::Simulink),
178        "thunk" => Ok(ExistQuery::Thunk),
179        "lib" | "library" => Ok(ExistQuery::Lib),
180        "java" => Ok(ExistQuery::Java),
181        _ => Err(exist_error(ERROR_INVALID_TYPE)),
182    }
183}
184
185async fn exist_for_query(name: &str, query: ExistQuery) -> BuiltinResult<ExistResultKind> {
186    if contains_wildcards(name) {
187        return Ok(ExistResultKind::NotFound);
188    }
189
190    match query {
191        ExistQuery::Any => evaluate_default(name).await,
192        ExistQuery::Var => Ok(if variable_exists(name) {
193            ExistResultKind::Variable
194        } else {
195            ExistResultKind::NotFound
196        }),
197        ExistQuery::Builtin => Ok(if builtin_exists(name) {
198            ExistResultKind::Builtin
199        } else {
200            ExistResultKind::NotFound
201        }),
202        ExistQuery::Class => Ok(if class_exists(name).await? {
203            ExistResultKind::Class
204        } else {
205            ExistResultKind::NotFound
206        }),
207        ExistQuery::Dir => Ok(if directory_exists(name).await? {
208            ExistResultKind::Directory
209        } else {
210            ExistResultKind::NotFound
211        }),
212        ExistQuery::File => Ok(detect_file_kind(name)
213            .await?
214            .unwrap_or(ExistResultKind::NotFound)),
215        ExistQuery::Mex => Ok(
216            if path_find_file_with_extensions(name, MEX_EXTENSIONS, "exist")
217                .await
218                .map_err(exist_error)?
219                .is_some()
220            {
221                ExistResultKind::Mex
222            } else {
223                ExistResultKind::NotFound
224            },
225        ),
226        ExistQuery::Pcode => Ok(
227            if path_find_file_with_extensions(name, PCODE_EXTENSIONS, "exist")
228                .await
229                .map_err(exist_error)?
230                .is_some()
231            {
232                ExistResultKind::Pcode
233            } else {
234                ExistResultKind::NotFound
235            },
236        ),
237        ExistQuery::Method => Ok(if method_exists(name) {
238            ExistResultKind::Builtin
239        } else {
240            ExistResultKind::NotFound
241        }),
242        ExistQuery::Simulink => Ok(
243            if path_find_file_with_extensions(name, SIMULINK_EXTENSIONS, "exist")
244                .await
245                .map_err(exist_error)?
246                .is_some()
247            {
248                ExistResultKind::Simulink
249            } else {
250                ExistResultKind::NotFound
251            },
252        ),
253        ExistQuery::Thunk => Ok(
254            if path_find_file_with_extensions(name, THUNK_EXTENSIONS, "exist")
255                .await
256                .map_err(exist_error)?
257                .is_some()
258            {
259                ExistResultKind::File
260            } else {
261                ExistResultKind::NotFound
262            },
263        ),
264        ExistQuery::Lib => Ok(
265            if path_find_file_with_extensions(name, LIB_EXTENSIONS, "exist")
266                .await
267                .map_err(exist_error)?
268                .is_some()
269            {
270                ExistResultKind::File
271            } else {
272                ExistResultKind::NotFound
273            },
274        ),
275        ExistQuery::Java => Ok(ExistResultKind::NotFound),
276        ExistQuery::Handle => unreachable!("handle queries handled separately"),
277    }
278}
279
280async fn evaluate_default(name: &str) -> BuiltinResult<ExistResultKind> {
281    if variable_exists(name) {
282        return Ok(ExistResultKind::Variable);
283    }
284    if builtin_exists(name) {
285        return Ok(ExistResultKind::Builtin);
286    }
287    if class_exists(name).await? {
288        return Ok(ExistResultKind::Class);
289    }
290    if let Some(kind) = detect_file_kind(name).await? {
291        return Ok(kind);
292    }
293    if directory_exists(name).await? {
294        return Ok(ExistResultKind::Directory);
295    }
296    Ok(ExistResultKind::NotFound)
297}
298
299fn exist_handle(value: &Value) -> ExistResultKind {
300    match value {
301        Value::HandleObject(handle) => {
302            if handle.valid {
303                ExistResultKind::Variable
304            } else {
305                ExistResultKind::NotFound
306            }
307        }
308        Value::Listener(listener) => {
309            if listener.valid {
310                ExistResultKind::Variable
311            } else {
312                ExistResultKind::NotFound
313            }
314        }
315        _ => ExistResultKind::NotFound,
316    }
317}
318
319fn variable_exists(name: &str) -> bool {
320    crate::workspace::lookup(name).is_some()
321}
322
323fn builtin_exists(name: &str) -> bool {
324    let lowered = name.to_ascii_lowercase();
325    builtin_functions()
326        .into_iter()
327        .any(|b| b.name.eq_ignore_ascii_case(&lowered))
328}
329
330async fn class_exists(name: &str) -> BuiltinResult<bool> {
331    if runmat_builtins::get_class(name).is_some() {
332        return Ok(true);
333    }
334    if class_folder_exists(name).await? {
335        return Ok(true);
336    }
337    if class_file_exists(name).await? {
338        return Ok(true);
339    }
340    Ok(false)
341}
342
343async fn class_folder_exists(name: &str) -> BuiltinResult<bool> {
344    for path in path_class_folder_candidates(name, "exist").map_err(exist_error)? {
345        if path_is_directory(&path).await {
346            return Ok(true);
347        }
348    }
349    Ok(false)
350}
351
352async fn class_file_exists(name: &str) -> BuiltinResult<bool> {
353    path_class_file_exists(name, CLASS_M_FILE_EXTENSIONS, "classdef", "exist")
354        .await
355        .map_err(exist_error)
356}
357
358fn method_exists(name: &str) -> bool {
359    if let Some((class_name, method_name)) = split_method_name(name) {
360        lookup_method(&class_name, &method_name).is_some()
361    } else {
362        false
363    }
364}
365
366async fn directory_exists(name: &str) -> BuiltinResult<bool> {
367    for path in path_directory_candidates(name, "exist").map_err(exist_error)? {
368        if path_is_directory(&path).await {
369            return Ok(true);
370        }
371    }
372    Ok(false)
373}
374
375async fn detect_file_kind(name: &str) -> BuiltinResult<Option<ExistResultKind>> {
376    if path_find_file_with_extensions(name, MEX_EXTENSIONS, "exist")
377        .await
378        .map_err(exist_error)?
379        .is_some()
380    {
381        return Ok(Some(ExistResultKind::Mex));
382    }
383    if path_find_file_with_extensions(name, PCODE_EXTENSIONS, "exist")
384        .await
385        .map_err(exist_error)?
386        .is_some()
387    {
388        return Ok(Some(ExistResultKind::Pcode));
389    }
390    if path_find_file_with_extensions(name, SIMULINK_EXTENSIONS, "exist")
391        .await
392        .map_err(exist_error)?
393        .is_some()
394    {
395        return Ok(Some(ExistResultKind::Simulink));
396    }
397    if path_find_file_with_extensions(name, GENERAL_FILE_EXTENSIONS, "exist")
398        .await
399        .map_err(exist_error)?
400        .is_some()
401    {
402        return Ok(Some(ExistResultKind::File));
403    }
404    Ok(None)
405}
406
407fn value_to_string(value: &Value) -> Option<String> {
408    match value {
409        Value::String(text) => Some(text.clone()),
410        Value::CharArray(array) if array.rows == 1 => Some(array.data.iter().collect()),
411        Value::StringArray(array) if array.data.len() == 1 => Some(array.data[0].clone()),
412        _ => None,
413    }
414}
415
416fn split_method_name(name: &str) -> Option<(String, String)> {
417    let mut parts: Vec<&str> = name.split('.').collect();
418    if parts.len() < 2 {
419        return None;
420    }
421    let method = parts.pop()?.to_string();
422    if method.is_empty() {
423        return None;
424    }
425    let class = parts.join(".");
426    if class.is_empty() {
427        return None;
428    }
429    Some((class, method))
430}
431
432#[cfg(test)]
433pub(crate) mod tests {
434    use super::super::REPL_FS_TEST_LOCK;
435    use super::*;
436    use runmat_builtins::Value;
437    use runmat_filesystem as vfs;
438    use runmat_thread_local::runmat_thread_local;
439    use std::cell::RefCell;
440    use std::collections::HashMap;
441    use std::env;
442    use std::fs::File;
443    use std::io::Write;
444    use std::path::PathBuf;
445    use tempfile::tempdir;
446
447    fn exist_builtin(name: Value, rest: Vec<Value>) -> BuiltinResult<Value> {
448        futures::executor::block_on(super::exist_builtin(name, rest))
449    }
450
451    fn workspace_guard() -> std::sync::MutexGuard<'static, ()> {
452        crate::workspace::test_guard()
453    }
454
455    fn test_guard() -> (
456        std::sync::MutexGuard<'static, ()>,
457        std::sync::MutexGuard<'static, ()>,
458    ) {
459        let workspace = workspace_guard();
460        let fs_lock = REPL_FS_TEST_LOCK
461            .lock()
462            .unwrap_or_else(|poison| poison.into_inner());
463        (workspace, fs_lock)
464    }
465
466    runmat_thread_local! {
467        static TEST_WORKSPACE: RefCell<HashMap<String, Value>> = RefCell::new(HashMap::new());
468    }
469
470    fn ensure_test_resolver() {
471        crate::workspace::register_workspace_resolver(crate::workspace::WorkspaceResolver {
472            lookup: |name| TEST_WORKSPACE.with(|slot| slot.borrow().get(name).cloned()),
473            snapshot: || {
474                let mut entries: Vec<(String, Value)> =
475                    TEST_WORKSPACE.with(|slot| slot.borrow().clone().into_iter().collect());
476                entries.sort_by(|a, b| a.0.cmp(&b.0));
477                entries
478            },
479            globals: || Vec::new(),
480            assign: None,
481            clear: None,
482            remove: None,
483        });
484    }
485
486    fn set_workspace(entries: &[(&str, Value)]) {
487        TEST_WORKSPACE.with(|slot| {
488            let mut map = slot.borrow_mut();
489            map.clear();
490            for (name, value) in entries {
491                map.insert((*name).to_string(), value.clone());
492            }
493        });
494    }
495
496    struct DirGuard {
497        original: PathBuf,
498    }
499
500    impl DirGuard {
501        fn new() -> Self {
502            let original = env::current_dir().expect("current dir");
503            Self { original }
504        }
505    }
506
507    impl Drop for DirGuard {
508        fn drop(&mut self) {
509            let _ = env::set_current_dir(&self.original);
510        }
511    }
512
513    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
514    #[test]
515    fn exist_detects_workspace_variables() {
516        let (_guard, _lock) = test_guard();
517        ensure_test_resolver();
518        set_workspace(&[("alpha", Value::Num(1.0))]);
519
520        let value = exist_builtin(Value::from("alpha"), Vec::new()).expect("exist");
521        assert_eq!(value, Value::Num(1.0));
522    }
523
524    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
525    #[test]
526    fn exist_detects_builtins() {
527        let (_guard, _lock) = test_guard();
528
529        let value = exist_builtin(Value::from("sin"), Vec::new()).expect("exist");
530        assert_eq!(value, Value::Num(5.0));
531
532        let builtin =
533            exist_builtin(Value::from("sin"), vec![Value::from("builtin")]).expect("exist");
534        assert_eq!(builtin, Value::Num(5.0));
535    }
536
537    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
538    #[test]
539    fn exist_detects_files_and_mex() {
540        let (_guard, _lock) = test_guard();
541        ensure_test_resolver();
542
543        let temp = tempdir().expect("tempdir");
544        let _guard = DirGuard::new();
545        env::set_current_dir(temp.path()).expect("set temp");
546
547        File::create("script.m").expect("create m-file");
548        File::create("fastfft.mexw64").expect("create mex");
549
550        let script =
551            exist_builtin(Value::from("script"), vec![Value::from("file")]).expect("exist");
552        assert_eq!(script, Value::Num(2.0));
553
554        let mex = exist_builtin(Value::from("fastfft"), vec![Value::from("file")]).expect("exist");
555        assert_eq!(mex, Value::Num(3.0));
556
557        let mex_specific =
558            exist_builtin(Value::from("fastfft"), vec![Value::from("mex")]).expect("exist");
559        assert_eq!(mex_specific, Value::Num(3.0));
560    }
561
562    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
563    #[test]
564    fn exist_detects_directories() {
565        let (_guard, _lock) = test_guard();
566
567        let temp = tempdir().expect("tempdir");
568        let _guard = DirGuard::new();
569        env::set_current_dir(temp.path()).expect("set temp");
570        futures::executor::block_on(vfs::create_dir_async("data")).expect("mkdir data");
571
572        let dir = exist_builtin(Value::from("data"), vec![Value::from("dir")]).expect("exist");
573        assert_eq!(dir, Value::Num(7.0));
574
575        let any = exist_builtin(Value::from("data"), Vec::new()).expect("exist");
576        assert_eq!(any, Value::Num(7.0));
577    }
578
579    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
580    #[test]
581    fn exist_detects_class_files_and_packages() {
582        let (_guard, _lock) = test_guard();
583
584        let temp = tempdir().expect("tempdir");
585        let _guard = DirGuard::new();
586        env::set_current_dir(temp.path()).expect("set temp");
587
588        let mut file = File::create("Widget.m").expect("create class file");
589        writeln!(
590            file,
591            "classdef Widget\n    methods\n        function obj = Widget()\n        end\n    end\nend"
592        )
593        .expect("write classdef");
594
595        futures::executor::block_on(vfs::create_dir_all_async("+pkg/@Gizmo"))
596            .expect("create package class folder");
597
598        let widget =
599            exist_builtin(Value::from("Widget"), vec![Value::from("class")]).expect("exist");
600        assert_eq!(widget, Value::Num(8.0));
601
602        let gizmo =
603            exist_builtin(Value::from("pkg.Gizmo"), vec![Value::from("class")]).expect("exist pkg");
604        assert_eq!(gizmo, Value::Num(8.0));
605    }
606
607    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
608    #[test]
609    fn exist_invalid_type_raises_error() {
610        let (_guard, _lock) = test_guard();
611
612        let err = exist_builtin(Value::from("foo"), vec![Value::from("unknown")])
613            .expect_err("expected error");
614        assert_eq!(err.message(), ERROR_INVALID_TYPE);
615    }
616
617    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
618    #[test]
619    fn exist_errors_on_non_text_name() {
620        let (_guard, _lock) = test_guard();
621
622        let err = exist_builtin(Value::Num(5.0), Vec::new()).expect_err("expected error");
623        assert_eq!(err.message(), ERROR_NAME_ARG);
624    }
625
626    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
627    #[test]
628    fn exist_handle_returns_zero_for_non_handle() {
629        let (_guard, _lock) = test_guard();
630
631        let value =
632            exist_builtin(Value::Num(17.0), vec![Value::from("handle")]).expect("exist handle");
633        assert_eq!(value, Value::Num(0.0));
634    }
635}