Skip to main content

r/interpreter/builtins/
system.rs

1//! System, file, directory, path, and temp builtins.
2//!
3//! Each function is auto-registered via `#[builtin]` + linkme.
4
5use crate::interpreter::coerce::*;
6use crate::interpreter::value::*;
7use crate::interpreter::{BuiltinContext, Interpreter};
8use derive_more::{Display, Error};
9use itertools::Itertools;
10use minir_macros::{builtin, interpreter_builtin};
11use std::fs;
12use std::path::Path;
13
14// region: SystemError
15
16/// Structured error type for system/file operations.
17#[derive(Debug, Display, Error)]
18#[allow(dead_code)]
19pub enum SystemError {
20    #[display("cannot copy '{}' to '{}': {}", from, to, source)]
21    Copy {
22        from: String,
23        to: String,
24        source: std::io::Error,
25    },
26    #[display("cannot rename '{}' to '{}': {}", from, to, source)]
27    Rename {
28        from: String,
29        to: String,
30        source: std::io::Error,
31    },
32    #[display("cannot remove '{}': {}", path, source)]
33    Remove {
34        path: String,
35        source: std::io::Error,
36    },
37    #[display("cannot create directory '{}': {}", path, source)]
38    CreateDir {
39        path: String,
40        source: std::io::Error,
41    },
42    #[display("cannot read directory '{}': {}", path, source)]
43    ReadDir {
44        path: String,
45        source: std::io::Error,
46    },
47    #[display("cannot execute command '{}': {}", command, source)]
48    Command {
49        command: String,
50        source: std::io::Error,
51    },
52    #[display("cannot get current directory: {}", source)]
53    GetCwd {
54        #[error(source)]
55        source: std::io::Error,
56    },
57    #[display("cannot set current directory '{}': {}", path, source)]
58    SetCwd {
59        path: String,
60        source: std::io::Error,
61    },
62}
63
64impl From<SystemError> for RError {
65    fn from(e: SystemError) -> Self {
66        RError::from_source(RErrorKind::Other, e)
67    }
68}
69
70// endregion
71
72fn resolved_path_string(interp: &Interpreter, path: &str) -> String {
73    interp.resolve_path(path).to_string_lossy().to_string()
74}
75
76fn home_dir_string(interp: &Interpreter) -> Option<String> {
77    interp
78        .get_env_var("HOME")
79        .or_else(|| interp.get_env_var("USERPROFILE"))
80        .or_else(|| {
81            #[cfg(feature = "dirs-support")]
82            {
83                dirs::home_dir().map(|home| home.to_string_lossy().to_string())
84            }
85            #[cfg(not(feature = "dirs-support"))]
86            {
87                None
88            }
89        })
90}
91
92fn minir_data_dir(interp: &Interpreter) -> String {
93    #[cfg(feature = "dirs-support")]
94    {
95        if let Some(data) = dirs::data_dir() {
96            return data.join("miniR").to_string_lossy().to_string();
97        }
98    }
99
100    home_dir_string(interp)
101        .map(|h| format!("{}/.miniR", h))
102        .unwrap_or_else(|| "/tmp/miniR".to_string())
103}
104
105// === File operations ===
106
107/// Copy a file from one path to another.
108///
109/// @param from character scalar: source file path
110/// @param to character scalar: destination file path
111/// @return logical scalar: TRUE on success
112#[interpreter_builtin(name = "file.copy", min_args = 2)]
113fn builtin_file_copy(
114    args: &[RValue],
115    _named: &[(String, RValue)],
116    context: &BuiltinContext,
117) -> Result<RValue, RError> {
118    let from = args
119        .first()
120        .and_then(|v| v.as_vector()?.as_character_scalar())
121        .ok_or_else(|| {
122            RError::new(
123                RErrorKind::Argument,
124                "'from' must be a character string".to_string(),
125            )
126        })?;
127    let to = args
128        .get(1)
129        .and_then(|v| v.as_vector()?.as_character_scalar())
130        .ok_or_else(|| {
131            RError::new(
132                RErrorKind::Argument,
133                "'to' must be a character string".to_string(),
134            )
135        })?;
136
137    let from = resolved_path_string(context.interpreter(), &from);
138    let to = resolved_path_string(context.interpreter(), &to);
139
140    match fs::copy(&from, &to) {
141        Ok(_) => Ok(RValue::vec(Vector::Logical(vec![Some(true)].into()))),
142        Err(source) => Err(SystemError::Copy { from, to, source }.into()),
143    }
144}
145
146/// Create empty files at the given paths.
147///
148/// @param ... character scalars: file paths to create
149/// @return logical vector: TRUE for each file successfully created
150#[interpreter_builtin(name = "file.create", min_args = 1)]
151fn builtin_file_create(
152    args: &[RValue],
153    _named: &[(String, RValue)],
154    context: &BuiltinContext,
155) -> Result<RValue, RError> {
156    let results: Vec<Option<bool>> = args
157        .iter()
158        .map(|arg| {
159            let path = arg
160                .as_vector()
161                .and_then(|v| v.as_character_scalar())
162                .unwrap_or_default();
163            let path = resolved_path_string(context.interpreter(), &path);
164            match fs::File::create(&path) {
165                Ok(_) => Some(true),
166                Err(_) => Some(false),
167            }
168        })
169        .collect();
170    Ok(RValue::vec(Vector::Logical(results.into())))
171}
172
173/// Delete files at the given paths.
174///
175/// @param ... character scalars: file paths to remove
176/// @return logical vector: TRUE for each file successfully removed
177#[interpreter_builtin(name = "file.remove", min_args = 1)]
178fn builtin_file_remove(
179    args: &[RValue],
180    _named: &[(String, RValue)],
181    context: &BuiltinContext,
182) -> Result<RValue, RError> {
183    let results: Vec<Option<bool>> = args
184        .iter()
185        .map(|arg| {
186            let path = arg
187                .as_vector()
188                .and_then(|v| v.as_character_scalar())
189                .unwrap_or_default();
190            let path = resolved_path_string(context.interpreter(), &path);
191            match fs::remove_file(&path) {
192                Ok(()) => Some(true),
193                Err(_) => Some(false),
194            }
195        })
196        .collect();
197    Ok(RValue::vec(Vector::Logical(results.into())))
198}
199
200/// Rename (move) a file.
201///
202/// @param from character scalar: current file path
203/// @param to character scalar: new file path
204/// @return logical scalar: TRUE on success
205#[interpreter_builtin(name = "file.rename", min_args = 2)]
206fn builtin_file_rename(
207    args: &[RValue],
208    _named: &[(String, RValue)],
209    context: &BuiltinContext,
210) -> Result<RValue, RError> {
211    let from = args
212        .first()
213        .and_then(|v| v.as_vector()?.as_character_scalar())
214        .ok_or_else(|| {
215            RError::new(
216                RErrorKind::Argument,
217                "'from' must be a character string".to_string(),
218            )
219        })?;
220    let to = args
221        .get(1)
222        .and_then(|v| v.as_vector()?.as_character_scalar())
223        .ok_or_else(|| {
224            RError::new(
225                RErrorKind::Argument,
226                "'to' must be a character string".to_string(),
227            )
228        })?;
229
230    let from = resolved_path_string(context.interpreter(), &from);
231    let to = resolved_path_string(context.interpreter(), &to);
232
233    match fs::rename(&from, &to) {
234        Ok(()) => Ok(RValue::vec(Vector::Logical(vec![Some(true)].into()))),
235        Err(source) => Err(SystemError::Rename { from, to, source }.into()),
236    }
237}
238
239/// Get the size of files in bytes.
240///
241/// @param ... character scalars: file paths to query
242/// @return double vector of file sizes (NA for non-existent files)
243#[interpreter_builtin(name = "file.size", min_args = 1)]
244fn builtin_file_size(
245    args: &[RValue],
246    _named: &[(String, RValue)],
247    context: &BuiltinContext,
248) -> Result<RValue, RError> {
249    let results: Vec<Option<f64>> = args
250        .iter()
251        .map(|arg| {
252            let path = arg
253                .as_vector()
254                .and_then(|v| v.as_character_scalar())
255                .unwrap_or_default();
256            let path = resolved_path_string(context.interpreter(), &path);
257            fs::metadata(&path).ok().map(|m| u64_to_f64(m.len()))
258        })
259        .collect();
260    Ok(RValue::vec(Vector::Double(results.into())))
261}
262
263/// Get file modification times as POSIXct timestamps (seconds since Unix epoch).
264///
265/// @param ... character scalars: file paths to query
266/// @return double vector of modification times (NA for non-existent files)
267#[interpreter_builtin(name = "file.mtime", min_args = 1)]
268fn builtin_file_mtime(
269    args: &[RValue],
270    _named: &[(String, RValue)],
271    context: &BuiltinContext,
272) -> Result<RValue, RError> {
273    let results: Vec<Option<f64>> = args
274        .iter()
275        .map(|arg| {
276            let path = arg
277                .as_vector()
278                .and_then(|v| v.as_character_scalar())
279                .unwrap_or_default();
280            let path = resolved_path_string(context.interpreter(), &path);
281            fs::metadata(&path)
282                .ok()
283                .and_then(|m| m.modified().ok())
284                .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
285                .map(|d| d.as_secs_f64())
286        })
287        .collect();
288    let mut rv = RVector::from(Vector::Double(results.into()));
289    rv.set_attr(
290        "class".to_string(),
291        RValue::vec(Vector::Character(
292            vec![Some("POSIXct".to_string()), Some("POSIXt".to_string())].into(),
293        )),
294    );
295    Ok(RValue::Vector(rv))
296}
297
298/// Delete files or directories.
299///
300/// @param x character scalar: path to remove
301/// @param recursive logical: if TRUE, remove directories recursively (default FALSE)
302/// @return integer scalar: 0 on success, 1 on failure
303#[interpreter_builtin(name = "unlink", min_args = 1)]
304fn builtin_unlink(
305    args: &[RValue],
306    named: &[(String, RValue)],
307    context: &BuiltinContext,
308) -> Result<RValue, RError> {
309    let path = args
310        .first()
311        .and_then(|v| v.as_vector()?.as_character_scalar())
312        .ok_or_else(|| {
313            RError::new(
314                RErrorKind::Argument,
315                "'x' must be a character string".to_string(),
316            )
317        })?;
318    let recursive = named
319        .iter()
320        .find(|(n, _)| n == "recursive")
321        .and_then(|(_, v)| v.as_vector()?.as_logical_scalar())
322        .unwrap_or(false);
323
324    let path = resolved_path_string(context.interpreter(), &path);
325    let p = Path::new(&path);
326    let result = if p.is_dir() {
327        if recursive {
328            fs::remove_dir_all(&path)
329        } else {
330            fs::remove_dir(&path)
331        }
332    } else {
333        fs::remove_file(&path)
334    };
335
336    match result {
337        Ok(()) => Ok(RValue::vec(Vector::Integer(vec![Some(0)].into()))),
338        Err(_) => Ok(RValue::vec(Vector::Integer(vec![Some(1)].into()))),
339    }
340}
341
342// region: file.info
343
344/// Get detailed file metadata (size, type, permissions, timestamps).
345///
346/// @param ... character scalars: file paths to query
347/// @return data.frame with columns: size, isdir, mode, mtime, ctime, atime
348#[interpreter_builtin(name = "file.info", min_args = 1)]
349fn builtin_file_info(
350    args: &[RValue],
351    _named: &[(String, RValue)],
352    context: &BuiltinContext,
353) -> Result<RValue, RError> {
354    let paths: Vec<String> = args
355        .iter()
356        .filter_map(|v| v.as_vector()?.as_character_scalar())
357        .map(|path| resolved_path_string(context.interpreter(), &path))
358        .collect();
359
360    if paths.is_empty() {
361        return Err(RError::new(
362            RErrorKind::Argument,
363            "'...' must contain at least one file path".to_string(),
364        ));
365    }
366
367    let mut sizes: Vec<Option<f64>> = Vec::new();
368    let mut isdirs: Vec<Option<bool>> = Vec::new();
369    let mut modes: Vec<Option<i64>> = Vec::new();
370    let mut mtimes: Vec<Option<f64>> = Vec::new();
371    let mut ctimes: Vec<Option<f64>> = Vec::new();
372    let mut atimes: Vec<Option<f64>> = Vec::new();
373
374    for path in &paths {
375        match fs::metadata(path) {
376            Ok(meta) => {
377                sizes.push(Some(u64_to_f64(meta.len())));
378                isdirs.push(Some(meta.is_dir()));
379
380                #[cfg(unix)]
381                {
382                    use std::os::unix::fs::PermissionsExt;
383                    let mode_u32 = meta.permissions().mode() & 0o777;
384                    modes.push(Some(i64::from(mode_u32)));
385                }
386                #[cfg(not(unix))]
387                {
388                    modes.push(Some(0o644));
389                }
390
391                let mtime = meta
392                    .modified()
393                    .ok()
394                    .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
395                    .map(|d| d.as_secs_f64());
396                mtimes.push(mtime);
397
398                let ctime = meta
399                    .created()
400                    .ok()
401                    .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
402                    .map(|d| d.as_secs_f64());
403                ctimes.push(ctime);
404
405                let atime = meta
406                    .accessed()
407                    .ok()
408                    .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
409                    .map(|d| d.as_secs_f64());
410                atimes.push(atime);
411            }
412            Err(_) => {
413                sizes.push(None);
414                isdirs.push(None);
415                modes.push(None);
416                mtimes.push(None);
417                ctimes.push(None);
418                atimes.push(None);
419            }
420        }
421    }
422
423    let row_names: Vec<Option<String>> = paths.into_iter().map(Some).collect();
424    let mut list = RList::new(vec![
425        (
426            Some("size".to_string()),
427            RValue::vec(Vector::Double(sizes.into())),
428        ),
429        (
430            Some("isdir".to_string()),
431            RValue::vec(Vector::Logical(isdirs.into())),
432        ),
433        (
434            Some("mode".to_string()),
435            RValue::vec(Vector::Integer(modes.into())),
436        ),
437        (
438            Some("mtime".to_string()),
439            RValue::vec(Vector::Double(mtimes.into())),
440        ),
441        (
442            Some("ctime".to_string()),
443            RValue::vec(Vector::Double(ctimes.into())),
444        ),
445        (
446            Some("atime".to_string()),
447            RValue::vec(Vector::Double(atimes.into())),
448        ),
449    ]);
450
451    list.set_attr(
452        "row.names".to_string(),
453        RValue::vec(Vector::Character(row_names.into())),
454    );
455
456    Ok(RValue::List(list))
457}
458// endregion
459
460// === Directory operations ===
461
462/// Create a directory, optionally with parent directories.
463///
464/// @param path character scalar: directory path to create
465/// @param showWarnings logical: if TRUE (default), warn on failure instead of erroring
466/// @param recursive logical: if TRUE, create parent directories as needed (default TRUE,
467///   diverging from R's default of FALSE for better ergonomics)
468/// @return logical scalar: TRUE on success, FALSE on failure (when showWarnings is TRUE)
469#[interpreter_builtin(name = "dir.create", min_args = 1)]
470fn builtin_dir_create(
471    args: &[RValue],
472    named: &[(String, RValue)],
473    context: &BuiltinContext,
474) -> Result<RValue, RError> {
475    let path = args
476        .first()
477        .and_then(|v| v.as_vector()?.as_character_scalar())
478        .ok_or_else(|| {
479            RError::new(
480                RErrorKind::Argument,
481                "'path' must be a character string".to_string(),
482            )
483        })?;
484
485    let show_warnings = named
486        .iter()
487        .find(|(n, _)| n == "showWarnings")
488        .and_then(|(_, v)| v.as_vector()?.as_logical_scalar())
489        .unwrap_or(true);
490
491    // miniR diverges from R: recursive = TRUE by default
492    let recursive = named
493        .iter()
494        .find(|(n, _)| n == "recursive")
495        .and_then(|(_, v)| v.as_vector()?.as_logical_scalar())
496        .unwrap_or(true);
497
498    let path = resolved_path_string(context.interpreter(), &path);
499
500    let result = if recursive {
501        fs::create_dir_all(&path)
502    } else {
503        fs::create_dir(&path)
504    };
505
506    match result {
507        Ok(()) => Ok(RValue::vec(Vector::Logical(vec![Some(true)].into()))),
508        Err(source) => {
509            if show_warnings {
510                // Return FALSE with a warning (matching R behavior for showWarnings=TRUE)
511                // In R, this issues a warning rather than an error. We return FALSE to
512                // signal failure without aborting.
513                Ok(RValue::vec(Vector::Logical(vec![Some(false)].into())))
514            } else {
515                Err(SystemError::CreateDir { path, source }.into())
516            }
517        }
518    }
519}
520
521/// Test whether directories exist at the given paths.
522///
523/// @param ... character scalars: directory paths to check
524/// @return logical vector indicating existence of each directory
525#[interpreter_builtin(name = "dir.exists", min_args = 1)]
526fn builtin_dir_exists(
527    args: &[RValue],
528    _named: &[(String, RValue)],
529    context: &BuiltinContext,
530) -> Result<RValue, RError> {
531    let results: Vec<Option<bool>> = args
532        .iter()
533        .map(|arg| {
534            let path = arg
535                .as_vector()
536                .and_then(|v| v.as_character_scalar())
537                .unwrap_or_default();
538            let path = resolved_path_string(context.interpreter(), &path);
539            Some(Path::new(&path).is_dir())
540        })
541        .collect();
542    Ok(RValue::vec(Vector::Logical(results.into())))
543}
544
545/// List files in a directory, optionally filtering by pattern.
546///
547/// @param path character scalar: directory path (default ".")
548/// @param pattern character scalar: regex pattern to filter file names
549/// @param all.files logical: if TRUE, include hidden files starting with "." (default FALSE)
550/// @param full.names logical: if TRUE, return full paths (default FALSE)
551/// @param recursive logical: if TRUE, recurse into subdirectories (default FALSE).
552///   When the `walkdir-support` feature is enabled, uses walkdir for efficient
553///   recursive traversal.
554/// @return character vector of matching file names (sorted)
555#[interpreter_builtin(name = "list.files", names = ["dir"])]
556fn builtin_list_files(
557    args: &[RValue],
558    named: &[(String, RValue)],
559    context: &BuiltinContext,
560) -> Result<RValue, RError> {
561    let path = args
562        .first()
563        .and_then(|v| v.as_vector()?.as_character_scalar())
564        .unwrap_or_else(|| ".".to_string());
565    let path = resolved_path_string(context.interpreter(), &path);
566
567    let pattern = named
568        .iter()
569        .find(|(n, _)| n == "pattern")
570        .and_then(|(_, v)| v.as_vector()?.as_character_scalar());
571
572    let all_files = named
573        .iter()
574        .find(|(n, _)| n == "all.files")
575        .and_then(|(_, v)| v.as_vector()?.as_logical_scalar())
576        .unwrap_or(false);
577
578    let recursive = named
579        .iter()
580        .find(|(n, _)| n == "recursive")
581        .and_then(|(_, v)| v.as_vector()?.as_logical_scalar())
582        .unwrap_or(false);
583
584    let full_names = named
585        .iter()
586        .find(|(n, _)| n == "full.names")
587        .and_then(|(_, v)| v.as_vector()?.as_logical_scalar())
588        .unwrap_or(false);
589
590    let regex = match &pattern {
591        Some(pat) => Some(regex::Regex::new(pat).map_err(|source| -> RError {
592            super::strings::StringError::InvalidRegex { source }.into()
593        })?),
594        None => None,
595    };
596
597    let result = if recursive {
598        list_files_recursive(&path, &regex, all_files, full_names)?
599    } else {
600        list_files_flat(&path, &regex, all_files, full_names)?
601    };
602
603    Ok(RValue::vec(Vector::Character(result.into())))
604}
605
606/// Non-recursive directory listing.
607fn list_files_flat(
608    path: &str,
609    regex: &Option<regex::Regex>,
610    all_files: bool,
611    full_names: bool,
612) -> Result<Vec<Option<String>>, RError> {
613    let entries = fs::read_dir(path).map_err(|source| SystemError::ReadDir {
614        path: path.to_string(),
615        source,
616    })?;
617
618    let result: Vec<Option<String>> = entries
619        .filter_map(|entry| {
620            let entry = entry.ok()?;
621            let name = entry.file_name().into_string().ok()?;
622            // Skip hidden files (starting with '.') unless all.files is TRUE
623            if !all_files && name.starts_with('.') {
624                return None;
625            }
626            if let Some(ref re) = regex {
627                if !re.is_match(&name) {
628                    return None;
629                }
630            }
631            if full_names {
632                Some(entry.path().to_string_lossy().to_string())
633            } else {
634                Some(name)
635            }
636        })
637        .sorted()
638        .map(Some)
639        .collect();
640    Ok(result)
641}
642
643/// Recursive directory listing using walkdir (when available) or std::fs fallback.
644#[cfg(feature = "walkdir-support")]
645fn list_files_recursive(
646    path: &str,
647    regex: &Option<regex::Regex>,
648    all_files: bool,
649    full_names: bool,
650) -> Result<Vec<Option<String>>, RError> {
651    let base = Path::new(path);
652    let result: Vec<Option<String>> = walkdir::WalkDir::new(path)
653        .min_depth(1)
654        .into_iter()
655        .filter_map(|entry| {
656            let entry = entry.ok()?;
657            let name = entry.file_name().to_string_lossy().to_string();
658            // Skip hidden files unless all.files is TRUE
659            if !all_files && name.starts_with('.') {
660                return None;
661            }
662            if let Some(ref re) = regex {
663                if !re.is_match(&name) {
664                    return None;
665                }
666            }
667            if full_names {
668                Some(entry.path().to_string_lossy().to_string())
669            } else {
670                // Return path relative to the base directory
671                entry
672                    .path()
673                    .strip_prefix(base)
674                    .ok()
675                    .map(|p| p.to_string_lossy().to_string())
676            }
677        })
678        .sorted()
679        .map(Some)
680        .collect();
681    Ok(result)
682}
683
684/// Recursive directory listing fallback without walkdir.
685#[cfg(not(feature = "walkdir-support"))]
686fn list_files_recursive(
687    path: &str,
688    regex: &Option<regex::Regex>,
689    all_files: bool,
690    full_names: bool,
691) -> Result<Vec<Option<String>>, RError> {
692    let mut result: Vec<String> = Vec::new();
693    list_files_recursive_fallback(
694        Path::new(path),
695        Path::new(path),
696        regex,
697        all_files,
698        full_names,
699        &mut result,
700    )?;
701    result.sort();
702    Ok(result.into_iter().map(Some).collect())
703}
704
705#[cfg(not(feature = "walkdir-support"))]
706fn list_files_recursive_fallback(
707    base: &Path,
708    dir: &Path,
709    regex: &Option<regex::Regex>,
710    all_files: bool,
711    full_names: bool,
712    out: &mut Vec<String>,
713) -> Result<(), RError> {
714    let entries = fs::read_dir(dir).map_err(|source| SystemError::ReadDir {
715        path: dir.to_string_lossy().to_string(),
716        source,
717    })?;
718    for entry in entries {
719        let entry = match entry {
720            Ok(e) => e,
721            Err(_) => continue,
722        };
723        let name = entry.file_name().to_string_lossy().to_string();
724        let entry_path = entry.path();
725
726        // Skip hidden files unless all.files is TRUE
727        if !all_files && name.starts_with('.') {
728            continue;
729        }
730
731        if entry_path.is_dir() {
732            list_files_recursive_fallback(base, &entry_path, regex, all_files, full_names, out)?;
733        } else {
734            if let Some(ref re) = regex {
735                if !re.is_match(&name) {
736                    continue;
737                }
738            }
739            if full_names {
740                out.push(entry_path.to_string_lossy().to_string());
741            } else if let Ok(rel) = entry_path.strip_prefix(base) {
742                out.push(rel.to_string_lossy().to_string());
743            }
744        }
745    }
746    Ok(())
747}
748
749// region: list.dirs
750
751/// List subdirectories of a directory.
752///
753/// @param path character scalar: directory path (default ".")
754/// @param full.names logical: if TRUE, return full paths (default TRUE, matching R)
755/// @param recursive logical: if TRUE, recurse into subdirectories (default TRUE).
756///   When the `walkdir-support` feature is enabled, uses walkdir for efficient
757///   recursive traversal.
758/// @return character vector of directory paths (sorted)
759#[interpreter_builtin(name = "list.dirs")]
760fn builtin_list_dirs(
761    args: &[RValue],
762    named: &[(String, RValue)],
763    context: &BuiltinContext,
764) -> Result<RValue, RError> {
765    let path = args
766        .first()
767        .and_then(|v| v.as_vector()?.as_character_scalar())
768        .unwrap_or_else(|| ".".to_string());
769    let path = resolved_path_string(context.interpreter(), &path);
770
771    // R defaults: full.names = TRUE, recursive = TRUE
772    let full_names = named
773        .iter()
774        .find(|(n, _)| n == "full.names")
775        .and_then(|(_, v)| v.as_vector()?.as_logical_scalar())
776        .unwrap_or(true);
777
778    let recursive = named
779        .iter()
780        .find(|(n, _)| n == "recursive")
781        .and_then(|(_, v)| v.as_vector()?.as_logical_scalar())
782        .unwrap_or(true);
783
784    let result = if recursive {
785        list_dirs_recursive(&path, full_names)?
786    } else {
787        list_dirs_flat(&path, full_names)?
788    };
789
790    Ok(RValue::vec(Vector::Character(result.into())))
791}
792
793/// Non-recursive directory listing (immediate subdirectories only).
794fn list_dirs_flat(path: &str, full_names: bool) -> Result<Vec<Option<String>>, RError> {
795    let base = Path::new(path);
796    let entries = fs::read_dir(path).map_err(|source| SystemError::ReadDir {
797        path: path.to_string(),
798        source,
799    })?;
800
801    // Include the base directory itself, matching R behavior
802    let mut result: Vec<String> = vec![if full_names {
803        base.to_string_lossy().to_string()
804    } else {
805        ".".to_string()
806    }];
807
808    for entry in entries {
809        let entry = match entry {
810            Ok(e) => e,
811            Err(_) => continue,
812        };
813        let entry_path = entry.path();
814        if !entry_path.is_dir() {
815            continue;
816        }
817        let name = entry.file_name().into_string().unwrap_or_default();
818        if full_names {
819            result.push(entry_path.to_string_lossy().to_string());
820        } else {
821            result.push(name);
822        }
823    }
824
825    result.sort();
826    Ok(result.into_iter().map(Some).collect())
827}
828
829/// Recursive directory listing using walkdir.
830#[cfg(feature = "walkdir-support")]
831fn list_dirs_recursive(path: &str, full_names: bool) -> Result<Vec<Option<String>>, RError> {
832    let base = Path::new(path);
833    let mut result: Vec<String> = walkdir::WalkDir::new(path)
834        .into_iter()
835        .filter_map(|entry| {
836            let entry = entry.ok()?;
837            if !entry.file_type().is_dir() {
838                return None;
839            }
840            if full_names {
841                Some(entry.path().to_string_lossy().to_string())
842            } else {
843                let rel = entry.path().strip_prefix(base).ok()?;
844                let s = rel.to_string_lossy().to_string();
845                Some(if s.is_empty() { ".".to_string() } else { s })
846            }
847        })
848        .collect();
849    result.sort();
850    Ok(result.into_iter().map(Some).collect())
851}
852
853/// Recursive directory listing fallback without walkdir.
854#[cfg(not(feature = "walkdir-support"))]
855fn list_dirs_recursive(path: &str, full_names: bool) -> Result<Vec<Option<String>>, RError> {
856    let base = Path::new(path);
857    let mut result: Vec<String> = Vec::new();
858    list_dirs_recursive_fallback(base, base, full_names, &mut result)?;
859    result.sort();
860    Ok(result.into_iter().map(Some).collect())
861}
862
863#[cfg(not(feature = "walkdir-support"))]
864fn list_dirs_recursive_fallback(
865    base: &Path,
866    dir: &Path,
867    full_names: bool,
868    out: &mut Vec<String>,
869) -> Result<(), RError> {
870    // Add the current directory
871    if full_names {
872        out.push(dir.to_string_lossy().to_string());
873    } else {
874        let rel = dir
875            .strip_prefix(base)
876            .ok()
877            .map(|p| p.to_string_lossy().to_string())
878            .unwrap_or_default();
879        out.push(if rel.is_empty() { ".".to_string() } else { rel });
880    }
881
882    let entries = fs::read_dir(dir).map_err(|source| SystemError::ReadDir {
883        path: dir.to_string_lossy().to_string(),
884        source,
885    })?;
886    for entry in entries {
887        let entry = match entry {
888            Ok(e) => e,
889            Err(_) => continue,
890        };
891        let entry_path = entry.path();
892        if entry_path.is_dir() {
893            list_dirs_recursive_fallback(base, &entry_path, full_names, out)?;
894        }
895    }
896    Ok(())
897}
898
899// endregion
900
901// === Temp paths ===
902
903/// Get the path to the interpreter's per-session temporary directory.
904///
905/// @return character scalar: path to the temp directory
906#[interpreter_builtin]
907fn interp_tempdir(
908    _args: &[RValue],
909    _named: &[(String, RValue)],
910    context: &BuiltinContext,
911) -> Result<RValue, RError> {
912    let path =
913        context.with_interpreter(|interp| interp.temp_dir.path().to_string_lossy().to_string());
914    Ok(RValue::vec(Vector::Character(vec![Some(path)].into())))
915}
916
917/// Generate a unique temporary file path.
918///
919/// @param pattern character scalar: filename prefix (default "file")
920/// @param tmpdir character scalar: directory for the temp file (default: session temp dir)
921/// @param fileext character scalar: file extension (default "")
922/// @return character scalar: the generated temporary file path
923#[interpreter_builtin]
924fn interp_tempfile(
925    args: &[RValue],
926    named: &[(String, RValue)],
927    context: &BuiltinContext,
928) -> Result<RValue, RError> {
929    let pattern = args
930        .first()
931        .or_else(|| named.iter().find(|(n, _)| n == "pattern").map(|(_, v)| v))
932        .and_then(|v| v.as_vector()?.as_character_scalar())
933        .unwrap_or_else(|| "file".to_string());
934
935    let fileext = args
936        .get(2)
937        .or_else(|| named.iter().find(|(n, _)| n == "fileext").map(|(_, v)| v))
938        .and_then(|v| v.as_vector()?.as_character_scalar())
939        .unwrap_or_default();
940
941    let path = context.with_interpreter(|interp| {
942        let tmpdir = args
943            .get(1)
944            .or_else(|| named.iter().find(|(n, _)| n == "tmpdir").map(|(_, v)| v))
945            .and_then(|v| v.as_vector()?.as_character_scalar())
946            .unwrap_or_else(|| interp.temp_dir.path().to_string_lossy().to_string());
947
948        let n = interp.temp_counter.get();
949        interp.temp_counter.set(n + 1);
950
951        Path::new(&tmpdir)
952            .join(format!("{}{}{}", pattern, n, fileext))
953            .to_string_lossy()
954            .to_string()
955    });
956    Ok(RValue::vec(Vector::Character(vec![Some(path)].into())))
957}
958
959// === Glob ===
960
961/// Expand file system glob patterns to matching paths.
962///
963/// Uses the `glob` crate for file-system expansion. When `globset-support` is
964/// enabled, pattern validation is done via `globset` for better error messages,
965/// though actual path enumeration still goes through `glob::glob()` since
966/// globset is a pattern matcher, not a directory walker.
967///
968/// @param ... character scalars: glob patterns (e.g. "*.R", "src/**/*.rs")
969/// @return character vector of matching file paths
970#[interpreter_builtin(name = "Sys.glob", min_args = 1)]
971fn builtin_sys_glob(
972    args: &[RValue],
973    _named: &[(String, RValue)],
974    context: &BuiltinContext,
975) -> Result<RValue, RError> {
976    let patterns: Vec<String> = args
977        .iter()
978        .filter_map(|v| v.as_vector()?.as_character_scalar())
979        .collect();
980
981    let mut results: Vec<Option<String>> = Vec::new();
982    for pattern in &patterns {
983        let resolved_pattern = resolved_path_string(context.interpreter(), pattern);
984        // Validate the pattern with globset when available, for better errors
985        #[cfg(feature = "globset-support")]
986        {
987            if let Err(e) = globset::Glob::new(&resolved_pattern) {
988                return Err(RError::other(format!(
989                    "invalid glob pattern '{}': {}",
990                    pattern, e
991                )));
992            }
993        }
994
995        match glob::glob(&resolved_pattern) {
996            Ok(paths) => {
997                for path in paths.flatten() {
998                    results.push(Some(path.to_string_lossy().to_string()));
999                }
1000            }
1001            Err(e) => {
1002                return Err(RError::other(format!(
1003                    "invalid glob pattern '{}': {}",
1004                    pattern, e
1005                )));
1006            }
1007        }
1008    }
1009
1010    Ok(RValue::vec(Vector::Character(results.into())))
1011}
1012
1013// === Path operations ===
1014
1015/// Normalize a file path to its canonical absolute form.
1016///
1017/// @param path character scalar: path to normalize
1018/// @param mustWork if TRUE, error when the path cannot be resolved; if FALSE/NA, return
1019///   the original path on failure
1020/// @return character scalar: the canonical path (or the original if resolution fails)
1021#[interpreter_builtin(name = "normalizePath", min_args = 1)]
1022fn builtin_normalize_path(
1023    args: &[RValue],
1024    named: &[(String, RValue)],
1025    context: &BuiltinContext,
1026) -> Result<RValue, RError> {
1027    let path = args
1028        .first()
1029        .and_then(|v| v.as_vector()?.as_character_scalar())
1030        .ok_or_else(|| {
1031            RError::new(
1032                RErrorKind::Argument,
1033                "'path' must be a character string".to_string(),
1034            )
1035        })?;
1036
1037    let must_work = named
1038        .iter()
1039        .find(|(n, _)| n == "mustWork")
1040        .or_else(|| named.iter().find(|(n, _)| n == "mustwork"))
1041        .and_then(|(_, v)| v.as_vector()?.as_logical_scalar())
1042        .or_else(|| args.get(2).and_then(|v| v.as_vector()?.as_logical_scalar()))
1043        .unwrap_or(false);
1044
1045    let resolved = context.interpreter().resolve_path(&path);
1046
1047    match fs::canonicalize(&resolved) {
1048        Ok(p) => Ok(RValue::vec(Vector::Character(
1049            vec![Some(p.to_string_lossy().to_string())].into(),
1050        ))),
1051        Err(e) => {
1052            if must_work {
1053                Err(RError::new(
1054                    RErrorKind::Other,
1055                    format!("path '{}' does not exist: {}", path, e),
1056                ))
1057            } else {
1058                Ok(RValue::vec(Vector::Character(
1059                    vec![Some(path.clone())].into(),
1060                )))
1061            }
1062        }
1063    }
1064}
1065
1066/// Expand a tilde (~) prefix in a file path to the user's home directory.
1067///
1068/// Uses `dirs::home_dir()` (when the `dirs-support` feature is enabled) for
1069/// robust, cross-platform home directory detection, falling back to $HOME /
1070/// %USERPROFILE% environment variables.
1071///
1072/// @param path character scalar: path possibly starting with ~
1073/// @return character scalar: the expanded path
1074#[interpreter_builtin(name = "path.expand", min_args = 1)]
1075fn builtin_path_expand(
1076    args: &[RValue],
1077    _named: &[(String, RValue)],
1078    context: &BuiltinContext,
1079) -> Result<RValue, RError> {
1080    let path = args
1081        .first()
1082        .and_then(|v| v.as_vector()?.as_character_scalar())
1083        .ok_or_else(|| {
1084            RError::new(
1085                RErrorKind::Argument,
1086                "'path' must be a character string".to_string(),
1087            )
1088        })?;
1089
1090    let expanded = if path.starts_with('~') {
1091        let home = home_dir_string(context.interpreter());
1092        match home {
1093            Some(h) => path.replacen('~', &h, 1),
1094            None => path,
1095        }
1096    } else {
1097        path
1098    };
1099
1100    Ok(RValue::vec(Vector::Character(vec![Some(expanded)].into())))
1101}
1102
1103// region: R.home / .libPaths
1104
1105/// Return the miniR "home" directory (data directory for miniR resources).
1106///
1107/// Uses `dirs::data_dir()` to find a platform-appropriate location, e.g.
1108/// `~/Library/Application Support/miniR` on macOS, `~/.local/share/miniR`
1109/// on Linux, `%APPDATA%/miniR` on Windows.
1110///
1111/// @param component character scalar: optional sub-path within R home (default "")
1112/// @return character scalar: the miniR home directory path
1113#[interpreter_builtin(name = "R.home")]
1114fn builtin_r_home(
1115    args: &[RValue],
1116    _named: &[(String, RValue)],
1117    context: &BuiltinContext,
1118) -> Result<RValue, RError> {
1119    let component = args
1120        .first()
1121        .and_then(|v| v.as_vector()?.as_character_scalar())
1122        .unwrap_or_default();
1123
1124    let base = minir_data_dir(context.interpreter());
1125    let result = if component.is_empty() {
1126        base
1127    } else {
1128        Path::new(&base)
1129            .join(&component)
1130            .to_string_lossy()
1131            .to_string()
1132    };
1133
1134    Ok(RValue::vec(Vector::Character(vec![Some(result)].into())))
1135}
1136
1137/// Return the library search paths for package installation.
1138///
1139/// Builds the search path from (in order):
1140/// 1. `R_LIBS` environment variable (colon-separated on Unix, semicolon on Windows)
1141/// 2. `R_LIBS_USER` environment variable
1142/// 3. The default miniR library directory (`<data_dir>/miniR/library`)
1143///
1144/// Only directories that actually exist on disk are included, matching R's
1145/// behavior of filtering `.libPaths()` to existing directories.
1146///
1147/// @param new character vector: if provided, sets new library paths (currently ignored)
1148/// @return character vector of library search paths
1149#[interpreter_builtin(name = ".libPaths")]
1150fn builtin_lib_paths(
1151    _args: &[RValue],
1152    _named: &[(String, RValue)],
1153    context: &BuiltinContext,
1154) -> Result<RValue, RError> {
1155    let interp = context.interpreter();
1156    let mut paths: Vec<String> = Vec::new();
1157
1158    // Platform-appropriate path separator
1159    let sep = if cfg!(windows) { ';' } else { ':' };
1160
1161    // R_LIBS takes priority
1162    if let Some(r_libs) = interp.get_env_var("R_LIBS") {
1163        for p in r_libs.split(sep) {
1164            let p = p.trim();
1165            if !p.is_empty() {
1166                let resolved = interp.resolve_path(p);
1167                if resolved.is_dir() {
1168                    paths.push(resolved.to_string_lossy().to_string());
1169                }
1170            }
1171        }
1172    }
1173
1174    // Then R_LIBS_USER
1175    if let Some(r_libs_user) = interp.get_env_var("R_LIBS_USER") {
1176        for p in r_libs_user.split(sep) {
1177            let p = p.trim();
1178            if !p.is_empty() {
1179                let resolved = interp.resolve_path(p);
1180                if resolved.is_dir() {
1181                    paths.push(resolved.to_string_lossy().to_string());
1182                }
1183            }
1184        }
1185    }
1186
1187    // Default miniR library directory (always included even if it doesn't exist yet,
1188    // as it's the canonical install location)
1189    let default_lib = format!("{}/library", minir_data_dir(interp));
1190    if !paths.contains(&default_lib) {
1191        paths.push(default_lib);
1192    }
1193
1194    let values: Vec<Option<String>> = paths.into_iter().map(Some).collect();
1195    Ok(RValue::vec(Vector::Character(values.into())))
1196}
1197
1198// endregion
1199
1200// === System operations ===
1201
1202/// Execute a shell command.
1203///
1204/// When `intern = FALSE` (default), the command runs and its exit code is
1205/// returned as an integer. When `intern = TRUE`, the command's stdout is
1206/// captured and returned as a character vector (one element per line).
1207///
1208/// @param command character scalar: the shell command to run
1209/// @param intern logical: if TRUE, capture stdout as character vector (default FALSE)
1210/// @return integer scalar (exit code) when intern=FALSE, or character vector when intern=TRUE
1211#[interpreter_builtin(name = "system", min_args = 1)]
1212fn builtin_system(
1213    args: &[RValue],
1214    named: &[(String, RValue)],
1215    context: &BuiltinContext,
1216) -> Result<RValue, RError> {
1217    let command = args
1218        .first()
1219        .and_then(|v| v.as_vector()?.as_character_scalar())
1220        .ok_or_else(|| {
1221            RError::new(
1222                RErrorKind::Argument,
1223                "'command' must be a character string".to_string(),
1224            )
1225        })?;
1226
1227    let intern = named
1228        .iter()
1229        .find(|(n, _)| n == "intern")
1230        .and_then(|(_, v)| v.as_vector()?.as_logical_scalar())
1231        .or_else(|| args.get(1).and_then(|v| v.as_vector()?.as_logical_scalar()))
1232        .unwrap_or(false);
1233
1234    if intern {
1235        // Capture stdout and return as character vector
1236        let output = context.with_interpreter(|interp| {
1237            let mut cmd = std::process::Command::new("sh");
1238            cmd.arg("-c")
1239                .arg(&command)
1240                .current_dir(interp.get_working_dir())
1241                .env_clear()
1242                .envs(interp.env_vars_snapshot());
1243            cmd.output().map_err(|source| SystemError::Command {
1244                command: command.clone(),
1245                source,
1246            })
1247        })?;
1248
1249        let stdout = String::from_utf8_lossy(&output.stdout);
1250        let lines: Vec<Option<String>> =
1251            stdout.lines().map(|line| Some(line.to_string())).collect();
1252        Ok(RValue::vec(Vector::Character(lines.into())))
1253    } else {
1254        // Run command and return exit code
1255        let output = context.with_interpreter(|interp| {
1256            let mut cmd = std::process::Command::new("sh");
1257            cmd.arg("-c")
1258                .arg(&command)
1259                .current_dir(interp.get_working_dir())
1260                .env_clear()
1261                .envs(interp.env_vars_snapshot());
1262            cmd.status().map_err(|source| SystemError::Command {
1263                command: command.clone(),
1264                source,
1265            })
1266        })?;
1267
1268        let code = i64::from(output.code().unwrap_or(-1));
1269        Ok(RValue::vec(Vector::Integer(vec![Some(code)].into())))
1270    }
1271}
1272
1273/// Execute a command with arguments, optionally capturing stdout/stderr.
1274///
1275/// @param command character scalar: the program to run
1276/// @param args character vector: command-line arguments (default: none)
1277/// @param stdout logical or character: TRUE = capture to character vector,
1278///   FALSE = discard, "" = inherit (default), or a file path to redirect to
1279/// @param stderr logical or character: TRUE = capture, FALSE = discard,
1280///   "" = inherit (default), or a file path to redirect to
1281/// @return integer scalar (exit code) when stdout is not captured, or
1282///   character vector of captured output with "status" attribute set to exit code
1283#[interpreter_builtin(name = "system2", min_args = 1)]
1284fn builtin_system2(
1285    args: &[RValue],
1286    named: &[(String, RValue)],
1287    context: &BuiltinContext,
1288) -> Result<RValue, RError> {
1289    let command = args
1290        .first()
1291        .and_then(|v| v.as_vector()?.as_character_scalar())
1292        .ok_or_else(|| {
1293            RError::new(
1294                RErrorKind::Argument,
1295                "'command' must be a character string".to_string(),
1296            )
1297        })?;
1298
1299    let cmd_args: Vec<String> = args
1300        .get(1)
1301        .or_else(|| named.iter().find(|(n, _)| n == "args").map(|(_, v)| v))
1302        .and_then(|v| v.as_vector())
1303        .map(|v| v.to_characters().into_iter().flatten().collect())
1304        .unwrap_or_default();
1305
1306    // Parse stdout parameter: TRUE = capture, FALSE = discard, "" = inherit
1307    let stdout_val = named.iter().find(|(n, _)| n == "stdout").map(|(_, v)| v);
1308    let capture_stdout = stdout_val
1309        .and_then(|v| v.as_vector()?.as_logical_scalar())
1310        .unwrap_or(false);
1311
1312    // Parse stderr parameter: TRUE = capture, FALSE = discard, "" = inherit
1313    let stderr_val = named.iter().find(|(n, _)| n == "stderr").map(|(_, v)| v);
1314    let capture_stderr = stderr_val
1315        .and_then(|v| v.as_vector()?.as_logical_scalar())
1316        .unwrap_or(false);
1317
1318    if capture_stdout || capture_stderr {
1319        // Capture output
1320        let output = context.with_interpreter(|interp| {
1321            let mut cmd = std::process::Command::new(&command);
1322            cmd.args(&cmd_args)
1323                .current_dir(interp.get_working_dir())
1324                .env_clear()
1325                .envs(interp.env_vars_snapshot());
1326
1327            if capture_stdout {
1328                cmd.stdout(std::process::Stdio::piped());
1329            }
1330            if capture_stderr {
1331                cmd.stderr(std::process::Stdio::piped());
1332            }
1333
1334            cmd.output().map_err(|source| SystemError::Command {
1335                command: command.clone(),
1336                source,
1337            })
1338        })?;
1339
1340        let code = i64::from(output.status.code().unwrap_or(-1));
1341
1342        // Build the captured output lines
1343        let mut lines: Vec<Option<String>> = Vec::new();
1344
1345        if capture_stdout {
1346            let stdout = String::from_utf8_lossy(&output.stdout);
1347            for line in stdout.lines() {
1348                lines.push(Some(line.to_string()));
1349            }
1350        }
1351
1352        if capture_stderr {
1353            let stderr = String::from_utf8_lossy(&output.stderr);
1354            for line in stderr.lines() {
1355                lines.push(Some(line.to_string()));
1356            }
1357        }
1358
1359        let mut rv = RVector::from(Vector::Character(lines.into()));
1360        rv.set_attr(
1361            "status".to_string(),
1362            RValue::vec(Vector::Integer(vec![Some(code)].into())),
1363        );
1364        Ok(RValue::Vector(rv))
1365    } else {
1366        // No capture — just run and return exit code
1367        let output = context.with_interpreter(|interp| {
1368            let mut cmd = std::process::Command::new(&command);
1369            cmd.args(&cmd_args)
1370                .current_dir(interp.get_working_dir())
1371                .env_clear()
1372                .envs(interp.env_vars_snapshot());
1373            cmd.status().map_err(|source| SystemError::Command {
1374                command: command.clone(),
1375                source,
1376            })
1377        })?;
1378
1379        let code = i64::from(output.code().unwrap_or(-1));
1380        Ok(RValue::vec(Vector::Integer(vec![Some(code)].into())))
1381    }
1382}
1383
1384/// Set environment variables in the interpreter's private environment.
1385///
1386/// @param ... named character scalars: name=value pairs to set
1387/// @return logical scalar: TRUE
1388#[interpreter_builtin(name = "Sys.setenv")]
1389fn interp_sys_setenv(
1390    _args: &[RValue],
1391    named: &[(String, RValue)],
1392    context: &BuiltinContext,
1393) -> Result<RValue, RError> {
1394    context.with_interpreter(|interp| {
1395        for (name, val) in named {
1396            let val_str = val
1397                .as_vector()
1398                .and_then(|v| v.as_character_scalar())
1399                .unwrap_or_default();
1400            interp.set_env_var(name.clone(), val_str);
1401        }
1402    });
1403    Ok(RValue::vec(Vector::Logical(vec![Some(true)].into())))
1404}
1405
1406/// Unset environment variables in the interpreter's private environment.
1407///
1408/// @param x character vector of variable names to unset
1409/// @return logical vector (TRUE for each successfully unset)
1410#[interpreter_builtin(name = "Sys.unsetenv", min_args = 1)]
1411fn interp_sys_unsetenv(
1412    args: &[RValue],
1413    _named: &[(String, RValue)],
1414    context: &BuiltinContext,
1415) -> Result<RValue, RError> {
1416    let names: Vec<Option<String>> = args
1417        .first()
1418        .and_then(|v| v.as_vector())
1419        .map(|v| v.to_characters())
1420        .unwrap_or_default();
1421
1422    let results: Vec<Option<bool>> = names
1423        .iter()
1424        .map(|n| {
1425            if let Some(name) = n {
1426                context.with_interpreter(|interp| interp.remove_env_var(name));
1427                Some(true)
1428            } else {
1429                Some(false)
1430            }
1431        })
1432        .collect();
1433    Ok(RValue::vec(Vector::Logical(results.into())))
1434}
1435
1436/// Look up the full paths of programs on the system PATH.
1437///
1438/// @param names character vector: program names to search for
1439/// @return named character vector: full paths (empty string if not found),
1440///   with names set to the input program names (matching R behavior)
1441#[interpreter_builtin(name = "Sys.which", min_args = 1)]
1442fn interp_sys_which(
1443    args: &[RValue],
1444    _named: &[(String, RValue)],
1445    context: &BuiltinContext,
1446) -> Result<RValue, RError> {
1447    // Accept a character vector as the first argument (R's Sys.which(c("ls", "cat")))
1448    let names: Vec<Option<String>> = args
1449        .first()
1450        .and_then(|v| v.as_vector())
1451        .map(|v| v.to_characters())
1452        .unwrap_or_default();
1453
1454    let path_var = context
1455        .with_interpreter(|interp| interp.get_env_var("PATH"))
1456        .unwrap_or_default();
1457    let sep = if cfg!(windows) { ';' } else { ':' };
1458    let path_dirs: Vec<&str> = path_var.split(sep).collect();
1459
1460    let results: Vec<Option<String>> = names
1461        .iter()
1462        .map(|name_opt| {
1463            let name = match name_opt {
1464                Some(n) => n,
1465                None => return Some(String::new()),
1466            };
1467            // If the name contains a path separator, check it directly
1468            if name.contains('/') || (cfg!(windows) && name.contains('\\')) {
1469                let p = Path::new(name);
1470                if p.is_file() {
1471                    return Some(p.to_string_lossy().to_string());
1472                }
1473                return Some(String::new());
1474            }
1475            for dir in &path_dirs {
1476                let candidate = Path::new(dir).join(name);
1477                if candidate.is_file() {
1478                    #[cfg(unix)]
1479                    {
1480                        use std::os::unix::fs::PermissionsExt;
1481                        if let Ok(meta) = candidate.metadata() {
1482                            if meta.permissions().mode() & 0o111 != 0 {
1483                                return Some(candidate.to_string_lossy().to_string());
1484                            }
1485                        }
1486                        continue;
1487                    }
1488                    #[cfg(not(unix))]
1489                    {
1490                        return Some(candidate.to_string_lossy().to_string());
1491                    }
1492                }
1493            }
1494            Some(String::new())
1495        })
1496        .collect();
1497
1498    // Return named character vector
1499    let mut rv = RVector::from(Vector::Character(results.into()));
1500    rv.set_attr(
1501        "names".to_string(),
1502        RValue::vec(Vector::Character(names.into())),
1503    );
1504    Ok(RValue::Vector(rv))
1505}
1506
1507/// Set the interpreter's working directory.
1508///
1509/// @param dir character scalar: path to the new working directory
1510/// @return character scalar: the previous working directory
1511#[interpreter_builtin(min_args = 1)]
1512fn interp_setwd(
1513    args: &[RValue],
1514    _named: &[(String, RValue)],
1515    context: &BuiltinContext,
1516) -> Result<RValue, RError> {
1517    let dir = args
1518        .first()
1519        .and_then(|v| v.as_vector()?.as_character_scalar())
1520        .ok_or_else(|| {
1521            RError::new(
1522                RErrorKind::Argument,
1523                "'dir' must be a character string".to_string(),
1524            )
1525        })?;
1526
1527    let path = Path::new(&dir);
1528    if !path.is_dir() {
1529        return Err(SystemError::SetCwd {
1530            path: dir.clone(),
1531            source: std::io::Error::new(std::io::ErrorKind::NotFound, "no such directory"),
1532        }
1533        .into());
1534    }
1535
1536    context.with_interpreter(|interp| {
1537        let old_wd = interp.get_working_dir().to_string_lossy().to_string();
1538        interp.set_working_dir(path.to_path_buf());
1539        Ok(RValue::vec(Vector::Character(vec![Some(old_wd)].into())))
1540    })
1541}
1542
1543// === Sleep ===
1544
1545/// Pause execution for a specified number of seconds.
1546///
1547/// @param time numeric scalar: seconds to sleep (values <= 0 are ignored)
1548/// @return NULL (invisibly)
1549#[interpreter_builtin(name = "Sys.sleep", min_args = 1)]
1550fn interp_sys_sleep(
1551    args: &[RValue],
1552    _named: &[(String, RValue)],
1553    context: &BuiltinContext,
1554) -> Result<RValue, RError> {
1555    let time = args
1556        .first()
1557        .and_then(|v| v.as_vector()?.as_double_scalar())
1558        .ok_or_else(|| {
1559            RError::new(
1560                RErrorKind::Argument,
1561                "'time' must be a numeric value".to_string(),
1562            )
1563        })?;
1564
1565    if time > 0.0 {
1566        std::thread::sleep(std::time::Duration::from_secs_f64(time));
1567    }
1568
1569    context.interpreter().set_invisible();
1570    Ok(RValue::Null)
1571}
1572
1573// === System info ===
1574
1575/// Return system information as a named character vector.
1576///
1577/// Returns all 7 fields that R's Sys.info() provides: sysname, nodename,
1578/// release, version, machine, login, user.
1579///
1580/// @return named character vector with 7 elements
1581#[interpreter_builtin(name = "Sys.info")]
1582fn builtin_sys_info(
1583    _args: &[RValue],
1584    _named: &[(String, RValue)],
1585    context: &BuiltinContext,
1586) -> Result<RValue, RError> {
1587    let sysname = if cfg!(target_os = "macos") {
1588        "Darwin"
1589    } else if cfg!(target_os = "linux") {
1590        "Linux"
1591    } else if cfg!(target_os = "windows") {
1592        "Windows"
1593    } else {
1594        "Unknown"
1595    };
1596
1597    let machine = if cfg!(target_arch = "x86_64") {
1598        "x86_64"
1599    } else if cfg!(target_arch = "aarch64") {
1600        "aarch64"
1601    } else {
1602        "unknown"
1603    };
1604
1605    let nodename = std::process::Command::new("hostname")
1606        .output()
1607        .ok()
1608        .and_then(|o| String::from_utf8(o.stdout).ok())
1609        .map(|s| s.trim().to_string())
1610        .unwrap_or_else(|| "unknown".to_string());
1611
1612    // Get OS release and version via uname -r / uname -v on Unix
1613    let release = std::process::Command::new("uname")
1614        .arg("-r")
1615        .output()
1616        .ok()
1617        .and_then(|o| String::from_utf8(o.stdout).ok())
1618        .map(|s| s.trim().to_string())
1619        .unwrap_or_else(|| "unknown".to_string());
1620
1621    let version = std::process::Command::new("uname")
1622        .arg("-v")
1623        .output()
1624        .ok()
1625        .and_then(|o| String::from_utf8(o.stdout).ok())
1626        .map(|s| s.trim().to_string())
1627        .unwrap_or_else(|| "unknown".to_string());
1628
1629    let user = context
1630        .with_interpreter(|interp| {
1631            interp
1632                .get_env_var("USER")
1633                .or_else(|| interp.get_env_var("USERNAME"))
1634        })
1635        .unwrap_or_else(|| "unknown".to_string());
1636
1637    // R returns a named character vector, not a list
1638    let field_names = vec![
1639        Some("sysname".to_string()),
1640        Some("nodename".to_string()),
1641        Some("release".to_string()),
1642        Some("version".to_string()),
1643        Some("machine".to_string()),
1644        Some("login".to_string()),
1645        Some("user".to_string()),
1646    ];
1647    let field_values = vec![
1648        Some(sysname.to_string()),
1649        Some(nodename),
1650        Some(release),
1651        Some(version),
1652        Some(machine.to_string()),
1653        Some(user.clone()),
1654        Some(user),
1655    ];
1656
1657    let mut rv = RVector::from(Vector::Character(field_values.into()));
1658    rv.set_attr(
1659        "names".to_string(),
1660        RValue::vec(Vector::Character(field_names.into())),
1661    );
1662    Ok(RValue::Vector(rv))
1663}
1664
1665/// Get the current timezone from the TZ environment variable.
1666///
1667/// @return character scalar: timezone string (defaults to "UTC")
1668#[interpreter_builtin(name = "Sys.timezone")]
1669fn builtin_sys_timezone(
1670    _args: &[RValue],
1671    _named: &[(String, RValue)],
1672    context: &BuiltinContext,
1673) -> Result<RValue, RError> {
1674    let tz = context
1675        .with_interpreter(|interp| interp.get_env_var("TZ"))
1676        .unwrap_or_else(|| "UTC".to_string());
1677    Ok(RValue::vec(Vector::Character(vec![Some(tz)].into())))
1678}
1679
1680/// Report which optional features are available in this interpreter.
1681///
1682/// @return named logical vector of capability flags (jpeg, png, etc.)
1683#[builtin]
1684fn builtin_capabilities(_args: &[RValue], _named: &[(String, RValue)]) -> Result<RValue, RError> {
1685    let caps = vec![
1686        ("jpeg", false),
1687        ("png", false),
1688        ("tiff", false),
1689        ("tcltk", false),
1690        ("X11", false),
1691        ("aqua", false),
1692        ("http/ftp", false),
1693        ("sockets", false),
1694        ("libxml", false),
1695        ("fifo", cfg!(unix)),
1696        ("cledit", true),
1697        ("iconv", true),
1698        ("NLS", false),
1699        ("profmem", false),
1700        ("cairo", false),
1701        ("ICU", false),
1702        ("long.double", true),
1703        ("libcurl", false),
1704    ];
1705
1706    let names: Vec<Option<String>> = caps.iter().map(|(n, _)| Some(n.to_string())).collect();
1707    let values: Vec<Option<bool>> = caps.iter().map(|(_, v)| Some(*v)).collect();
1708
1709    let mut rv = RVector::from(Vector::Logical(values.into()));
1710    rv.set_attr(
1711        "names".to_string(),
1712        RValue::vec(Vector::Character(names.into())),
1713    );
1714    Ok(RValue::Vector(rv))
1715}
1716
1717/// Report localization information (encoding support).
1718///
1719/// @return named list with MBCS, UTF-8, and Latin-1 flags
1720#[builtin]
1721fn builtin_l10n_info(_args: &[RValue], _named: &[(String, RValue)]) -> Result<RValue, RError> {
1722    Ok(RValue::List(RList::new(vec![
1723        (
1724            Some("MBCS".to_string()),
1725            RValue::vec(Vector::Logical(vec![Some(true)].into())),
1726        ),
1727        (
1728            Some("UTF-8".to_string()),
1729            RValue::vec(Vector::Logical(vec![Some(true)].into())),
1730        ),
1731        (
1732            Some("Latin-1".to_string()),
1733            RValue::vec(Vector::Logical(vec![Some(false)].into())),
1734        ),
1735    ])))
1736}
1737
1738// region: proc.time
1739
1740/// Get elapsed (wall-clock) time since the interpreter started.
1741///
1742/// Returns a named numeric vector of class `"proc_time"` with elements
1743/// `user.self`, `sys.self`, and `elapsed`. User and system CPU times are
1744/// currently reported as 0 since we don't track per-process CPU usage.
1745///
1746/// @return named double vector of class "proc_time": c(user.self, sys.self, elapsed)
1747#[interpreter_builtin(name = "proc.time")]
1748fn interp_proc_time(
1749    _args: &[RValue],
1750    _named: &[(String, RValue)],
1751    context: &BuiltinContext,
1752) -> Result<RValue, RError> {
1753    let elapsed = context.with_interpreter(|interp| interp.start_instant.elapsed().as_secs_f64());
1754    Ok(make_proc_time(0.0, 0.0, elapsed))
1755}
1756
1757/// Print a `proc_time` object in R's standard format.
1758///
1759/// Formats as:
1760/// ```text
1761///    user  system elapsed
1762///   0.000   0.000   0.123
1763/// ```
1764///
1765/// @param x a proc_time object
1766/// @return x, invisibly
1767#[interpreter_builtin(name = "print.proc_time", min_args = 1)]
1768fn interp_print_proc_time(
1769    args: &[RValue],
1770    _named: &[(String, RValue)],
1771    context: &BuiltinContext,
1772) -> Result<RValue, RError> {
1773    let val = args
1774        .first()
1775        .ok_or_else(|| RError::new(RErrorKind::Argument, "argument is missing".to_string()))?;
1776    let (user, system, elapsed) = match val {
1777        RValue::Vector(rv) => match &rv.inner {
1778            Vector::Double(d) => {
1779                let u = d.first_opt().unwrap_or(0.0);
1780                let s = d.get_opt(1).unwrap_or(0.0);
1781                let e = d.get_opt(2).unwrap_or(0.0);
1782                (u, s, e)
1783            }
1784            _ => (0.0, 0.0, 0.0),
1785        },
1786        _ => (0.0, 0.0, 0.0),
1787    };
1788    context.write(&format!(
1789        "   user  system elapsed\n  {:.3}   {:.3}   {:.3}\n",
1790        user, system, elapsed
1791    ));
1792    context.interpreter().set_invisible();
1793    Ok(val.clone())
1794}
1795
1796/// Construct a `proc_time` RValue with the standard names and class attributes.
1797pub(super) fn make_proc_time(user: f64, system: f64, elapsed: f64) -> RValue {
1798    let mut rv = RVector::from(Vector::Double(
1799        vec![Some(user), Some(system), Some(elapsed)].into(),
1800    ));
1801    rv.set_attr(
1802        "names".to_string(),
1803        RValue::vec(Vector::Character(
1804            vec![
1805                Some("user.self".to_string()),
1806                Some("sys.self".to_string()),
1807                Some("elapsed".to_string()),
1808            ]
1809            .into(),
1810        )),
1811    );
1812    rv.set_attr(
1813        "class".to_string(),
1814        RValue::vec(Vector::Character(
1815            vec![Some("proc_time".to_string())].into(),
1816        )),
1817    );
1818    RValue::Vector(rv)
1819}
1820
1821// endregion
1822
1823/// Return session information (miniR version, platform, locale).
1824///
1825/// @return named list with R.version, platform, and locale
1826#[interpreter_builtin(name = "sessionInfo")]
1827fn builtin_session_info(
1828    _args: &[RValue],
1829    _named: &[(String, RValue)],
1830    context: &BuiltinContext,
1831) -> Result<RValue, RError> {
1832    let locale = context
1833        .with_interpreter(|interp| interp.get_env_var("LANG"))
1834        .unwrap_or_else(|| "C".to_string());
1835    Ok(RValue::List(RList::new(vec![
1836        (
1837            Some("R.version".to_string()),
1838            RValue::List(RList::new(vec![
1839                (
1840                    Some("major".to_string()),
1841                    RValue::vec(Vector::Character(vec![Some("0".to_string())].into())),
1842                ),
1843                (
1844                    Some("minor".to_string()),
1845                    RValue::vec(Vector::Character(vec![Some("1.0".to_string())].into())),
1846                ),
1847                (
1848                    Some("engine".to_string()),
1849                    RValue::vec(Vector::Character(
1850                        vec![Some("miniR (Rust)".to_string())].into(),
1851                    )),
1852                ),
1853            ])),
1854        ),
1855        (
1856            Some("platform".to_string()),
1857            RValue::vec(Vector::Character(
1858                vec![Some(format!(
1859                    "{}-{}",
1860                    std::env::consts::ARCH,
1861                    std::env::consts::OS
1862                ))]
1863                .into(),
1864            )),
1865        ),
1866        (
1867            Some("locale".to_string()),
1868            RValue::vec(Vector::Character(vec![Some(locale)].into())),
1869        ),
1870    ])))
1871}
1872
1873/// Find files in installed packages.
1874///
1875/// Searches the package's installation directory for the specified file
1876/// path components. Returns the full path if found, or "" if not found.
1877///
1878/// @param ... character: path components to join (e.g. "DESCRIPTION", or "data", "mtcars.rda")
1879/// @param package character: the package name to search in
1880/// @param lib.loc character vector: library search paths (defaults to .libPaths())
1881/// @return character scalar: the full path to the file, or "" if not found
1882#[interpreter_builtin(name = "system.file")]
1883fn interp_system_file(
1884    args: &[RValue],
1885    named: &[(String, RValue)],
1886    context: &BuiltinContext,
1887) -> Result<RValue, RError> {
1888    // Extract 'package' named argument
1889    let package = named
1890        .iter()
1891        .find(|(n, _)| n == "package")
1892        .and_then(|(_, v)| v.as_vector()?.as_character_scalar());
1893
1894    // Extract 'lib.loc' named argument (optional)
1895    let lib_loc: Option<Vec<String>> =
1896        named
1897            .iter()
1898            .find(|(n, _)| n == "lib.loc")
1899            .and_then(|(_, v)| {
1900                let vec = v.as_vector()?;
1901                Some(
1902                    vec.to_characters()
1903                        .into_iter()
1904                        .flatten()
1905                        .collect::<Vec<String>>(),
1906                )
1907            });
1908
1909    // Collect positional args as path components (skip any that are named-arg leaks)
1910    let path_parts: Vec<String> = args
1911        .iter()
1912        .filter_map(|v| v.as_vector()?.as_character_scalar())
1913        .collect();
1914
1915    let package_name = match package {
1916        Some(p) if !p.is_empty() => p,
1917        _ => {
1918            // No package specified — return ""
1919            return Ok(RValue::vec(Vector::Character(
1920                vec![Some(String::new())].into(),
1921            )));
1922        }
1923    };
1924
1925    // Build the subpath from the path components
1926    let subpath = if path_parts.is_empty() {
1927        String::new()
1928    } else {
1929        path_parts.join("/")
1930    };
1931
1932    // Search for the package directory
1933    let result = context.with_interpreter(|interp| {
1934        // Use lib.loc if provided, otherwise .libPaths()
1935        let lib_paths = lib_loc.unwrap_or_else(|| interp.get_lib_paths());
1936
1937        for lib_path in &lib_paths {
1938            let pkg_dir = std::path::Path::new(lib_path).join(&package_name);
1939            if !pkg_dir.join("DESCRIPTION").is_file() {
1940                continue;
1941            }
1942            if subpath.is_empty() {
1943                // No subpath: return the package directory itself
1944                return pkg_dir.to_string_lossy().to_string();
1945            }
1946            let target = pkg_dir.join(&subpath);
1947            if target.exists() {
1948                return target.to_string_lossy().to_string();
1949            }
1950        }
1951
1952        // Also check loaded_namespaces for the package's lib_path
1953        if let Some(ns) = interp.loaded_namespaces.borrow().get(&package_name) {
1954            let pkg_dir = &ns.lib_path;
1955            if subpath.is_empty() {
1956                return pkg_dir.to_string_lossy().to_string();
1957            }
1958            let target = pkg_dir.join(&subpath);
1959            if target.exists() {
1960                return target.to_string_lossy().to_string();
1961            }
1962        }
1963
1964        // Not found
1965        String::new()
1966    });
1967
1968    Ok(RValue::vec(Vector::Character(vec![Some(result)].into())))
1969}
1970
1971/// `find.package(package, lib.loc, quiet)` — find the directory of an installed package.
1972///
1973/// @param package character vector of package names
1974/// @param lib.loc library paths (default: .libPaths())
1975/// @param quiet logical: suppress errors for missing packages
1976/// @return character vector of package directories
1977/// @namespace base
1978#[interpreter_builtin(name = "find.package", min_args = 1)]
1979fn interp_find_package(
1980    args: &[RValue],
1981    named: &[(String, RValue)],
1982    context: &BuiltinContext,
1983) -> Result<RValue, RError> {
1984    let packages = match &args[0] {
1985        RValue::Vector(rv) => rv.inner.to_characters(),
1986        _ => vec![],
1987    };
1988    let quiet = named
1989        .iter()
1990        .find(|(n, _)| n == "quiet")
1991        .and_then(|(_, v)| v.as_vector()?.as_logical_scalar())
1992        .unwrap_or(false);
1993
1994    let lib_loc: Option<Vec<String>> =
1995        named
1996            .iter()
1997            .find(|(n, _)| n == "lib.loc")
1998            .and_then(|(_, v)| {
1999                let vec = v.as_vector()?;
2000                Some(vec.to_characters().into_iter().flatten().collect())
2001            });
2002
2003    let results: Vec<Option<String>> = context.with_interpreter(|interp| {
2004        let lib_paths = lib_loc.unwrap_or_else(|| interp.get_lib_paths());
2005        packages
2006            .iter()
2007            .map(|pkg_opt| {
2008                let pkg = pkg_opt.as_deref().unwrap_or("");
2009                if crate::interpreter::Interpreter::is_base_package(pkg) {
2010                    // Base packages are built-in. Return a path that
2011                    // exists if the package is in the CRAN corpus (for
2012                    // packages like renv that inspect base package dirs),
2013                    // otherwise return the include dir as a fallback.
2014                    for lp in &lib_paths {
2015                        let pkg_dir = std::path::Path::new(lp).join(pkg);
2016                        if pkg_dir.is_dir() {
2017                            return Some(pkg_dir.to_string_lossy().to_string());
2018                        }
2019                    }
2020                    // No physical directory — return a path under R_HOME
2021                    return Some(format!("lib/{pkg}"));
2022                }
2023                for lib_path in &lib_paths {
2024                    let pkg_dir = std::path::Path::new(lib_path).join(pkg);
2025                    if pkg_dir.join("DESCRIPTION").is_file() {
2026                        return Some(pkg_dir.to_string_lossy().to_string());
2027                    }
2028                }
2029                None
2030            })
2031            .collect()
2032    });
2033
2034    if results.iter().any(|r| r.is_none()) && !quiet {
2035        let missing: Vec<&str> = packages
2036            .iter()
2037            .zip(&results)
2038            .filter(|(_, r)| r.is_none())
2039            .map(|(p, _)| p.as_deref().unwrap_or(""))
2040            .collect();
2041        return Err(RError::new(
2042            RErrorKind::Other,
2043            format!("there is no package called '{}'", missing.join("', '")),
2044        ));
2045    }
2046
2047    Ok(RValue::vec(Vector::Character(results.into())))
2048}
2049
2050/// Return the process ID of the current R process.
2051///
2052/// @return integer scalar: the PID
2053#[builtin(name = "Sys.getpid")]
2054fn builtin_sys_getpid(_args: &[RValue], _named: &[(String, RValue)]) -> Result<RValue, RError> {
2055    let pid = i64::from(std::process::id());
2056    Ok(RValue::vec(Vector::Integer(vec![Some(pid)].into())))
2057}
2058
2059/// Open a file or URL with the system's default application.
2060///
2061/// Uses `open` on macOS, `xdg-open` on Linux, and `cmd /c start` on Windows.
2062/// This is an interactive utility — it launches an external process and returns
2063/// immediately without waiting for it to finish.
2064///
2065/// @param file character scalar: the file path or URL to open
2066/// @return NULL (invisibly)
2067#[builtin(name = "shell.exec", min_args = 1)]
2068fn builtin_shell_exec(args: &[RValue], _named: &[(String, RValue)]) -> Result<RValue, RError> {
2069    let file = args
2070        .first()
2071        .and_then(|v| v.as_vector()?.as_character_scalar())
2072        .ok_or_else(|| {
2073            RError::new(
2074                RErrorKind::Argument,
2075                "'file' must be a character string".to_string(),
2076            )
2077        })?;
2078
2079    let result = if cfg!(target_os = "macos") {
2080        std::process::Command::new("open").arg(&file).spawn()
2081    } else if cfg!(target_os = "linux") {
2082        std::process::Command::new("xdg-open").arg(&file).spawn()
2083    } else if cfg!(target_os = "windows") {
2084        std::process::Command::new("cmd")
2085            .args(["/c", "start", "", &file])
2086            .spawn()
2087    } else {
2088        return Err(RError::other(
2089            "shell.exec is not supported on this platform".to_string(),
2090        ));
2091    };
2092
2093    match result {
2094        Ok(_) => Ok(RValue::Null),
2095        Err(e) => Err(RError::other(format!("cannot open '{}': {}", file, e))),
2096    }
2097}