Skip to main content

runmat_runtime/builtins/io/repl_fs/
run.rs

1//! MATLAB-compatible `run` builtin support.
2//!
3//! The runtime registry owns the descriptor and filesystem resolution helper.
4//! The VM owns execution because `run` mutates the caller workspace.
5
6use std::path::{Path, PathBuf};
7
8use runmat_builtins::{
9    BuiltinCompletionPolicy, BuiltinDescriptor, BuiltinErrorDescriptor, BuiltinOutputMode,
10    BuiltinParamArity, BuiltinParamDescriptor, BuiltinParamType, BuiltinSignatureDescriptor,
11};
12use runmat_macros::runtime_builtin;
13use runmat_types::RUN_BUILTIN_NAME;
14use runmat_value::Value;
15
16use crate::builtins::common::fs::path_to_string;
17use crate::builtins::common::path_search::{
18    file_candidates, find_file_with_extensions, path_is_file,
19};
20use crate::builtins::common::spec::{
21    BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
22    ReductionNaN, ResidencyPolicy, ShapeRequirements,
23};
24use crate::{build_runtime_error, gather_if_needed_async, BuiltinResult, RuntimeError};
25
26const RUN_SCRIPT_EXTENSIONS: &[&str] = &[".p", ".m"];
27
28const RUN_INPUTS: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
29    name: "script",
30    ty: BuiltinParamType::StringScalar,
31    arity: BuiltinParamArity::Required,
32    default: None,
33    description: "Script name or path to execute in the caller workspace.",
34}];
35
36const RUN_SIGNATURES: [BuiltinSignatureDescriptor; 1] = [BuiltinSignatureDescriptor {
37    label: "run(script)",
38    inputs: &RUN_INPUTS,
39    outputs: &[],
40}];
41
42pub const RUN_ERROR_REQUIRES_VM: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
43    code: "RM.RUN.REQUIRES_VM",
44    identifier: Some("RunMat:run:RequiresVm"),
45    when: "`run` is dispatched outside an active VM workspace frame.",
46    message: "run: requires VM workspace context",
47};
48
49pub const RUN_ERROR_ARG_TYPE: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
50    code: "RM.RUN.ARG_TYPE",
51    identifier: Some("RunMat:run:InvalidScriptArgument"),
52    when: "The script argument is not a character row, string scalar, or scalar string array.",
53    message: "run: script must be a character vector or string scalar",
54};
55
56pub const RUN_ERROR_EMPTY_SCRIPT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
57    code: "RM.RUN.EMPTY_SCRIPT",
58    identifier: Some("RunMat:run:EmptyScript"),
59    when: "The script argument is an empty path.",
60    message: "run: script path must not be empty",
61};
62
63pub const RUN_ERROR_PATH_RESOLVE: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
64    code: "RM.RUN.PATH_RESOLVE",
65    identifier: Some("RunMat:run:PathResolveFailed"),
66    when: "RunMat cannot resolve the current directory, home directory, or search path.",
67    message: "run: failed to resolve script path",
68};
69
70pub const RUN_ERROR_FILE_NOT_FOUND: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
71    code: "RM.RUN.FILE_NOT_FOUND",
72    identifier: Some("RunMat:run:FileNotFound"),
73    when: "No matching script file exists in the current directory or RunMat search path.",
74    message: "run: script file not found",
75};
76
77pub const RUN_ERROR_FILE_READ: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
78    code: "RM.RUN.FILE_READ",
79    identifier: Some("RunMat:run:FileReadFailed"),
80    when: "The matched script file cannot be read as source text.",
81    message: "run: failed to read script file",
82};
83
84pub const RUN_ERROR_TOO_MANY_OUTPUTS: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
85    code: "RM.RUN.TOO_MANY_OUTPUTS",
86    identifier: Some("RunMat:run:TooManyOutputs"),
87    when: "`run` is called with one or more requested output arguments.",
88    message: "run: too many output arguments",
89};
90
91pub const RUN_ERRORS: [BuiltinErrorDescriptor; 7] = [
92    RUN_ERROR_REQUIRES_VM,
93    RUN_ERROR_ARG_TYPE,
94    RUN_ERROR_EMPTY_SCRIPT,
95    RUN_ERROR_PATH_RESOLVE,
96    RUN_ERROR_FILE_NOT_FOUND,
97    RUN_ERROR_FILE_READ,
98    RUN_ERROR_TOO_MANY_OUTPUTS,
99];
100
101pub const RUN_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
102    signatures: &RUN_SIGNATURES,
103    output_mode: BuiltinOutputMode::Fixed,
104    completion_policy: BuiltinCompletionPolicy::Public,
105    errors: &RUN_ERRORS,
106};
107
108#[runmat_macros::register_gpu_spec(builtin_path = "crate::builtins::io::repl_fs::run")]
109pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
110    name: "run",
111    op_kind: GpuOpKind::Custom("io"),
112    supported_precisions: &[],
113    broadcast: BroadcastSemantics::None,
114    provider_hooks: &[],
115    constant_strategy: ConstantStrategy::InlineLiteral,
116    residency: ResidencyPolicy::GatherImmediately,
117    nan_mode: ReductionNaN::Include,
118    two_pass_threshold: None,
119    workgroup_size: None,
120    accepts_nan_mode: false,
121    notes: "Script resolution and execution run on the host. GPU-resident script path arguments are gathered before lookup.",
122};
123
124#[runmat_macros::register_fusion_spec(builtin_path = "crate::builtins::io::repl_fs::run")]
125pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
126    name: "run",
127    shape: ShapeRequirements::Any,
128    constant_strategy: ConstantStrategy::InlineLiteral,
129    elementwise: None,
130    reduction: None,
131    emits_nan: false,
132    notes: "Script execution mutates the workspace and is a fusion barrier.",
133};
134
135#[derive(Debug, Clone)]
136pub struct RunScriptSource {
137    pub path: PathBuf,
138    pub display_name: String,
139    pub text: String,
140}
141
142fn run_error(error: &'static BuiltinErrorDescriptor) -> RuntimeError {
143    run_error_with_detail(error, "")
144}
145
146fn run_error_with_detail(
147    error: &'static BuiltinErrorDescriptor,
148    detail: impl AsRef<str>,
149) -> RuntimeError {
150    let detail = detail.as_ref();
151    let message = if detail.is_empty() {
152        error.message.to_string()
153    } else {
154        format!("{}: {detail}", error.message)
155    };
156    let mut builder = build_runtime_error(message).with_builtin(RUN_BUILTIN_NAME);
157    if let Some(identifier) = error.identifier {
158        builder = builder.with_identifier(identifier);
159    }
160    builder.build()
161}
162
163fn run_flow(err: RuntimeError) -> RuntimeError {
164    let identifier = err.identifier().map(str::to_string);
165    let mut builder = build_runtime_error(err.message().to_string())
166        .with_builtin(RUN_BUILTIN_NAME)
167        .with_source(err);
168    if let Some(identifier) = identifier {
169        builder = builder.with_identifier(identifier);
170    }
171    builder.build()
172}
173
174fn value_to_string_scalar(value: &Value) -> Option<String> {
175    match value {
176        Value::String(text) => Some(text.clone()),
177        Value::CharArray(array) if array.rows == 1 => Some(array.data.iter().collect()),
178        Value::StringArray(array) if array.data.len() == 1 => Some(array.data[0].clone()),
179        _ => None,
180    }
181}
182
183pub fn requires_vm_workspace_context() -> crate::BuiltinResult<Value> {
184    Err(run_error(&RUN_ERROR_REQUIRES_VM))
185}
186
187pub fn too_many_outputs_error() -> RuntimeError {
188    run_error(&RUN_ERROR_TOO_MANY_OUTPUTS)
189}
190
191fn bare_run_file_stem(script: &str) -> Option<&str> {
192    if script.starts_with('~')
193        || script.starts_with('@')
194        || script.starts_with('+')
195        || script.contains('/')
196        || script.contains('\\')
197    {
198        return None;
199    }
200    let path = Path::new(script);
201    if path.components().count() != 1 {
202        return None;
203    }
204    let extension = path.extension()?.to_str()?;
205    if !extension.eq_ignore_ascii_case("m") && !extension.eq_ignore_ascii_case("p") {
206        return None;
207    }
208    path.file_stem()?.to_str().filter(|stem| !stem.is_empty())
209}
210
211async fn find_run_script(script: &str) -> Result<Option<PathBuf>, String> {
212    if let Some(path) =
213        find_file_with_extensions(script, RUN_SCRIPT_EXTENSIONS, RUN_BUILTIN_NAME).await?
214    {
215        let path = crate::builtins::io::repl_fs::pcode::prefer_pcode_source_path(&path).await;
216        return Ok(Some(path));
217    }
218
219    let Some(stem) = bare_run_file_stem(script) else {
220        return Ok(None);
221    };
222    for candidate in file_candidates(stem, RUN_SCRIPT_EXTENSIONS, RUN_BUILTIN_NAME)? {
223        if candidate
224            .extension()
225            .and_then(|extension| extension.to_str())
226            .is_some_and(|extension| {
227                extension.eq_ignore_ascii_case("p") || extension.eq_ignore_ascii_case("m")
228            })
229            && path_is_file(&candidate).await
230        {
231            let candidate =
232                crate::builtins::io::repl_fs::pcode::prefer_pcode_source_path(&candidate).await;
233            return Ok(Some(candidate));
234        }
235    }
236    Ok(None)
237}
238
239pub async fn resolve_run_source(value: &Value) -> BuiltinResult<RunScriptSource> {
240    let value = gather_if_needed_async(value).await.map_err(run_flow)?;
241    let script = value_to_string_scalar(&value).ok_or_else(|| run_error(&RUN_ERROR_ARG_TYPE))?;
242    if script.is_empty() {
243        return Err(run_error(&RUN_ERROR_EMPTY_SCRIPT));
244    }
245
246    let path = find_run_script(&script)
247        .await
248        .map_err(|err| run_error_with_detail(&RUN_ERROR_PATH_RESOLVE, err))?
249        .ok_or_else(|| run_error_with_detail(&RUN_ERROR_FILE_NOT_FOUND, format!("'{script}'")))?;
250
251    let text = match crate::builtins::io::repl_fs::pcode::read_source_text_async(&path).await {
252        Ok(text) => text,
253        Err(crate::builtins::io::repl_fs::pcode::PcodeSourceReadError::InvalidPcode(err)) => {
254            return Err(
255                crate::builtins::io::repl_fs::pcode::invalid_pcode_runtime_error(format!(
256                    "{} ({err})",
257                    path.display()
258                )),
259            );
260        }
261        Err(crate::builtins::io::repl_fs::pcode::PcodeSourceReadError::Io(err)) => {
262            return Err(run_error_with_detail(
263                &RUN_ERROR_FILE_READ,
264                format!("{} ({err})", path.display()),
265            ));
266        }
267    };
268
269    let display_path = runmat_filesystem::canonicalize_async(&path)
270        .await
271        .unwrap_or_else(|_| path.clone());
272    Ok(RunScriptSource {
273        path: display_path.clone(),
274        display_name: path_to_string(&display_path),
275        text,
276    })
277}
278
279#[runtime_builtin(
280    name = "run",
281    category = "io/repl_fs",
282    summary = "Execute a script file in the caller workspace.",
283    keywords = "run,script,file,path,workspace",
284    sink = true,
285    suppress_auto_output = true,
286    accel = "cpu",
287    type_resolver(crate::builtins::io::type_resolvers::run_type),
288    descriptor(crate::builtins::io::repl_fs::run::RUN_DESCRIPTOR),
289    builtin_path = "crate::builtins::io::repl_fs::run"
290)]
291pub fn run_builtin_registered(_args: Vec<Value>) -> crate::BuiltinResult<Value> {
292    requires_vm_workspace_context()
293}
294
295#[cfg(test)]
296mod tests {
297    use super::*;
298    use crate::builtins::common::path_state::{current_path_string, set_path_string};
299    use crate::builtins::io::repl_fs::REPL_FS_TEST_LOCK;
300    use futures::executor::block_on;
301    use runmat_value::CharArray;
302    use std::env;
303    use std::path::{Path, PathBuf};
304
305    struct CwdGuard {
306        original: PathBuf,
307    }
308
309    struct PathStateGuard {
310        previous: String,
311    }
312
313    impl Drop for PathStateGuard {
314        fn drop(&mut self) {
315            set_path_string(&self.previous);
316        }
317    }
318
319    impl Drop for CwdGuard {
320        fn drop(&mut self) {
321            let _ = env::set_current_dir(&self.original);
322        }
323    }
324
325    fn push_cwd(path: &Path) -> CwdGuard {
326        let original = env::current_dir().expect("current dir");
327        env::set_current_dir(path).expect("set current dir");
328        CwdGuard { original }
329    }
330
331    fn push_path_state(path: &Path) -> PathStateGuard {
332        let previous = current_path_string();
333        let path = path.to_string_lossy().to_string();
334        set_path_string(&path);
335        PathStateGuard { previous }
336    }
337
338    #[test]
339    fn runtime_fallback_requires_vm_context() {
340        let err = run_builtin_registered(Vec::new()).expect_err("run fallback should fail");
341        assert_eq!(err.identifier(), Some("RunMat:run:RequiresVm"));
342    }
343
344    #[test]
345    fn resolves_script_from_current_directory_with_implicit_m_extension() {
346        let _lock = REPL_FS_TEST_LOCK.lock().unwrap();
347        let temp = tempfile::TempDir::new().expect("tempdir");
348        std::fs::write(temp.path().join("worker.m"), "generated = 41;\n").expect("write script");
349        let _cwd = push_cwd(temp.path());
350
351        let source = block_on(resolve_run_source(&Value::from("worker"))).expect("resolve source");
352        assert!(source.display_name.ends_with("worker.m"));
353        assert_eq!(source.text, "generated = 41;\n");
354    }
355
356    #[test]
357    fn resolves_bare_m_filename_from_search_path() {
358        let _lock = REPL_FS_TEST_LOCK.lock().unwrap();
359        let temp = tempfile::TempDir::new().expect("tempdir");
360        let scripts = temp.path().join("scripts");
361        std::fs::create_dir_all(&scripts).expect("create scripts dir");
362        std::fs::write(scripts.join("path_worker.m"), "path_value = 17;\n").expect("write script");
363        let _cwd = push_cwd(temp.path());
364        let _path = push_path_state(&scripts);
365
366        let source =
367            block_on(resolve_run_source(&Value::from("path_worker.m"))).expect("resolve source");
368        assert!(source.display_name.ends_with("path_worker.m"));
369        assert_eq!(source.text, "path_value = 17;\n");
370    }
371
372    #[test]
373    fn resolves_script_from_char_row_path() {
374        let _lock = REPL_FS_TEST_LOCK.lock().unwrap();
375        let temp = tempfile::TempDir::new().expect("tempdir");
376        let path = temp.path().join("direct_script.m");
377        std::fs::write(&path, "x = 1;\n").expect("write script");
378
379        let value = Value::CharArray(CharArray::new_row(path.to_string_lossy().as_ref()));
380        let source = block_on(resolve_run_source(&value)).expect("resolve source");
381        assert_eq!(source.text, "x = 1;\n");
382        assert!(source.path.ends_with("direct_script.m"));
383    }
384
385    #[test]
386    fn missing_script_reports_stable_identifier() {
387        let _lock = REPL_FS_TEST_LOCK.lock().unwrap();
388        let temp = tempfile::TempDir::new().expect("tempdir");
389        let _cwd = push_cwd(temp.path());
390
391        let err = block_on(resolve_run_source(&Value::from("missing_script")))
392            .expect_err("missing script should fail");
393        assert_eq!(err.identifier(), Some("RunMat:run:FileNotFound"));
394    }
395
396    #[test]
397    fn invalid_script_argument_reports_stable_identifier() {
398        let err =
399            block_on(resolve_run_source(&Value::Num(1.0))).expect_err("numeric script should fail");
400        assert_eq!(err.identifier(), Some("RunMat:run:InvalidScriptArgument"));
401    }
402}