use runmat_filesystem as vfs;
use std::io;
use std::path::{Path, PathBuf};
use glob::{Pattern, PatternError};
use runmat_builtins::{CellArray, CharArray, StringArray, Value};
use runmat_macros::runtime_builtin;
use crate::builtins::common::fs::{contains_wildcards, expand_user_path, path_to_string};
use crate::builtins::common::spec::{
BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
ReductionNaN, ResidencyPolicy, ShapeRequirements,
};
use crate::{build_runtime_error, gather_if_needed_async, BuiltinResult, RuntimeError};
const MESSAGE_ID_FILE_NOT_FOUND: &str = "RunMat:DELETE:FileNotFound";
const MESSAGE_ID_IS_DIRECTORY: &str = "RunMat:delete:Directories";
const MESSAGE_ID_OS_ERROR: &str = "RunMat:DELETE:PermissionDenied";
const MESSAGE_ID_INVALID_PATTERN: &str = "RunMat:delete:InvalidPattern";
const MESSAGE_ID_INVALID_INPUT: &str = "RunMat:delete:InvalidInput";
const MESSAGE_ID_EMPTY_FILENAME: &str = "RunMat:delete:EmptyFilename";
const MESSAGE_ID_INVALID_HANDLE: &str = "RunMat:delete:InvalidHandle";
const ERR_FILENAME_ARG: &str =
"delete: filename must be a character vector, string scalar, string array, or cell array of character vectors";
#[runmat_macros::register_gpu_spec(builtin_path = "crate::builtins::io::repl_fs::delete")]
pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
name: "delete",
op_kind: GpuOpKind::Custom("io"),
supported_precisions: &[],
broadcast: BroadcastSemantics::None,
provider_hooks: &[],
constant_strategy: ConstantStrategy::InlineLiteral,
residency: ResidencyPolicy::GatherImmediately,
nan_mode: ReductionNaN::Include,
two_pass_threshold: None,
workgroup_size: None,
accepts_nan_mode: false,
notes:
"Host-only filesystem operation. GPU-resident path values are gathered automatically before deletion.",
};
#[runmat_macros::register_fusion_spec(builtin_path = "crate::builtins::io::repl_fs::delete")]
pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
name: "delete",
shape: ShapeRequirements::Any,
constant_strategy: ConstantStrategy::InlineLiteral,
elementwise: None,
reduction: None,
emits_nan: false,
notes:
"Filesystem side-effects are executed immediately; metadata registered for completeness.",
};
const BUILTIN_NAME: &str = "delete";
fn delete_error(message_id: &'static str, message: impl Into<String>) -> RuntimeError {
build_runtime_error(message)
.with_builtin(BUILTIN_NAME)
.with_identifier(message_id)
.build()
}
fn map_control_flow(err: RuntimeError) -> RuntimeError {
let identifier = err.identifier().map(str::to_string);
let mut builder = build_runtime_error(format!("{BUILTIN_NAME}: {}", err.message()))
.with_builtin(BUILTIN_NAME)
.with_source(err);
if let Some(identifier) = identifier {
builder = builder.with_identifier(identifier);
}
builder.build()
}
#[runtime_builtin(
name = "delete",
category = "io/repl_fs",
summary = "Remove files using MATLAB-compatible wildcard expansion, array inputs, and error diagnostics.",
keywords = "delete,remove file,wildcard delete,cleanup,temporary files,MATLAB delete",
accel = "cpu",
sink = true,
suppress_auto_output = true,
type_resolver(crate::builtins::io::type_resolvers::delete_type),
builtin_path = "crate::builtins::io::repl_fs::delete"
)]
async fn delete_builtin(args: Vec<Value>) -> crate::BuiltinResult<Value> {
if args.is_empty() {
return Err(delete_error(
MESSAGE_ID_INVALID_INPUT,
"delete: missing filename input",
));
}
let gathered = gather_arguments(&args).await?;
if gathered.iter().all(is_handle_input) {
return delete_handles(&gathered);
}
if gathered.iter().any(contains_handle_input) {
return Err(delete_error(
MESSAGE_ID_INVALID_HANDLE,
"delete: cannot mix handle and filename inputs",
));
}
let mut raw_targets = Vec::new();
for value in &gathered {
collect_targets(value, &mut raw_targets)?;
}
if raw_targets.is_empty() {
return Ok(Value::Num(0.0));
}
for raw in raw_targets {
delete_target(&raw).await?;
}
Ok(Value::Num(0.0))
}
async fn delete_target(raw: &str) -> BuiltinResult<()> {
let expanded = expand_user_path(raw, "delete")
.map_err(|msg| delete_error(MESSAGE_ID_INVALID_INPUT, msg))?;
if expanded.is_empty() {
return Err(delete_error(
MESSAGE_ID_EMPTY_FILENAME,
"delete: filename cannot be empty",
));
}
if contains_wildcards(&expanded) {
delete_with_pattern(&expanded, raw).await
} else {
delete_single_path_async(&PathBuf::from(&expanded), raw).await
}
}
async fn delete_with_pattern(pattern: &str, display: &str) -> BuiltinResult<()> {
validate_wildcard_pattern(pattern, display)?;
if let Err(PatternError { msg, .. }) = Pattern::new(pattern) {
return Err(delete_error(
MESSAGE_ID_INVALID_PATTERN,
format!("delete: invalid wildcard pattern '{display}' ({msg})"),
));
}
let paths = match glob::glob(pattern) {
Ok(iter) => iter,
Err(PatternError { msg, .. }) => {
return Err(delete_error(
MESSAGE_ID_INVALID_PATTERN,
format!("delete: invalid wildcard pattern '{display}' ({msg})"),
))
}
};
let mut matches = Vec::new();
for entry in paths {
match entry {
Ok(path) => matches.push(path),
Err(err) => {
let problem_path = path_to_string(err.path());
return Err(delete_error(
MESSAGE_ID_OS_ERROR,
format!(
"delete: unable to delete '{}' ({})",
problem_path,
err.error()
),
));
}
}
}
if matches.is_empty() {
return Err(delete_error(
MESSAGE_ID_FILE_NOT_FOUND,
format!(
"delete: cannot delete '{}' because it does not exist",
display
),
));
}
for path in matches {
let display_path = path_to_string(&path);
delete_single_path_async(&path, &display_path).await?;
}
Ok(())
}
async fn delete_single_path_async(path: &Path, display: &str) -> BuiltinResult<()> {
match vfs::metadata_async(path).await {
Ok(meta) => {
if meta.is_dir() {
return Err(delete_error(
MESSAGE_ID_IS_DIRECTORY,
format!(
"delete: cannot delete '{}' because it is a directory (use rmdir instead)",
display
),
));
}
vfs::remove_file_async(path).await.map_err(|err| {
delete_error(
MESSAGE_ID_OS_ERROR,
format!("delete: unable to delete '{}' ({})", display, err),
)
})
}
Err(err) => {
if err.kind() == io::ErrorKind::NotFound {
Err(delete_error(
MESSAGE_ID_FILE_NOT_FOUND,
format!(
"delete: cannot delete '{}' because it does not exist",
display
),
))
} else {
Err(delete_error(
MESSAGE_ID_OS_ERROR,
format!("delete: unable to delete '{}' ({})", display, err),
))
}
}
}
}
#[cfg(test)]
fn delete_single_path(path: &Path, display: &str) -> BuiltinResult<()> {
futures::executor::block_on(delete_single_path_async(path, display))
}
fn validate_wildcard_pattern(pattern: &str, display: &str) -> BuiltinResult<()> {
if has_unbalanced(pattern, '[', ']') || has_unbalanced(pattern, '{', '}') {
return Err(delete_error(
MESSAGE_ID_INVALID_PATTERN,
format!("delete: invalid wildcard pattern '{display}'"),
));
}
Ok(())
}
fn has_unbalanced(pattern: &str, open: char, close: char) -> bool {
let mut depth = 0usize;
let mut chars = pattern.chars();
while let Some(ch) = chars.next() {
if ch == '\\' {
let _ = chars.next();
continue;
}
if ch == open {
depth += 1;
} else if ch == close {
if depth == 0 {
return true;
}
depth -= 1;
}
}
depth != 0
}
async fn gather_arguments(args: &[Value]) -> BuiltinResult<Vec<Value>> {
let mut out = Vec::with_capacity(args.len());
for value in args {
out.push(
gather_if_needed_async(value)
.await
.map_err(map_control_flow)?,
);
}
Ok(out)
}
fn collect_targets(value: &Value, targets: &mut Vec<String>) -> BuiltinResult<()> {
match value {
Value::String(text) => push_nonempty_target(text, targets),
Value::CharArray(array) => collect_char_array_targets(array, targets),
Value::StringArray(array) => collect_string_array_targets(array, targets),
Value::Cell(cell) => collect_cell_targets(cell, targets),
_ => Err(delete_error(MESSAGE_ID_INVALID_INPUT, ERR_FILENAME_ARG)),
}
}
fn collect_char_array_targets(array: &CharArray, targets: &mut Vec<String>) -> BuiltinResult<()> {
if array.rows == 0 || array.cols == 0 {
return Ok(());
}
for row in 0..array.rows {
let mut text = String::with_capacity(array.cols);
for col in 0..array.cols {
text.push(array.data[row * array.cols + col]);
}
let trimmed = text.trim_end().to_string();
if trimmed.is_empty() {
return Err(delete_error(
MESSAGE_ID_EMPTY_FILENAME,
"delete: filename cannot be empty",
));
}
targets.push(trimmed);
}
Ok(())
}
fn collect_string_array_targets(
array: &StringArray,
targets: &mut Vec<String>,
) -> BuiltinResult<()> {
for text in &array.data {
if text.is_empty() {
return Err(delete_error(
MESSAGE_ID_EMPTY_FILENAME,
"delete: filename cannot be empty",
));
}
targets.push(text.clone());
}
Ok(())
}
fn collect_cell_targets(cell: &CellArray, targets: &mut Vec<String>) -> BuiltinResult<()> {
for handle in &cell.data {
let value = unsafe { &*handle.as_raw() };
collect_targets(value, targets)?;
}
Ok(())
}
fn delete_handles(values: &[Value]) -> BuiltinResult<Value> {
let mut mutated_last: Option<Value> = None;
let mut total = 0usize;
for value in values {
total += process_handle_value(value, &mut mutated_last)?;
}
if total == 1 {
Ok(mutated_last.unwrap_or(Value::Num(0.0)))
} else {
Ok(Value::Num(0.0))
}
}
fn process_handle_value(value: &Value, mutated_last: &mut Option<Value>) -> BuiltinResult<usize> {
match value {
Value::HandleObject(handle) => {
let mut invalid = handle.clone();
invalid.valid = false;
*mutated_last = Some(Value::HandleObject(invalid));
Ok(1)
}
Value::Listener(listener) => {
let mut invalid = listener.clone();
invalid.valid = false;
invalid.enabled = false;
*mutated_last = Some(Value::Listener(invalid));
Ok(1)
}
Value::Cell(cell) => {
let mut total = 0usize;
for handle in &cell.data {
let inner = unsafe { &*handle.as_raw() };
total += process_handle_value(inner, mutated_last)?;
}
Ok(total)
}
other => Err(delete_error(
MESSAGE_ID_INVALID_HANDLE,
format!("delete: unsupported handle input {other:?}"),
)),
}
}
fn is_handle_input(value: &Value) -> bool {
match value {
Value::HandleObject(_) | Value::Listener(_) => true,
Value::Cell(cell) => cell
.data
.iter()
.all(|ptr| is_handle_input(unsafe { &*ptr.as_raw() })),
_ => false,
}
}
fn contains_handle_input(value: &Value) -> bool {
match value {
Value::HandleObject(_) | Value::Listener(_) => true,
Value::Cell(cell) => cell
.data
.iter()
.any(|ptr| contains_handle_input(unsafe { &*ptr.as_raw() })),
_ => false,
}
}
fn push_nonempty_target(text: &str, targets: &mut Vec<String>) -> BuiltinResult<()> {
if text.is_empty() {
Err(delete_error(
MESSAGE_ID_EMPTY_FILENAME,
"delete: filename cannot be empty",
))
} else {
targets.push(text.to_string());
Ok(())
}
}
#[cfg(test)]
pub(crate) mod tests {
use super::super::REPL_FS_TEST_LOCK;
use super::*;
use runmat_builtins::{CharArray, StringArray, Value};
use std::fs::File;
use tempfile::tempdir;
fn delete_builtin(args: Vec<Value>) -> BuiltinResult<Value> {
futures::executor::block_on(super::delete_builtin(args))
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[test]
fn delete_removes_single_file() {
let _lock = REPL_FS_TEST_LOCK
.lock()
.unwrap_or_else(|poison| poison.into_inner());
let temp = tempdir().expect("temp dir");
let target = temp.path().join("single.txt");
File::create(&target).expect("create");
let result = delete_builtin(vec![Value::from(target.to_string_lossy().to_string())])
.expect("delete");
assert_eq!(result, Value::Num(0.0));
assert!(!target.exists());
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[test]
fn delete_removes_files_with_wildcard() {
let _lock = REPL_FS_TEST_LOCK
.lock()
.unwrap_or_else(|poison| poison.into_inner());
let temp = tempdir().expect("temp dir");
let file_a = temp.path().join("log-01.txt");
let file_b = temp.path().join("log-02.txt");
File::create(&file_a).expect("create a");
File::create(&file_b).expect("create b");
let pattern = temp.path().join("log-*.txt");
delete_builtin(vec![Value::from(pattern.to_string_lossy().to_string())]).expect("delete");
assert!(!file_a.exists());
assert!(!file_b.exists());
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[test]
fn delete_accepts_string_array() {
let _lock = REPL_FS_TEST_LOCK
.lock()
.unwrap_or_else(|poison| poison.into_inner());
let temp = tempdir().expect("temp dir");
let file_a = temp.path().join("stageA.dat");
let file_b = temp.path().join("stageB.dat");
File::create(&file_a).expect("create a");
File::create(&file_b).expect("create b");
let array = StringArray::new(
vec![
file_a.to_string_lossy().to_string(),
file_b.to_string_lossy().to_string(),
],
vec![2],
)
.expect("string array");
delete_builtin(vec![Value::StringArray(array)]).expect("delete");
assert!(!file_a.exists());
assert!(!file_b.exists());
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[test]
fn delete_accepts_char_array() {
let _lock = REPL_FS_TEST_LOCK
.lock()
.unwrap_or_else(|poison| poison.into_inner());
let temp = tempdir().expect("temp dir");
let paths: Vec<_> = ["stageA.tmp", "stageB.tmp"]
.into_iter()
.map(|name| temp.path().join(name))
.collect();
let path_strings: Vec<String> = paths
.iter()
.map(|p| p.to_string_lossy().to_string())
.collect();
let max_len = path_strings.iter().map(|s| s.len()).max().unwrap();
let mut data: Vec<char> = Vec::with_capacity(path_strings.len() * max_len);
for (path, path_string) in paths.iter().zip(path_strings.iter()) {
File::create(path).expect("create file");
let mut chars: Vec<char> = path_string.chars().collect();
while chars.len() < max_len {
chars.push(' ');
}
data.extend(&chars);
}
let char_array = CharArray::new(data, path_strings.len(), max_len).expect("char array");
delete_builtin(vec![Value::CharArray(char_array)]).expect("delete");
for path in paths {
assert!(!path.exists(), "{path:?} should be removed");
}
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[test]
fn delete_accepts_cell_array_of_paths() {
let _lock = REPL_FS_TEST_LOCK
.lock()
.unwrap_or_else(|poison| poison.into_inner());
let temp = tempdir().expect("temp dir");
let file_a = temp.path().join("cellA.dat");
let file_b = temp.path().join("cellB.dat");
File::create(&file_a).expect("create cellA");
File::create(&file_b).expect("create cellB");
let cell_value = crate::make_cell(
vec![
Value::from(file_a.to_string_lossy().to_string()),
Value::from(file_b.to_string_lossy().to_string()),
],
1,
2,
)
.expect("cell");
delete_builtin(vec![cell_value]).expect("delete");
assert!(!file_a.exists());
assert!(!file_b.exists());
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[test]
fn delete_empty_string_array_is_noop() {
let array = StringArray::new(Vec::<String>::new(), vec![0]).expect("empty array");
let result = delete_builtin(vec![Value::StringArray(array)]).expect("delete");
assert_eq!(result, Value::Num(0.0));
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[test]
fn delete_errors_on_empty_string_argument() {
let err = delete_builtin(vec![Value::from(String::new())]).expect_err("empty string");
assert_eq!(err.identifier(), Some(MESSAGE_ID_EMPTY_FILENAME));
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[test]
fn delete_errors_on_string_array_empty_element() {
let array =
StringArray::new(vec![String::new()], vec![1]).expect("single empty string element");
let err = delete_builtin(vec![Value::StringArray(array)]).expect_err("empty element");
assert_eq!(err.identifier(), Some(MESSAGE_ID_EMPTY_FILENAME));
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[test]
fn delete_errors_on_char_array_blank_row() {
let data = vec![' '; 4];
let char_array = CharArray::new(data, 1, 4).expect("char matrix");
let err = delete_builtin(vec![Value::CharArray(char_array)]).expect_err("blank row");
assert_eq!(err.identifier(), Some(MESSAGE_ID_EMPTY_FILENAME));
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[test]
fn delete_errors_on_invalid_pattern() {
let pattern = "{invalid*";
let err = futures::executor::block_on(delete_target(pattern))
.expect_err("invalid pattern should error");
assert_eq!(err.identifier(), Some(MESSAGE_ID_INVALID_PATTERN));
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[test]
fn delete_errors_on_missing_file() {
let _lock = REPL_FS_TEST_LOCK
.lock()
.unwrap_or_else(|poison| poison.into_inner());
let temp = tempdir().expect("temp dir");
let missing = temp.path().join("missing.txt");
let missing_str = missing.to_string_lossy().to_string();
let err = futures::executor::block_on(delete_target(&missing_str)).expect_err("error");
assert_eq!(err.identifier(), Some(MESSAGE_ID_FILE_NOT_FOUND));
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[test]
fn delete_errors_on_directory() {
let _lock = REPL_FS_TEST_LOCK
.lock()
.unwrap_or_else(|poison| poison.into_inner());
let temp = tempdir().expect("temp dir");
let dir = temp.path().join("dir");
std::fs::create_dir(&dir).expect("create dir");
let dir_display = dir.to_string_lossy().to_string();
let err = delete_single_path(&dir, &dir_display).expect_err("error");
assert_eq!(err.identifier(), Some(MESSAGE_ID_IS_DIRECTORY));
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[test]
fn delete_handle_returns_invalid_handle() {
let handle = futures::executor::block_on(crate::new_handle_object_builtin(
"ReplFsDeleteTestHandle".to_string(),
))
.expect("handle");
let result = delete_builtin(vec![handle]).expect("delete handle");
match result {
Value::HandleObject(h) => {
assert!(!h.valid, "handle should be marked invalid");
let valid_value = futures::executor::block_on(crate::isvalid_builtin(
Value::HandleObject(h.clone()),
))
.expect("isvalid");
match valid_value {
Value::Bool(flag) => assert!(!flag, "isvalid should report false after delete"),
other => panic!("expected bool from isvalid, got {other:?}"),
}
}
other => panic!("expected handle result, got {other:?}"),
}
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[test]
fn delete_rejects_mixed_handle_and_filename() {
let handle = futures::executor::block_on(crate::new_handle_object_builtin(
"ReplFsDeleteTestHandle".to_string(),
))
.expect("handle");
let err = delete_builtin(vec![
handle,
Value::from("mixed-handle-path.txt".to_string()),
])
.expect_err("expected mixed error");
assert_eq!(err.identifier(), Some(MESSAGE_ID_INVALID_HANDLE));
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[test]
fn delete_accepts_cell_of_handles() {
let handle_a = futures::executor::block_on(crate::new_handle_object_builtin(
"ReplFsDeleteTestHandle".to_string(),
))
.expect("handle");
let handle_b = futures::executor::block_on(crate::new_handle_object_builtin(
"ReplFsDeleteTestHandle".to_string(),
))
.expect("handle");
let cell = crate::make_cell(vec![handle_a, handle_b], 1, 2).expect("cell of handles");
let result = delete_builtin(vec![cell]).expect("delete handles");
assert_eq!(result, Value::Num(0.0));
}
#[cfg(feature = "wgpu")]
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[test]
fn delete_runs_with_wgpu_provider_registered() {
let _lock = REPL_FS_TEST_LOCK
.lock()
.unwrap_or_else(|poison| poison.into_inner());
let _ = runmat_accelerate::backend::wgpu::provider::register_wgpu_provider(
runmat_accelerate::backend::wgpu::provider::WgpuProviderOptions::default(),
);
let temp = tempdir().expect("temp dir");
let path = temp.path().join("wgpu-file.txt");
File::create(&path).expect("create file");
delete_builtin(vec![Value::from(path.to_string_lossy().to_string())]).expect("delete");
assert!(!path.exists());
}
}