runmat_runtime/builtins/io/repl_fs/
run.rs1use 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] = &[".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_m_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") {
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 return Ok(Some(path));
215 }
216
217 let Some(stem) = bare_m_file_stem(script) else {
218 return Ok(None);
219 };
220 for candidate in file_candidates(stem, RUN_SCRIPT_EXTENSIONS, RUN_BUILTIN_NAME)? {
221 if candidate
222 .extension()
223 .and_then(|extension| extension.to_str())
224 .is_some_and(|extension| extension.eq_ignore_ascii_case("m"))
225 && path_is_file(&candidate).await
226 {
227 return Ok(Some(candidate));
228 }
229 }
230 Ok(None)
231}
232
233pub async fn resolve_run_source(value: &Value) -> BuiltinResult<RunScriptSource> {
234 let value = gather_if_needed_async(value).await.map_err(run_flow)?;
235 let script = value_to_string_scalar(&value).ok_or_else(|| run_error(&RUN_ERROR_ARG_TYPE))?;
236 if script.is_empty() {
237 return Err(run_error(&RUN_ERROR_EMPTY_SCRIPT));
238 }
239
240 let path = find_run_script(&script)
241 .await
242 .map_err(|err| run_error_with_detail(&RUN_ERROR_PATH_RESOLVE, err))?
243 .ok_or_else(|| run_error_with_detail(&RUN_ERROR_FILE_NOT_FOUND, format!("'{script}'")))?;
244
245 let text = runmat_filesystem::read_to_string_async(&path)
246 .await
247 .map_err(|err| {
248 run_error_with_detail(&RUN_ERROR_FILE_READ, format!("{} ({err})", path.display()))
249 })?;
250
251 let display_path = runmat_filesystem::canonicalize_async(&path)
252 .await
253 .unwrap_or_else(|_| path.clone());
254 Ok(RunScriptSource {
255 path: display_path.clone(),
256 display_name: path_to_string(&display_path),
257 text,
258 })
259}
260
261#[runtime_builtin(
262 name = "run",
263 category = "io/repl_fs",
264 summary = "Execute a script file in the caller workspace.",
265 keywords = "run,script,file,path,workspace",
266 sink = true,
267 suppress_auto_output = true,
268 accel = "cpu",
269 type_resolver(crate::builtins::io::type_resolvers::run_type),
270 descriptor(crate::builtins::io::repl_fs::run::RUN_DESCRIPTOR),
271 builtin_path = "crate::builtins::io::repl_fs::run"
272)]
273pub fn run_builtin_registered(_args: Vec<Value>) -> crate::BuiltinResult<Value> {
274 requires_vm_workspace_context()
275}
276
277#[cfg(test)]
278mod tests {
279 use super::*;
280 use crate::builtins::common::path_state::{current_path_string, set_path_string};
281 use crate::builtins::io::repl_fs::REPL_FS_TEST_LOCK;
282 use futures::executor::block_on;
283 use runmat_builtins::CharArray;
284 use std::env;
285 use std::path::{Path, PathBuf};
286
287 struct CwdGuard {
288 original: PathBuf,
289 }
290
291 struct PathStateGuard {
292 previous: String,
293 }
294
295 impl Drop for PathStateGuard {
296 fn drop(&mut self) {
297 set_path_string(&self.previous);
298 }
299 }
300
301 impl Drop for CwdGuard {
302 fn drop(&mut self) {
303 let _ = env::set_current_dir(&self.original);
304 }
305 }
306
307 fn push_cwd(path: &Path) -> CwdGuard {
308 let original = env::current_dir().expect("current dir");
309 env::set_current_dir(path).expect("set current dir");
310 CwdGuard { original }
311 }
312
313 fn push_path_state(path: &Path) -> PathStateGuard {
314 let previous = current_path_string();
315 let path = path.to_string_lossy().to_string();
316 set_path_string(&path);
317 PathStateGuard { previous }
318 }
319
320 #[test]
321 fn runtime_fallback_requires_vm_context() {
322 let err = run_builtin_registered(Vec::new()).expect_err("run fallback should fail");
323 assert_eq!(err.identifier(), Some("RunMat:run:RequiresVm"));
324 }
325
326 #[test]
327 fn resolves_script_from_current_directory_with_implicit_m_extension() {
328 let _lock = REPL_FS_TEST_LOCK.lock().unwrap();
329 let temp = tempfile::TempDir::new().expect("tempdir");
330 std::fs::write(temp.path().join("worker.m"), "generated = 41;\n").expect("write script");
331 let _cwd = push_cwd(temp.path());
332
333 let source = block_on(resolve_run_source(&Value::from("worker"))).expect("resolve source");
334 assert!(source.display_name.ends_with("worker.m"));
335 assert_eq!(source.text, "generated = 41;\n");
336 }
337
338 #[test]
339 fn resolves_bare_m_filename_from_search_path() {
340 let _lock = REPL_FS_TEST_LOCK.lock().unwrap();
341 let temp = tempfile::TempDir::new().expect("tempdir");
342 let scripts = temp.path().join("scripts");
343 std::fs::create_dir_all(&scripts).expect("create scripts dir");
344 std::fs::write(scripts.join("path_worker.m"), "path_value = 17;\n").expect("write script");
345 let _cwd = push_cwd(temp.path());
346 let _path = push_path_state(&scripts);
347
348 let source =
349 block_on(resolve_run_source(&Value::from("path_worker.m"))).expect("resolve source");
350 assert!(source.display_name.ends_with("path_worker.m"));
351 assert_eq!(source.text, "path_value = 17;\n");
352 }
353
354 #[test]
355 fn resolves_script_from_char_row_path() {
356 let _lock = REPL_FS_TEST_LOCK.lock().unwrap();
357 let temp = tempfile::TempDir::new().expect("tempdir");
358 let path = temp.path().join("direct_script.m");
359 std::fs::write(&path, "x = 1;\n").expect("write script");
360
361 let value = Value::CharArray(CharArray::new_row(path.to_string_lossy().as_ref()));
362 let source = block_on(resolve_run_source(&value)).expect("resolve source");
363 assert_eq!(source.text, "x = 1;\n");
364 assert!(source.path.ends_with("direct_script.m"));
365 }
366
367 #[test]
368 fn missing_script_reports_stable_identifier() {
369 let _lock = REPL_FS_TEST_LOCK.lock().unwrap();
370 let temp = tempfile::TempDir::new().expect("tempdir");
371 let _cwd = push_cwd(temp.path());
372
373 let err = block_on(resolve_run_source(&Value::from("missing_script")))
374 .expect_err("missing script should fail");
375 assert_eq!(err.identifier(), Some("RunMat:run:FileNotFound"));
376 }
377
378 #[test]
379 fn invalid_script_argument_reports_stable_identifier() {
380 let err =
381 block_on(resolve_run_source(&Value::Num(1.0))).expect_err("numeric script should fail");
382 assert_eq!(err.identifier(), Some("RunMat:run:InvalidScriptArgument"));
383 }
384}