rune/
fileio.rs

1//! File I/O.
2use crate::{
3    core::{
4        cons::Cons,
5        env::{Env, sym},
6        error::{Type, TypeError},
7        gc::{Context, Rt},
8        object::{Number, Object, ObjectType, OptionalFlag, Symbol},
9    },
10    data::symbol_value,
11    lisp::maybe_quit,
12    search::lisp_regex_to_rust,
13    sym::{FILE_NAME_HANDLER_ALIST, INHIBIT_FILE_NAME_HANDLERS, NIL},
14};
15use anyhow::{Result, bail, ensure};
16use fancy_regex::Regex;
17use rune_macros::defun;
18use std::path::{Component, MAIN_SEPARATOR, Path};
19
20#[defun]
21pub(crate) fn expand_file_name(
22    name: &str,
23    default_directory: Option<&str>,
24    env: &Rt<Env>,
25    cx: &Context,
26) -> Result<String> {
27    // TODO: this needs to be tested to ensure it has the same behavior as GNU
28    // Emacs. It doesn't do any normalization for one thing.
29    if Path::new(name).is_absolute() {
30        Ok(name.to_owned())
31    } else if let Some(dir) = default_directory {
32        let path = Path::new(dir);
33        Ok(path.join(name).to_string_lossy().to_string())
34    } else {
35        let dir = env.vars.get(sym::DEFAULT_DIRECTORY).unwrap();
36        match dir.untag(cx) {
37            ObjectType::String(dir) => {
38                let path = Path::new(dir.as_ref());
39                Ok(path.join(name).to_string_lossy().to_string())
40            }
41            _ => unreachable!("`default-directory' should be a string"),
42        }
43    }
44}
45
46#[defun]
47fn car_less_than_car(a: &Cons, b: &Cons) -> Result<bool> {
48    let a: Number = a.car().try_into()?;
49    let b: Number = b.car().try_into()?;
50    Ok(a.val() < b.val())
51}
52
53#[defun]
54fn file_accessible_directory_p(filename: &str) -> bool {
55    let path = Path::new(filename);
56    path.exists() && path.is_dir()
57}
58
59#[defun]
60fn file_name_as_directory(filename: &str) -> String {
61    if filename.ends_with(MAIN_SEPARATOR) {
62        filename.to_owned()
63    } else {
64        format!("{filename}{MAIN_SEPARATOR}")
65    }
66}
67
68#[defun]
69fn file_directory_p(filename: &str) -> bool {
70    if filename.is_empty() { true } else { Path::new(filename).is_dir() }
71}
72
73/// Return dirname sans final path separator, unless the string consists entirely of separators.
74#[defun]
75fn directory_file_name(dirname: &str) -> &str {
76    let path = Path::new(dirname);
77    let mut path_components = path.components();
78    if path_components.clone().next().is_none() {
79        return "";
80    }
81
82    if path_components.all(|c| c == Component::RootDir || c == Component::Normal("".as_ref())) {
83        return "/";
84    }
85
86    dirname.strip_suffix(MAIN_SEPARATOR).unwrap_or(dirname)
87}
88
89/// Returns true if the path is absolute
90#[defun]
91fn file_name_absolute_p(filename: &str) -> bool {
92    let path = Path::new(filename);
93    // TODO: GNU Emacs has special handling for ~user directories, where the user exists.
94    //   so as per example in the manual, ~rms/foo is considered absolute if user `rms` exists
95    //   doing this here would require "knowing" the list of valid users and looking for ~path
96    //   components.
97    path.is_absolute()
98}
99
100/// Returns the directory part of `filename`, as a directory name, or nil if filename does not include a directory part.
101#[defun]
102fn file_name_directory(filename: &str) -> Option<String> {
103    // TODO: GNU Emacs docs stipulate that "On MS-DOS [ed: presumably windows,
104    // too] it can also end in a colon."
105    if !filename.contains(MAIN_SEPARATOR) {
106        return None;
107    }
108
109    if filename.ends_with(MAIN_SEPARATOR) {
110        return Some(filename.into());
111    }
112
113    let path = Path::new(filename);
114    let parent = path.parent()?;
115
116    // Special case for root path so we don't end up returning '//'
117    if parent.parent().is_none() {
118        return Some(format!("{MAIN_SEPARATOR}"));
119    }
120    let parent_path = parent.to_str()?;
121    Some(format!("{parent_path}{MAIN_SEPARATOR}"))
122}
123
124/// Returns the non-directory part of `filename`
125#[defun]
126fn file_name_nondirectory(filename: &str) -> &str {
127    if filename.ends_with(MAIN_SEPARATOR) {
128        return "";
129    }
130
131    let path = Path::new(filename);
132    let Some(file_name) = path.file_name() else {
133        return "";
134    };
135
136    file_name.to_str().unwrap()
137}
138
139/// Return non-nil if NAME ends with a directory separator character.
140#[defun]
141fn directory_name_p(name: &str) -> bool {
142    name.ends_with(MAIN_SEPARATOR)
143}
144
145defvar!(FILE_NAME_HANDLER_ALIST);
146defvar!(INHIBIT_FILE_NAME_HANDLERS);
147defsym!(INHIBIT_FILE_NAME_OPERATION);
148
149/// Return FILENAME's handler function for OPERATION, if it has one.
150/// Otherwise, return nil.
151/// A file name is handled if one of the regular expressions in
152/// `file-name-handler-alist' matches it.
153///
154/// If OPERATION equals `inhibit-file-name-operation', then ignore
155/// any handlers that are members of `inhibit-file-name-handlers',
156/// but still do run any other handlers.  This lets handlers
157/// use the standard functions without calling themselves recursively.
158#[defun]
159fn find_file_name_handler<'ob>(
160    filename: &str,
161    operation: Symbol,
162    env: &mut Rt<Env>,
163    cx: &'ob Context,
164) -> Symbol<'ob> {
165    find_file_name_handler_internal(filename, operation, env, cx).unwrap_or(NIL)
166}
167
168fn find_file_name_handler_internal<'ob>(
169    filename: &str,
170    operation: Symbol,
171    env: &mut Rt<Env>,
172    cx: &'ob Context,
173) -> Option<Symbol<'ob>> {
174    let file_name_handler_alist = symbol_value(FILE_NAME_HANDLER_ALIST, env, cx)?.as_list().ok()?;
175    let inhibit_file_name_handlers =
176        symbol_value(INHIBIT_FILE_NAME_HANDLERS, env, cx).and_then(|sym| sym.as_list().ok());
177
178    file_name_handler_alist
179        .filter_map(|elem| {
180            let cons = elem.ok()?.cons()?;
181            let regexp = cons.car().string()?;
182            let re = Regex::new(&lisp_regex_to_rust(regexp))
183                .unwrap_or_else(|err| panic!("Invalid regexp '{regexp}': {err}"));
184            if re.is_match(filename).ok()? {
185                let handler = cons.cdr().symbol()?;
186                if operation != sym::INHIBIT_FILE_NAME_OPERATION
187                    || !inhibit_file_name_handlers
188                        .clone()
189                        .is_some_and(|mut ifnh| ifnh.any(|h| h.is_ok_and(|h| h == handler)))
190                {
191                    return Some(handler);
192                }
193            }
194            maybe_quit();
195            None
196        })
197        .next()
198}
199
200#[defun]
201fn file_symlink_p(filename: &str) -> bool {
202    Path::new(filename).is_symlink()
203}
204
205#[defun]
206fn file_name_case_insensitive_p(filename: &str) -> bool {
207    if !Path::new(filename).exists() {
208        return false;
209    }
210    case_insensitive(filename)
211}
212
213#[cfg(target_os = "macos")]
214fn case_insensitive(filename: &str) -> bool {
215    // https://github.com/phracker/MacOSX-SDKs/blob/master/MacOSX10.1.5.sdk/usr/include/sys/unistd.h#L127
216    const _PC_CASE_SENSITIVE: libc::c_int = 11;
217    let result = unsafe {
218        let filename = std::ffi::CString::new(filename).unwrap();
219        libc::pathconf(filename.as_ptr(), _PC_CASE_SENSITIVE)
220    };
221    if result == -1 {
222        panic!(
223            "file-name-case-insensitive-p pathconf failed: {}",
224            std::io::Error::last_os_error()
225        )
226    }
227    result == 0
228}
229
230#[cfg(windows)]
231fn case_insensitive(filename: &str) -> bool {
232    // https://learn.microsoft.com/en-us/windows/wsl/case-sensitivity#inspect-current-case-sensitivity
233    let output = std::process::Command::new("fsutil.exe")
234        .arg("file")
235        .arg("queryCaseSensitiveInfo")
236        .arg(filename)
237        .output()
238        .unwrap()
239        .stdout;
240    std::str::from_utf8(&output).unwrap().contains("disabled")
241}
242
243#[cfg(target_os = "linux")]
244fn case_insensitive(_filename: &str) -> bool {
245    false
246}
247
248#[test]
249#[cfg(not(miri))]
250fn test_case_sensative_call() {
251    let _ = file_name_case_insensitive_p("/");
252}
253
254#[defun]
255#[expect(clippy::too_many_arguments)]
256fn write_region(
257    start: i64,
258    end: i64,
259    filename: &str,
260    append: OptionalFlag,
261    visit: OptionalFlag,
262    lockname: OptionalFlag,
263    mustbenew: OptionalFlag,
264    env: &Rt<Env>,
265) -> Result<()> {
266    use std::io::Write;
267    ensure!(append.is_none(), "append not implemented");
268    ensure!(visit.is_none(), "visit not implemented");
269    ensure!(lockname.is_none(), "lockname not implemented");
270    ensure!(mustbenew.is_none(), "mustbenew not implemented");
271    // Open filename for writing
272    let mut file = std::fs::OpenOptions::new()
273        .write(true)
274        .create(true)
275        .truncate(true)
276        .open(filename)
277        .unwrap();
278    let b = env.current_buffer.get();
279    let (s1, s2) = b.slice_with_gap(start as usize, end as usize)?;
280    write!(file, "{s1}")?;
281    write!(file, "{s2}")?;
282    Ok(())
283}
284
285/// Concatenate components to directory, inserting path separators as required.
286#[defun]
287fn file_name_concat(directory: &str, rest_components: &[Object]) -> Result<String> {
288    let mut path = String::from(directory);
289
290    // All components must be stringp...
291    for r_c in rest_components {
292        let ObjectType::String(s) = r_c.untag() else {
293            bail!(TypeError::new(Type::String, r_c));
294        };
295
296        // Append separator before adding the new element, but only if the
297        // existing path isn't already terminated with a "/"
298        if !path.ends_with(MAIN_SEPARATOR) {
299            path.push(MAIN_SEPARATOR)
300        }
301
302        path.push_str(s.as_ref());
303    }
304
305    Ok(path)
306}
307
308// TODO: file-relative-name -- requires knowing the current buffer's default directory
309// TODO: file-name-sans-versions
310// TODO: find-file-name-handler: https://www.gnu.org/software/emacs/manual/html_node/elisp/Magic-File-Names.html
311//   required by file-name-extension  & file-name-sans-extension library & file-relative-name functions (among others)
312#[cfg(test)]
313mod tests {
314    use super::*;
315    use crate::{
316        assert_elprop,
317        core::{env::sym, gc::RootSet},
318        data::set,
319        sym::INHIBIT_FILE_NAME_HANDLERS,
320    };
321    use rune_core::macros::{list, root};
322
323    #[test]
324    fn test_find_file_name_handler_matching() {
325        let roots = &RootSet::default();
326        let cx = &mut Context::new(roots);
327        root!(env, new(Env), cx);
328
329        let handler = Symbol::new_uninterned("matching-handler", cx);
330        let not_matching_handler = Symbol::new_uninterned("not-matching-handler", cx);
331        let file_name_handler_alist = list![
332            Cons::new(r"test\.txt", handler, cx),
333            Cons::new("NEVER-MATCH", not_matching_handler, cx);
334            cx
335        ];
336        set(FILE_NAME_HANDLER_ALIST, file_name_handler_alist, env).unwrap();
337
338        let test_txt_handler = find_file_name_handler("test.txt", NIL, env, cx);
339        assert_eq!(test_txt_handler, handler);
340        let not_matching_handler = find_file_name_handler("not-matching.el", NIL, env, cx);
341        assert_eq!(not_matching_handler, NIL);
342    }
343
344    #[test]
345    fn test_find_file_name_handler_inhibit_handlers() {
346        let roots = &RootSet::default();
347        let cx = &mut Context::new(roots);
348        root!(env, new(Env), cx);
349
350        let handler = Symbol::new_uninterned("matching-handler", cx);
351        let inhibit_handler = Symbol::new_uninterned("inhibit-handler", cx);
352        let file_name_handler_alist = list![
353            Cons::new(r"test\.txt", inhibit_handler, cx),
354            Cons::new(r"test\.txt", handler, cx);
355            cx
356        ];
357        set(FILE_NAME_HANDLER_ALIST, file_name_handler_alist, env).unwrap();
358        set(INHIBIT_FILE_NAME_HANDLERS, list![inhibit_handler; cx], env).unwrap();
359
360        let result = find_file_name_handler("test.txt", NIL, env, cx);
361        assert_eq!(result, inhibit_handler);
362
363        let result = find_file_name_handler("test.txt", sym::INHIBIT_FILE_NAME_OPERATION, env, cx);
364        assert_eq!(result, handler);
365    }
366
367    #[test]
368    fn test_find_file_name_handler_no_panic() {
369        let roots = &RootSet::default();
370        let cx = &mut Context::new(roots);
371        let handler = Symbol::new_uninterned("handler", cx);
372        root!(env, new(Env), cx);
373
374        set(FILE_NAME_HANDLER_ALIST, list![Cons::new(NIL, handler, cx); cx], env).unwrap();
375        find_file_name_handler("example", NIL, env, cx);
376
377        set(FILE_NAME_HANDLER_ALIST, list![Cons::new(".*", NIL , cx); cx], env).unwrap();
378        find_file_name_handler("example", NIL, env, cx);
379    }
380
381    #[test]
382    #[should_panic(
383        expected = "Invalid regexp '\\\\(incorrect-regexp': Parsing error at position 17: Opening parenthesis without closing parenthesis"
384    )]
385    fn test_find_file_name_handler_panic_on_invalid_regex() {
386        let roots = &RootSet::default();
387        let cx = &mut Context::new(roots);
388        let handler = Symbol::new_uninterned("handler", cx);
389        root!(env, new(Env), cx);
390
391        set(
392            FILE_NAME_HANDLER_ALIST,
393            list![Cons::new(r#"\(incorrect-regexp"#, handler, cx); cx],
394            env,
395        )
396        .unwrap();
397        find_file_name_handler("example", NIL, env, cx);
398    }
399
400    #[test]
401    #[ignore = "TODO: Handle ~ in expand-file-name"]
402    fn test_expand_file_name() {
403        assert_elprop![r#"(expand-file-name "~/test.txt" "/")"#];
404    }
405}