rune/
fileio.rs

1//! File I/O.
2use crate::core::{
3    cons::Cons,
4    env::{Env, sym},
5    error::{Type, TypeError},
6    gc::{Context, Rt},
7    object::{Number, Object, ObjectType, OptionalFlag},
8};
9use anyhow::{Result, bail, ensure};
10use rune_macros::defun;
11use std::path::{Component, MAIN_SEPARATOR, Path};
12
13defvar!(FILE_NAME_HANDLER_ALIST);
14
15#[defun]
16pub(crate) fn expand_file_name(
17    name: &str,
18    default_directory: Option<&str>,
19    env: &Rt<Env>,
20    cx: &Context,
21) -> Result<String> {
22    // TODO: this needs to be tested to ensure it has the same behavior as GNU
23    // Emacs. It doesn't do any normalization for one thing.
24    if Path::new(name).is_absolute() {
25        Ok(name.to_owned())
26    } else if let Some(dir) = default_directory {
27        let path = Path::new(dir);
28        Ok(path.join(name).to_string_lossy().to_string())
29    } else {
30        let dir = env.vars.get(sym::DEFAULT_DIRECTORY).unwrap();
31        match dir.untag(cx) {
32            ObjectType::String(dir) => {
33                let path = Path::new(dir.as_ref());
34                Ok(path.join(name).to_string_lossy().to_string())
35            }
36            _ => unreachable!("`default-directory' should be a string"),
37        }
38    }
39}
40
41#[defun]
42fn car_less_than_car(a: &Cons, b: &Cons) -> Result<bool> {
43    let a: Number = a.car().try_into()?;
44    let b: Number = b.car().try_into()?;
45    Ok(a.val() < b.val())
46}
47
48#[defun]
49fn file_name_as_directory(filename: &str) -> String {
50    if filename.ends_with(MAIN_SEPARATOR) {
51        filename.to_owned()
52    } else {
53        format!("{filename}{MAIN_SEPARATOR}")
54    }
55}
56
57#[defun]
58fn file_directory_p(filename: &str) -> bool {
59    if filename.is_empty() { true } else { Path::new(filename).is_dir() }
60}
61
62/// Return dirname sans final path separator, unless the string consists entirely of separators.
63#[defun]
64fn directory_file_name(dirname: &str) -> &str {
65    let path = Path::new(dirname);
66    let mut path_components = path.components();
67    if path_components.clone().next().is_none() {
68        return "";
69    }
70
71    if path_components.all(|c| c == Component::RootDir || c == Component::Normal("".as_ref())) {
72        return "/";
73    }
74
75    dirname.strip_suffix(MAIN_SEPARATOR).unwrap_or(dirname)
76}
77
78/// Returns true if the path is absolute
79#[defun]
80fn file_name_absolute_p(filename: &str) -> bool {
81    let path = Path::new(filename);
82    // TODO: GNU Emacs has special handling for ~user directories, where the user exists.
83    //   so as per example in the manual, ~rms/foo is considered absolute if user `rms` exists
84    //   doing this here would require "knowing" the list of valid users and looking for ~path
85    //   components.
86    path.is_absolute()
87}
88
89/// Returns the directory part of `filename`, as a directory name, or nil if filename does not include a directory part.
90#[defun]
91fn file_name_directory(filename: &str) -> Option<String> {
92    // TODO: GNU Emacs docs stipulate that "On MS-DOS [ed: presumably windows,
93    // too] it can also end in a colon."
94    if !filename.contains(MAIN_SEPARATOR) {
95        return None;
96    }
97
98    if filename.ends_with(MAIN_SEPARATOR) {
99        return Some(filename.into());
100    }
101
102    let path = Path::new(filename);
103    let parent = path.parent()?;
104
105    // Special case for root path so we don't end up returning '//'
106    if parent.parent().is_none() {
107        return Some(format!("{MAIN_SEPARATOR}"));
108    }
109    let parent_path = parent.to_str()?;
110    Some(format!("{parent_path}{MAIN_SEPARATOR}"))
111}
112
113/// Returns the non-directory part of `filename`
114#[defun]
115fn file_name_nondirectory(filename: &str) -> &str {
116    if filename.ends_with(MAIN_SEPARATOR) {
117        return "";
118    }
119
120    let path = Path::new(filename);
121    let Some(file_name) = path.file_name() else {
122        return "";
123    };
124
125    file_name.to_str().unwrap()
126}
127
128/// Return non-nil if NAME ends with a directory separator character.
129#[defun]
130fn directory_name_p(name: &str) -> bool {
131    name.ends_with(MAIN_SEPARATOR)
132}
133
134#[defun]
135fn find_file_name_handler(_filename: &str, _operation: Object) {
136    // TODO: implement file-name-handler-alist
137}
138
139#[defun]
140fn file_symlink_p(filename: &str) -> bool {
141    Path::new(filename).is_symlink()
142}
143
144#[defun]
145fn file_name_case_insensitive_p(filename: &str) -> bool {
146    if !Path::new(filename).exists() {
147        return false;
148    }
149    case_insensitive(filename)
150}
151
152#[cfg(target_os = "macos")]
153fn case_insensitive(filename: &str) -> bool {
154    // https://github.com/phracker/MacOSX-SDKs/blob/master/MacOSX10.1.5.sdk/usr/include/sys/unistd.h#L127
155    const _PC_CASE_SENSITIVE: libc::c_int = 11;
156    let result = unsafe {
157        let filename = std::ffi::CString::new(filename).unwrap();
158        libc::pathconf(filename.as_ptr(), _PC_CASE_SENSITIVE)
159    };
160    if result == -1 {
161        panic!(
162            "file-name-case-insensitive-p pathconf failed: {}",
163            std::io::Error::last_os_error()
164        )
165    }
166    result == 0
167}
168
169#[cfg(windows)]
170fn case_insensitive(filename: &str) -> bool {
171    // https://learn.microsoft.com/en-us/windows/wsl/case-sensitivity#inspect-current-case-sensitivity
172    let output = std::process::Command::new("fsutil.exe")
173        .arg("file")
174        .arg("queryCaseSensitiveInfo")
175        .arg(filename)
176        .output()
177        .unwrap()
178        .stdout;
179    std::str::from_utf8(&output).unwrap().contains("disabled")
180}
181
182#[cfg(target_os = "linux")]
183fn case_insensitive(_filename: &str) -> bool {
184    false
185}
186
187#[test]
188#[cfg(not(miri))]
189fn test_case_sensative_call() {
190    let _ = file_name_case_insensitive_p("/");
191}
192
193#[defun]
194#[expect(clippy::too_many_arguments)]
195fn write_region(
196    start: i64,
197    end: i64,
198    filename: &str,
199    append: OptionalFlag,
200    visit: OptionalFlag,
201    lockname: OptionalFlag,
202    mustbenew: OptionalFlag,
203    env: &Rt<Env>,
204) -> Result<()> {
205    use std::io::Write;
206    ensure!(append.is_none(), "append not implemented");
207    ensure!(visit.is_none(), "visit not implemented");
208    ensure!(lockname.is_none(), "lockname not implemented");
209    ensure!(mustbenew.is_none(), "mustbenew not implemented");
210    // Open filename for writing
211    let mut file = std::fs::OpenOptions::new()
212        .write(true)
213        .create(true)
214        .truncate(true)
215        .open(filename)
216        .unwrap();
217    let b = env.current_buffer.get();
218    let (s1, s2) = b.slice_with_gap(start as usize, end as usize)?;
219    write!(file, "{s1}")?;
220    write!(file, "{s2}")?;
221    Ok(())
222}
223
224/// Concatenate components to directory, inserting path separators as required.
225#[defun]
226fn file_name_concat(directory: &str, rest_components: &[Object]) -> Result<String> {
227    let mut path = String::from(directory);
228
229    // All components must be stringp...
230    for r_c in rest_components {
231        let ObjectType::String(s) = r_c.untag() else {
232            bail!(TypeError::new(Type::String, r_c));
233        };
234
235        // Append separator before adding the new element, but only if the
236        // existing path isn't already terminated with a "/"
237        if !path.ends_with(MAIN_SEPARATOR) {
238            path.push(MAIN_SEPARATOR)
239        }
240
241        path.push_str(s.as_ref());
242    }
243
244    Ok(path)
245}
246
247// TODO: file-relative-name -- requires knowing the current buffer's default directory
248// TODO: file-name-sans-versions
249// TODO: find-file-name-handler: https://www.gnu.org/software/emacs/manual/html_node/elisp/Magic-File-Names.html
250//   required by file-name-extension  & file-name-sans-extension library & file-relative-name functions (among others)