use runmat_builtins::{CharArray, StringArray, Tensor, Value};
use runmat_macros::runtime_builtin;
use crate::builtins::common::fs::{expand_user_path, path_to_string};
use crate::builtins::common::path_state::{
current_path_segments, current_path_string, set_path_string, PATH_LIST_SEPARATOR,
};
use crate::builtins::common::spec::{
BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
ReductionNaN, ResidencyPolicy, ShapeRequirements,
};
use crate::{build_runtime_error, gather_if_needed_async, BuiltinResult, RuntimeError};
use runmat_filesystem as vfs;
use std::collections::HashSet;
use std::path::{Component, Path, PathBuf};
const ERROR_ARG_TYPE: &str =
"rmpath: folder names must be character vectors, string scalars, string arrays, or cell arrays of character vectors";
const ERROR_TOO_FEW_ARGS: &str = "rmpath: at least one folder must be specified";
#[runmat_macros::register_gpu_spec(builtin_path = "crate::builtins::io::repl_fs::rmpath")]
pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
name: "rmpath",
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: "Search-path manipulation is a host-only operation; GPU inputs are gathered before processing.",
};
#[runmat_macros::register_fusion_spec(builtin_path = "crate::builtins::io::repl_fs::rmpath")]
pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
name: "rmpath",
shape: ShapeRequirements::Any,
constant_strategy: ConstantStrategy::InlineLiteral,
elementwise: None,
reduction: None,
emits_nan: false,
notes: "IO builtins are not eligible for fusion; metadata registered for completeness.",
};
const BUILTIN_NAME: &str = "rmpath";
fn rmpath_error(message: impl Into<String>) -> RuntimeError {
build_runtime_error(message)
.with_builtin(BUILTIN_NAME)
.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 = "rmpath",
category = "io/repl_fs",
summary = "Remove folders from the MATLAB search path used by RunMat.",
keywords = "rmpath,search path,matlab path,remove folder",
accel = "cpu",
suppress_auto_output = true,
type_resolver(crate::builtins::io::type_resolvers::rmpath_type),
builtin_path = "crate::builtins::io::repl_fs::rmpath"
)]
async fn rmpath_builtin(args: Vec<Value>) -> crate::BuiltinResult<Value> {
if args.is_empty() {
return Err(rmpath_error(ERROR_TOO_FEW_ARGS));
}
let gathered = gather_arguments(&args).await?;
let directories = parse_directories(&gathered).await?;
let previous = current_path_string();
apply_rmpath(directories).await?;
Ok(char_array_value(&previous))
}
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)
}
async fn parse_directories(args: &[Value]) -> BuiltinResult<Vec<String>> {
let mut directories = Vec::new();
for value in args {
collect_strings(value, &mut directories).await?;
}
if directories.is_empty() {
return Err(rmpath_error(ERROR_TOO_FEW_ARGS));
}
let mut resolved = Vec::new();
for token in directories {
let trimmed = token.trim();
if trimmed.is_empty() {
continue;
}
resolved.extend(split_path_list(trimmed));
}
if resolved.is_empty() {
return Err(rmpath_error(ERROR_TOO_FEW_ARGS));
}
Ok(resolved)
}
#[async_recursion::async_recursion(?Send)]
async fn collect_strings(value: &Value, output: &mut Vec<String>) -> BuiltinResult<()> {
match value {
Value::String(text) => {
output.push(text.clone());
Ok(())
}
Value::StringArray(StringArray { data, .. }) => {
for entry in data {
output.push(entry.clone());
}
Ok(())
}
Value::CharArray(chars) => {
if chars.rows == 1 {
output.push(chars.data.iter().collect());
return Ok(());
}
for row in 0..chars.rows {
let mut line = String::with_capacity(chars.cols);
for col in 0..chars.cols {
line.push(chars.data[row * chars.cols + col]);
}
output.push(line.trim_end().to_string());
}
Ok(())
}
Value::Tensor(tensor) => {
output.push(tensor_to_string(tensor)?);
Ok(())
}
Value::Cell(cell) => {
for ptr in &cell.data {
let inner = (**ptr).clone();
let gathered = gather_if_needed_async(&inner)
.await
.map_err(map_control_flow)?;
collect_strings(&gathered, output).await?;
}
Ok(())
}
Value::GpuTensor(_) => Err(rmpath_error(ERROR_ARG_TYPE)),
_ => Err(rmpath_error(ERROR_ARG_TYPE)),
}
}
fn split_path_list(text: &str) -> Vec<String> {
text.split(PATH_LIST_SEPARATOR)
.map(|segment| segment.trim())
.filter(|segment| !segment.is_empty())
.map(|segment| segment.to_string())
.collect()
}
async fn apply_rmpath(directories: Vec<String>) -> BuiltinResult<()> {
let mut segments = current_path_segments();
let mut changed = false;
let mut seen = HashSet::new();
for raw in directories {
let trimmed = raw.trim();
if trimmed.is_empty() {
continue;
}
let dedup_key = path_identity(trimmed);
if !seen.insert(dedup_key) {
continue;
}
if remove_directory(&mut segments, trimmed).await? {
changed = true;
}
}
if changed {
let new_path = if segments.is_empty() {
String::new()
} else {
join_segments(&segments)
};
set_path_string(&new_path);
}
Ok(())
}
async fn remove_directory(segments: &mut Vec<String>, raw: &str) -> BuiltinResult<bool> {
let direct_identity = path_identity(raw);
let before = segments.len();
segments.retain(|entry| path_identity(entry) != direct_identity);
if segments.len() != before {
return Ok(true);
}
let expanded = expand_user_path(raw, "rmpath").map_err(rmpath_error)?;
let path = Path::new(&expanded);
let joined = if path.is_absolute() {
path.to_path_buf()
} else {
vfs::current_dir()
.map_err(|_| rmpath_error("rmpath: unable to resolve current directory"))?
.join(path)
};
let normalized = normalize_pathbuf(&joined);
let canonical = path_to_string(&normalized);
let canonical_identity = path_identity(&canonical);
let before = segments.len();
segments.retain(|entry| path_identity(entry) != canonical_identity);
if segments.len() != before {
return Ok(true);
}
match vfs::metadata_async(&normalized).await {
Ok(meta) => {
if !meta.is_dir() {
Err(rmpath_error(format!("rmpath: '{raw}' is not a folder")))
} else {
Err(rmpath_error(format!(
"rmpath: folder '{raw}' not on search path"
)))
}
}
Err(_) => Err(rmpath_error(format!("rmpath: folder '{raw}' not found"))),
}
}
fn normalize_pathbuf(path: &Path) -> PathBuf {
let mut normalized = PathBuf::new();
for component in path.components() {
match component {
Component::Prefix(prefix) => normalized.push(prefix.as_os_str()),
Component::RootDir => normalized.push(component.as_os_str()),
Component::CurDir => {}
Component::ParentDir => {
normalized.pop();
}
Component::Normal(part) => normalized.push(part),
}
}
if normalized.as_os_str().is_empty() {
path.to_path_buf()
} else {
normalized
}
}
fn tensor_to_string(tensor: &Tensor) -> BuiltinResult<String> {
if tensor.shape.len() > 2 {
return Err(rmpath_error(ERROR_ARG_TYPE));
}
if tensor.rows() > 1 {
return Err(rmpath_error(ERROR_ARG_TYPE));
}
let mut text = String::with_capacity(tensor.data.len());
for &code in &tensor.data {
if !code.is_finite() {
return Err(rmpath_error(ERROR_ARG_TYPE));
}
let rounded = code.round();
if (code - rounded).abs() > 1e-6 {
return Err(rmpath_error(ERROR_ARG_TYPE));
}
let int_code = rounded as i64;
if !(0..=0x10FFFF).contains(&int_code) {
return Err(rmpath_error(ERROR_ARG_TYPE));
}
let ch = char::from_u32(int_code as u32).ok_or_else(|| rmpath_error(ERROR_ARG_TYPE))?;
text.push(ch);
}
Ok(text)
}
fn path_identity(path: &str) -> String {
#[cfg(windows)]
{
path.replace('/', "\\").to_ascii_lowercase()
}
#[cfg(not(windows))]
{
path.to_string()
}
}
fn join_segments(segments: &[String]) -> String {
let mut joined = String::new();
for (idx, segment) in segments.iter().enumerate() {
if idx > 0 {
joined.push(PATH_LIST_SEPARATOR);
}
joined.push_str(segment);
}
joined
}
fn char_array_value(text: &str) -> Value {
Value::CharArray(CharArray::new_row(text))
}
#[cfg(test)]
pub(crate) mod tests {
use super::super::REPL_FS_TEST_LOCK;
use super::*;
use crate::builtins::common::path_state::{current_path_segments, set_path_string};
use runmat_builtins::CellArray;
use std::convert::TryFrom;
use tempfile::tempdir;
fn rmpath_builtin(args: Vec<Value>) -> BuiltinResult<Value> {
futures::executor::block_on(super::rmpath_builtin(args))
}
struct PathGuard {
previous: String,
}
impl PathGuard {
fn new() -> Self {
Self {
previous: current_path_string(),
}
}
}
impl Drop for PathGuard {
fn drop(&mut self) {
set_path_string(&self.previous);
}
}
fn canonical(dir: &Path) -> String {
let normalized = normalize_pathbuf(dir);
path_to_string(&normalized)
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[test]
fn rmpath_removes_single_entry() {
let _lock = REPL_FS_TEST_LOCK
.lock()
.unwrap_or_else(|poison| poison.into_inner());
let _guard = PathGuard::new();
let target = tempdir().expect("target");
let keep = tempdir().expect("keep");
let target_str = canonical(target.path());
let keep_str = canonical(keep.path());
let combined = format!(
"{target}{sep}{keep}",
target = target_str,
keep = keep_str,
sep = PATH_LIST_SEPARATOR
);
set_path_string(&combined);
let returned = rmpath_builtin(vec![Value::String(target_str.clone())]).expect("rmpath");
let returned_str = String::try_from(&returned).expect("convert");
assert_eq!(returned_str, combined);
let segments = current_path_segments();
assert_eq!(segments, vec![keep_str]);
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[test]
fn rmpath_splits_path_list_argument() {
let _lock = REPL_FS_TEST_LOCK
.lock()
.unwrap_or_else(|poison| poison.into_inner());
let _guard = PathGuard::new();
let dir1 = tempdir().expect("dir1");
let dir2 = tempdir().expect("dir2");
let dir3 = tempdir().expect("dir3");
let str1 = canonical(dir1.path());
let str2 = canonical(dir2.path());
let str3 = canonical(dir3.path());
let combined = format!(
"{first}{sep}{second}{sep}{third}",
first = str1,
second = str2,
third = str3,
sep = PATH_LIST_SEPARATOR
);
set_path_string(&combined);
let to_remove = format!("{str1}{sep}{str2}", sep = PATH_LIST_SEPARATOR);
rmpath_builtin(vec![Value::String(to_remove)]).expect("rmpath");
let segments = current_path_segments();
assert_eq!(segments, vec![str3]);
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[test]
fn rmpath_accepts_string_containers() {
let _lock = REPL_FS_TEST_LOCK
.lock()
.unwrap_or_else(|poison| poison.into_inner());
let _guard = PathGuard::new();
let dir1 = tempdir().expect("dir1");
let dir2 = tempdir().expect("dir2");
let str1 = canonical(dir1.path());
let str2 = canonical(dir2.path());
set_path_string(&format!("{str1}{sep}{str2}", sep = PATH_LIST_SEPARATOR));
let strings = StringArray::new(vec![str1.clone()], vec![1, 1]).expect("string array");
let chars = CharArray::new_row(str2.as_str());
let args = vec![Value::StringArray(strings), Value::CharArray(chars)];
rmpath_builtin(args).expect("rmpath");
let segments = current_path_segments();
assert!(segments.is_empty());
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[test]
fn rmpath_supports_cell_array() {
let _lock = REPL_FS_TEST_LOCK
.lock()
.unwrap_or_else(|poison| poison.into_inner());
let _guard = PathGuard::new();
let dir = tempdir().expect("dir");
let str = canonical(dir.path());
set_path_string(&str);
let cell = CellArray::new(vec![Value::String(str.clone())], 1, 1).expect("cell");
rmpath_builtin(vec![Value::Cell(cell)]).expect("rmpath");
let segments = current_path_segments();
assert!(segments.is_empty());
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[test]
fn rmpath_errors_on_missing_folder() {
let _lock = REPL_FS_TEST_LOCK
.lock()
.unwrap_or_else(|poison| poison.into_inner());
let _guard = PathGuard::new();
set_path_string("");
let err = rmpath_builtin(vec![Value::String("this/folder/does/not/exist".into())])
.expect_err("expected error");
assert!(err.message().contains("not found"));
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[test]
fn rmpath_errors_when_folder_not_on_path() {
let _lock = REPL_FS_TEST_LOCK
.lock()
.unwrap_or_else(|poison| poison.into_inner());
let _guard = PathGuard::new();
let dir = tempdir().expect("dir");
let str = canonical(dir.path());
set_path_string("");
let err = rmpath_builtin(vec![Value::String(str.clone())]).expect_err("expected error");
assert!(err.message().contains("not on search path"));
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[test]
fn rmpath_returns_previous_path() {
let _lock = REPL_FS_TEST_LOCK
.lock()
.unwrap_or_else(|poison| poison.into_inner());
let _guard = PathGuard::new();
let dir = tempdir().expect("dir");
let str = canonical(dir.path());
set_path_string(&str);
let returned = rmpath_builtin(vec![Value::String(str.clone())]).expect("rmpath");
let returned_str = String::try_from(&returned).expect("string");
assert_eq!(returned_str, str);
}
}