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