Skip to main content

stryke/
perl_fs.rs

1//! Perl-style filesystem helpers (`stat`, `glob`, etc.).
2
3use rand::Rng;
4use rayon::prelude::*;
5use std::env;
6use std::io::{self, BufRead, Write};
7use std::path::{Path, PathBuf};
8use std::sync::Arc;
9use std::time::UNIX_EPOCH;
10
11use crate::pmap_progress::PmapProgress;
12use crate::value::PerlValue;
13
14pub use crate::perl_decode::{
15    decode_utf8_or_latin1, decode_utf8_or_latin1_line, decode_utf8_or_latin1_read_until,
16};
17
18/// Read a file as text for Perl source or slurped data. Unlike [`std::fs::read_to_string`], this
19/// does not reject bytes that are not valid UTF-8 (stock `perl` accepts such files by default).
20pub fn read_file_text_perl_compat(path: impl AsRef<Path>) -> io::Result<String> {
21    let bytes = std::fs::read(path.as_ref())?;
22    Ok(decode_utf8_or_latin1(&bytes))
23}
24
25/// `glob_with_options` preset for stryke: full extended glob, bare-qualifier
26/// shorthand on (`*(/)` works without `(#q.../)`), `null_glob: true` so a
27/// no-match returns an empty list (Perl `glob` semantics) instead of echoing
28/// the literal pattern back. `glob_star_short: true` makes `**.stk` match
29/// every .stk file at any depth, `brace_ccl: true` enables `{a,b,c}` and
30/// `{abc}` brace expansion — both wired through to the expander in
31/// zshrs ≥0.10.4.
32fn stryke_glob(pattern: &str) -> Vec<String> {
33    // zshrs glob fails to match wildcards behind a leading `./` (works for
34    // literal filenames but not patterns like `./lib/*.stk`). Strip the
35    // prefix before delegating, then re-prepend it to each result so callers
36    // see the path they asked for. `./` is semantic-equivalent to no prefix
37    // (current directory), so this normalisation is safe.
38    let (stripped, had_dot_slash) = if let Some(rest) = pattern.strip_prefix("./") {
39        (rest, true)
40    } else {
41        (pattern, false)
42    };
43    let results = zsh::glob::glob_with_options(
44        stripped,
45        zsh::glob::GlobOptions {
46            null_glob: true,
47            mark_dirs: false,
48            no_glob_dots: true,
49            list_types: false,
50            numeric_sort: false,
51            follow_links: false,
52            extended_glob: true,
53            case_glob: true,
54            glob_star_short: true,
55            bare_glob_qual: true,
56            brace_ccl: true,
57        },
58    );
59    if had_dot_slash {
60        results
61            .into_iter()
62            .map(|p| {
63                if p.starts_with("./") {
64                    p
65                } else {
66                    format!("./{}", p)
67                }
68            })
69            .collect()
70    } else {
71        results
72    }
73}
74
75/// `slurp`/`cat`/`c` payload — accepts a literal path OR a zsh-style glob
76/// pattern (forwarded to [`zsh::glob`], so the entire qualifier set `(/)`,
77/// `(.)`, `(@)`, `(L+10)`, `(mh-1)`, `(om)`, `(N)`, … all work). World-first:
78/// zsh glob qualifiers in a scripting language. When the qualifier filters
79/// away every regular file (e.g. `c("**(/)")` returns directories only) we
80/// hard-fail rather than silently return empty — slurping a directory is
81/// meaningless and asking for it is a bug.
82///
83/// Routing: a path goes through the glob expander when it has wildcards
84/// (per zshrs's [`zsh::glob::has_wildcards`]) OR a trailing bare qualifier
85/// suffix (`dir(/)` / `name(.)`). Otherwise we read the literal path so a
86/// missing file produces the proper `No such file or directory` instead of
87/// a silent empty.
88pub fn read_file_text_or_glob(path: &str) -> io::Result<String> {
89    let (stripped, qual) = zsh::glob::split_qualifier(path);
90    let is_glob = qual.is_some() || zsh::glob::has_wildcards(stripped);
91    if !is_glob {
92        return read_file_text_perl_compat(path);
93    }
94    let paths = stryke_glob(path);
95    if paths.is_empty() {
96        return Err(io::Error::new(
97            io::ErrorKind::NotFound,
98            format!("no files matched glob: {}", path),
99        ));
100    }
101    let mut out = String::new();
102    for p in &paths {
103        let meta = std::fs::metadata(p)?;
104        if !meta.is_file() {
105            return Err(io::Error::new(
106                io::ErrorKind::InvalidInput,
107                format!("slurp: not a regular file: {}", p),
108            ));
109        }
110        out.push_str(&read_file_text_perl_compat(p)?);
111    }
112    Ok(out)
113}
114
115/// Pattern routes to [`zsh::glob`] when it has wildcards or a bare qualifier
116/// suffix; literal paths short-circuit so callers can preserve "no such
117/// file" diagnostics. Wraps zshrs's own predicates so stryke owns no
118/// pattern-parsing logic of its own.
119fn pattern_is_glob(path: &str) -> bool {
120    let (stripped, qual) = zsh::glob::split_qualifier(path);
121    qual.is_some() || zsh::glob::has_wildcards(stripped) || zsh::glob::has_braces(stripped, true)
122}
123
124/// Like [`BufRead::read_line`] but decodes with [`decode_utf8_or_latin1_read_until`] (no U+FFFD).
125pub fn read_line_perl_compat(reader: &mut impl BufRead, buf: &mut String) -> io::Result<usize> {
126    buf.clear();
127    let mut raw = Vec::new();
128    let n = reader.read_until(b'\n', &mut raw)?;
129    if n == 0 {
130        return Ok(0);
131    }
132    buf.push_str(&decode_utf8_or_latin1_read_until(&raw));
133    Ok(n)
134}
135
136/// One line from `reader` (delimiter `\n`), content **without** trailing `\n` / `\r\n` / `\r`,
137/// same as [`BufRead::lines`] but UTF-8 or Latin-1 per line.
138pub fn read_logical_line_perl_compat(reader: &mut impl BufRead) -> io::Result<Option<String>> {
139    let mut buf = Vec::new();
140    let n = reader.read_until(b'\n', &mut buf)?;
141    if n == 0 {
142        return Ok(None);
143    }
144    if buf.ends_with(b"\n") {
145        buf.pop();
146        if buf.ends_with(b"\r") {
147            buf.pop();
148        }
149    }
150    Ok(Some(decode_utf8_or_latin1_line(&buf)))
151}
152
153/// Perl `-t` — true if the handle/path refers to a terminal ([`libc::isatty`] on Unix).
154/// Recognizes `STDIN`/`STDOUT`/`STDERR`, `/dev/stdin` (etc.), `/dev/fd/N`, small numeric fds, or opens a path and tests its fd.
155pub fn filetest_is_tty(path: &str) -> bool {
156    #[cfg(unix)]
157    {
158        use std::os::unix::io::AsRawFd;
159        if let Some(fd) = tty_fd_literal(path) {
160            return unsafe { libc::isatty(fd) != 0 };
161        }
162        if let Ok(f) = std::fs::File::open(path) {
163            return unsafe { libc::isatty(f.as_raw_fd()) != 0 };
164        }
165    }
166    #[cfg(not(unix))]
167    {
168        let _ = path;
169    }
170    false
171}
172
173#[cfg(unix)]
174fn tty_fd_literal(path: &str) -> Option<i32> {
175    match path {
176        "" | "STDIN" | "-" | "/dev/stdin" => Some(0),
177        "STDOUT" | "/dev/stdout" => Some(1),
178        "STDERR" | "/dev/stderr" => Some(2),
179        p if p.starts_with("/dev/fd/") => p.strip_prefix("/dev/fd/").and_then(|s| s.parse().ok()),
180        _ => path.parse::<i32>().ok().filter(|&n| (0..128).contains(&n)),
181    }
182}
183
184/// Check if effective uid/gid has the given access to a file.
185/// `check` is one of 4 (read), 2 (write), 1 (execute).
186#[cfg(unix)]
187pub fn filetest_effective_access(path: &str, check: u32) -> bool {
188    use std::os::unix::fs::MetadataExt;
189    let meta = match std::fs::metadata(path) {
190        Ok(m) => m,
191        Err(_) => return false,
192    };
193    let mode = meta.mode();
194    let euid = unsafe { libc::geteuid() };
195    let egid = unsafe { libc::getegid() };
196    // Root can read/write anything, execute if any x bit set
197    if euid == 0 {
198        return if check == 1 { mode & 0o111 != 0 } else { true };
199    }
200    if meta.uid() == euid {
201        return mode & (check << 6) != 0;
202    }
203    if meta.gid() == egid {
204        return mode & (check << 3) != 0;
205    }
206    mode & check != 0
207}
208
209/// Check if real uid/gid has the given access (uses libc::access).
210#[cfg(unix)]
211pub fn filetest_real_access(path: &str, amode: libc::c_int) -> bool {
212    match std::ffi::CString::new(path) {
213        Ok(c) => unsafe { libc::access(c.as_ptr(), amode) == 0 },
214        Err(_) => false,
215    }
216}
217
218/// Is the file owned by effective uid?
219#[cfg(unix)]
220pub fn filetest_owned_effective(path: &str) -> bool {
221    use std::os::unix::fs::MetadataExt;
222    std::fs::metadata(path)
223        .map(|m| m.uid() == unsafe { libc::geteuid() })
224        .unwrap_or(false)
225}
226
227/// Is the file owned by real uid?
228#[cfg(unix)]
229pub fn filetest_owned_real(path: &str) -> bool {
230    use std::os::unix::fs::MetadataExt;
231    std::fs::metadata(path)
232        .map(|m| m.uid() == unsafe { libc::getuid() })
233        .unwrap_or(false)
234}
235
236/// Is the file a named pipe (FIFO)?
237#[cfg(unix)]
238pub fn filetest_is_pipe(path: &str) -> bool {
239    use std::os::unix::fs::FileTypeExt;
240    std::fs::metadata(path)
241        .map(|m| m.file_type().is_fifo())
242        .unwrap_or(false)
243}
244
245/// Is the file a socket?
246#[cfg(unix)]
247pub fn filetest_is_socket(path: &str) -> bool {
248    use std::os::unix::fs::FileTypeExt;
249    std::fs::metadata(path)
250        .map(|m| m.file_type().is_socket())
251        .unwrap_or(false)
252}
253
254/// Is the file a block device?
255#[cfg(unix)]
256pub fn filetest_is_block_device(path: &str) -> bool {
257    use std::os::unix::fs::FileTypeExt;
258    std::fs::metadata(path)
259        .map(|m| m.file_type().is_block_device())
260        .unwrap_or(false)
261}
262
263/// Is the file a character device?
264#[cfg(unix)]
265pub fn filetest_is_char_device(path: &str) -> bool {
266    use std::os::unix::fs::FileTypeExt;
267    std::fs::metadata(path)
268        .map(|m| m.file_type().is_char_device())
269        .unwrap_or(false)
270}
271
272/// Is setuid bit set?
273#[cfg(unix)]
274pub fn filetest_is_setuid(path: &str) -> bool {
275    use std::os::unix::fs::MetadataExt;
276    std::fs::metadata(path)
277        .map(|m| m.mode() & 0o4000 != 0)
278        .unwrap_or(false)
279}
280
281/// Is setgid bit set?
282#[cfg(unix)]
283pub fn filetest_is_setgid(path: &str) -> bool {
284    use std::os::unix::fs::MetadataExt;
285    std::fs::metadata(path)
286        .map(|m| m.mode() & 0o2000 != 0)
287        .unwrap_or(false)
288}
289
290/// Is sticky bit set?
291#[cfg(unix)]
292pub fn filetest_is_sticky(path: &str) -> bool {
293    use std::os::unix::fs::MetadataExt;
294    std::fs::metadata(path)
295        .map(|m| m.mode() & 0o1000 != 0)
296        .unwrap_or(false)
297}
298
299/// Is the file a text file? (Perl heuristic: read first block, check for high proportion of printable chars)
300pub fn filetest_is_text(path: &str) -> bool {
301    filetest_text_binary(path, true)
302}
303
304/// Is the file a binary file? (opposite of text)
305pub fn filetest_is_binary(path: &str) -> bool {
306    filetest_text_binary(path, false)
307}
308
309fn filetest_text_binary(path: &str, want_text: bool) -> bool {
310    use std::io::Read;
311    let mut f = match std::fs::File::open(path) {
312        Ok(f) => f,
313        Err(_) => return false,
314    };
315    let mut buf = [0u8; 512];
316    let n = match f.read(&mut buf) {
317        Ok(n) => n,
318        Err(_) => return false,
319    };
320    if n == 0 {
321        // Empty files are considered text in Perl
322        return want_text;
323    }
324    let slice = &buf[..n];
325    // Count bytes that are "non-text": NUL and control chars (except \t \n \r \x1b)
326    let non_text = slice
327        .iter()
328        .filter(|&&b| b == 0 || (b < 0x20 && b != b'\t' && b != b'\n' && b != b'\r' && b != 0x1b))
329        .count();
330    let is_text = (non_text as f64 / n as f64) < 0.30;
331    if want_text {
332        is_text
333    } else {
334        !is_text
335    }
336}
337
338/// File age in fractional days since now. `which`: 'M' = mtime, 'A' = atime, 'C' = ctime.
339#[cfg(unix)]
340pub fn filetest_age_days(path: &str, which: char) -> Option<f64> {
341    use std::os::unix::fs::MetadataExt;
342    let meta = std::fs::metadata(path).ok()?;
343    let t = match which {
344        'M' => meta.mtime() as f64,
345        'A' => meta.atime() as f64,
346        _ => meta.ctime() as f64,
347    };
348    let now = std::time::SystemTime::now()
349        .duration_since(std::time::UNIX_EPOCH)
350        .unwrap_or_default()
351        .as_secs_f64();
352    Some((now - t) / 86400.0)
353}
354
355/// 13-element `stat` / `lstat` list (empty vector on failure).
356pub fn stat_path(path: &str, symlink: bool) -> PerlValue {
357    let res = if symlink {
358        std::fs::symlink_metadata(path)
359    } else {
360        std::fs::metadata(path)
361    };
362    match res {
363        Ok(meta) => PerlValue::array(perl_stat_from_metadata(&meta)),
364        Err(_) => PerlValue::array(vec![]),
365    }
366}
367
368pub fn perl_stat_from_metadata(meta: &std::fs::Metadata) -> Vec<PerlValue> {
369    #[cfg(unix)]
370    {
371        use std::os::unix::fs::MetadataExt;
372        vec![
373            PerlValue::integer(meta.dev() as i64),
374            PerlValue::integer(meta.ino() as i64),
375            PerlValue::integer(meta.mode() as i64),
376            PerlValue::integer(meta.nlink() as i64),
377            PerlValue::integer(meta.uid() as i64),
378            PerlValue::integer(meta.gid() as i64),
379            PerlValue::integer(meta.rdev() as i64),
380            PerlValue::integer(meta.len() as i64),
381            PerlValue::integer(meta.atime()),
382            PerlValue::integer(meta.mtime()),
383            PerlValue::integer(meta.ctime()),
384            PerlValue::integer(meta.blksize() as i64),
385            PerlValue::integer(meta.blocks() as i64),
386        ]
387    }
388    #[cfg(not(unix))]
389    {
390        let len = meta.len() as i64;
391        vec![
392            PerlValue::integer(0),
393            PerlValue::integer(0),
394            PerlValue::integer(0),
395            PerlValue::integer(0),
396            PerlValue::integer(0),
397            PerlValue::integer(0),
398            PerlValue::integer(0),
399            PerlValue::integer(len),
400            PerlValue::integer(0),
401            PerlValue::integer(0),
402            PerlValue::integer(0),
403            PerlValue::integer(0),
404            PerlValue::integer(0),
405        ]
406    }
407}
408
409pub fn link_hard(old: &str, new: &str) -> PerlValue {
410    PerlValue::integer(if std::fs::hard_link(old, new).is_ok() {
411        1
412    } else {
413        0
414    })
415}
416
417pub fn link_sym(old: &str, new: &str) -> PerlValue {
418    #[cfg(unix)]
419    {
420        use std::os::unix::fs::symlink;
421        PerlValue::integer(if symlink(old, new).is_ok() { 1 } else { 0 })
422    }
423    #[cfg(not(unix))]
424    {
425        let _ = (old, new);
426        PerlValue::integer(0)
427    }
428}
429
430pub fn read_link(path: &str) -> PerlValue {
431    match std::fs::read_link(path) {
432        Ok(p) => PerlValue::string(p.to_string_lossy().into_owned()),
433        Err(_) => PerlValue::UNDEF,
434    }
435}
436
437/// Absolute path with symlinks resolved (`std::fs::canonicalize`); all path components must exist.
438pub fn realpath_resolved(path: &str) -> io::Result<String> {
439    std::fs::canonicalize(path).map(|p| p.to_string_lossy().into_owned())
440}
441
442/// Normalize `.` / `..` and redundant separators without touching the disk (Perl
443/// `File::Spec->canonpath`-like). Unlike [`std::path::Path::components`] alone, this collapses
444/// `foo/..` in relative paths instead of preserving `..` for symlink safety.
445pub fn canonpath_logical(path: &str) -> String {
446    use std::path::Component;
447    if path.is_empty() {
448        return String::new();
449    }
450    let mut stack: Vec<String> = Vec::new();
451    let mut anchored = false;
452    for c in Path::new(path).components() {
453        match c {
454            Component::Prefix(p) => {
455                stack.push(p.as_os_str().to_string_lossy().into_owned());
456            }
457            Component::RootDir => {
458                anchored = true;
459                stack.clear();
460            }
461            Component::CurDir => {}
462            Component::Normal(s) => {
463                stack.push(s.to_string_lossy().into_owned());
464            }
465            Component::ParentDir => {
466                if anchored {
467                    if !stack.is_empty() {
468                        stack.pop();
469                    }
470                } else if stack.is_empty() || stack.last().is_some_and(|t| t == "..") {
471                    stack.push("..".to_string());
472                } else {
473                    stack.pop();
474                }
475            }
476        }
477    }
478    let body = stack.join("/");
479    if anchored {
480        if body.is_empty() {
481            "/".to_string()
482        } else {
483            format!("/{body}")
484        }
485    } else if body.is_empty() {
486        ".".to_string()
487    } else {
488        body
489    }
490}
491
492/// List file/directory names inside `dir` (non-recursive), sorted.
493/// Returns an empty list if `dir` cannot be read.
494pub fn list_files(dir: &str) -> PerlValue {
495    let mut names: Vec<String> = Vec::new();
496    if let Ok(entries) = std::fs::read_dir(dir) {
497        for entry in entries.flatten() {
498            if let Some(name) = entry.file_name().to_str() {
499                names.push(name.to_string());
500            }
501        }
502    }
503    names.sort();
504    PerlValue::array(names.into_iter().map(PerlValue::string).collect())
505}
506
507/// List only regular file names inside `dir` (non-recursive), sorted.
508/// Excludes directories, symlinks, and special files.
509/// Returns an empty list if `dir` cannot be read.
510pub fn list_filesf(dir: &str) -> PerlValue {
511    let mut names: Vec<String> = Vec::new();
512    if let Ok(entries) = std::fs::read_dir(dir) {
513        for entry in entries.flatten() {
514            if entry.file_type().map(|ft| ft.is_file()).unwrap_or(false) {
515                if let Some(name) = entry.file_name().to_str() {
516                    names.push(name.to_string());
517                }
518            }
519        }
520    }
521    names.sort();
522    PerlValue::array(names.into_iter().map(PerlValue::string).collect())
523}
524
525/// List only regular file paths under `dir` **recursively**, sorted.
526/// Returns relative paths from `dir` (e.g. `"sub/file.txt"`).
527/// Returns an empty list if `dir` cannot be read.
528pub fn list_filesf_recursive(dir: &str) -> PerlValue {
529    let root = std::path::Path::new(dir);
530    let mut paths: Vec<String> = Vec::new();
531    fn walk(base: &std::path::Path, rel: &str, out: &mut Vec<String>) {
532        let Ok(entries) = std::fs::read_dir(base) else {
533            return;
534        };
535        for entry in entries.flatten() {
536            let ft = match entry.file_type() {
537                Ok(ft) => ft,
538                Err(_) => continue,
539            };
540            let name = match entry.file_name().into_string() {
541                Ok(n) => n,
542                Err(_) => continue,
543            };
544            let child_rel = if rel.is_empty() {
545                name.clone()
546            } else {
547                format!("{rel}/{name}")
548            };
549            if ft.is_file() {
550                out.push(child_rel);
551            } else if ft.is_dir() {
552                walk(&base.join(&name), &child_rel, out);
553            }
554        }
555    }
556    walk(root, "", &mut paths);
557    paths.sort();
558    PerlValue::array(paths.into_iter().map(PerlValue::string).collect())
559}
560
561/// List only directory names inside `dir` (non-recursive), sorted.
562/// Returns an empty list if `dir` cannot be read.
563pub fn list_dirs(dir: &str) -> PerlValue {
564    let mut names: Vec<String> = Vec::new();
565    if let Ok(entries) = std::fs::read_dir(dir) {
566        for entry in entries.flatten() {
567            if entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false) {
568                if let Some(name) = entry.file_name().to_str() {
569                    names.push(name.to_string());
570                }
571            }
572        }
573    }
574    names.sort();
575    PerlValue::array(names.into_iter().map(PerlValue::string).collect())
576}
577
578/// List subdirectory paths under `dir` **recursively**, sorted.
579/// Returns relative paths from `dir` (e.g. `"sub/nested"`).
580/// Returns an empty list if `dir` cannot be read.
581pub fn list_dirs_recursive(dir: &str) -> PerlValue {
582    let root = std::path::Path::new(dir);
583    let mut paths: Vec<String> = Vec::new();
584    fn walk(base: &std::path::Path, rel: &str, out: &mut Vec<String>) {
585        let Ok(entries) = std::fs::read_dir(base) else {
586            return;
587        };
588        for entry in entries.flatten() {
589            let ft = match entry.file_type() {
590                Ok(ft) => ft,
591                Err(_) => continue,
592            };
593            if !ft.is_dir() {
594                continue;
595            }
596            let name = match entry.file_name().into_string() {
597                Ok(n) => n,
598                Err(_) => continue,
599            };
600            let child_rel = if rel.is_empty() {
601                name.clone()
602            } else {
603                format!("{rel}/{name}")
604            };
605            out.push(child_rel.clone());
606            walk(&base.join(&name), &child_rel, out);
607        }
608    }
609    walk(root, "", &mut paths);
610    paths.sort();
611    PerlValue::array(paths.into_iter().map(PerlValue::string).collect())
612}
613
614/// List only symlink names inside `dir` (non-recursive), sorted.
615/// Returns an empty list if `dir` cannot be read.
616pub fn list_sym_links(dir: &str) -> PerlValue {
617    let mut names: Vec<String> = Vec::new();
618    if let Ok(entries) = std::fs::read_dir(dir) {
619        for entry in entries.flatten() {
620            if entry.file_type().map(|ft| ft.is_symlink()).unwrap_or(false) {
621                if let Some(name) = entry.file_name().to_str() {
622                    names.push(name.to_string());
623                }
624            }
625        }
626    }
627    names.sort();
628    PerlValue::array(names.into_iter().map(PerlValue::string).collect())
629}
630
631/// List only Unix socket names inside `dir` (non-recursive), sorted.
632/// Returns an empty list if `dir` cannot be read or on non-Unix platforms.
633pub fn list_sockets(dir: &str) -> PerlValue {
634    let mut names: Vec<String> = Vec::new();
635    #[cfg(unix)]
636    {
637        use std::os::unix::fs::FileTypeExt;
638        if let Ok(entries) = std::fs::read_dir(dir) {
639            for entry in entries.flatten() {
640                if entry.file_type().map(|ft| ft.is_socket()).unwrap_or(false) {
641                    if let Some(name) = entry.file_name().to_str() {
642                        names.push(name.to_string());
643                    }
644                }
645            }
646        }
647    }
648    let _ = dir;
649    names.sort();
650    PerlValue::array(names.into_iter().map(PerlValue::string).collect())
651}
652
653/// List only named-pipe (FIFO) names inside `dir` (non-recursive), sorted.
654/// Returns an empty list if `dir` cannot be read or on non-Unix platforms.
655pub fn list_pipes(dir: &str) -> PerlValue {
656    let mut names: Vec<String> = Vec::new();
657    #[cfg(unix)]
658    {
659        use std::os::unix::fs::FileTypeExt;
660        if let Ok(entries) = std::fs::read_dir(dir) {
661            for entry in entries.flatten() {
662                if entry.file_type().map(|ft| ft.is_fifo()).unwrap_or(false) {
663                    if let Some(name) = entry.file_name().to_str() {
664                        names.push(name.to_string());
665                    }
666                }
667            }
668        }
669    }
670    let _ = dir;
671    names.sort();
672    PerlValue::array(names.into_iter().map(PerlValue::string).collect())
673}
674
675/// List only block device names inside `dir` (non-recursive), sorted.
676/// Returns an empty list if `dir` cannot be read or on non-Unix platforms.
677pub fn list_block_devices(dir: &str) -> PerlValue {
678    let mut names: Vec<String> = Vec::new();
679    #[cfg(unix)]
680    {
681        use std::os::unix::fs::FileTypeExt;
682        if let Ok(entries) = std::fs::read_dir(dir) {
683            for entry in entries.flatten() {
684                if entry
685                    .file_type()
686                    .map(|ft| ft.is_block_device())
687                    .unwrap_or(false)
688                {
689                    if let Some(name) = entry.file_name().to_str() {
690                        names.push(name.to_string());
691                    }
692                }
693            }
694        }
695    }
696    let _ = dir;
697    names.sort();
698    PerlValue::array(names.into_iter().map(PerlValue::string).collect())
699}
700
701/// List only executable file names inside `dir` (non-recursive), sorted.
702/// Returns an empty list if `dir` cannot be read.
703pub fn list_executables(dir: &str) -> PerlValue {
704    let mut names: Vec<String> = Vec::new();
705    #[cfg(unix)]
706    {
707        if let Ok(entries) = std::fs::read_dir(dir) {
708            for entry in entries.flatten() {
709                if entry.file_type().map(|ft| ft.is_file()).unwrap_or(false)
710                    && unix_path_executable(&entry.path())
711                {
712                    if let Some(name) = entry.file_name().to_str() {
713                        names.push(name.to_string());
714                    }
715                }
716            }
717        }
718    }
719    #[cfg(not(unix))]
720    {
721        if let Ok(entries) = std::fs::read_dir(dir) {
722            for entry in entries.flatten() {
723                if entry.file_type().map(|ft| ft.is_file()).unwrap_or(false) {
724                    let p = entry.path();
725                    if let Some(ext) = p.extension() {
726                        if ext == "exe" || ext == "bat" || ext == "cmd" {
727                            if let Some(name) = entry.file_name().to_str() {
728                                names.push(name.to_string());
729                            }
730                        }
731                    }
732                }
733            }
734        }
735    }
736    let _ = dir;
737    names.sort();
738    PerlValue::array(names.into_iter().map(PerlValue::string).collect())
739}
740
741/// List only character device names inside `dir` (non-recursive), sorted.
742/// Returns an empty list if `dir` cannot be read or on non-Unix platforms.
743pub fn list_char_devices(dir: &str) -> PerlValue {
744    let mut names: Vec<String> = Vec::new();
745    #[cfg(unix)]
746    {
747        use std::os::unix::fs::FileTypeExt;
748        if let Ok(entries) = std::fs::read_dir(dir) {
749            for entry in entries.flatten() {
750                if entry
751                    .file_type()
752                    .map(|ft| ft.is_char_device())
753                    .unwrap_or(false)
754                {
755                    if let Some(name) = entry.file_name().to_str() {
756                        names.push(name.to_string());
757                    }
758                }
759            }
760        }
761    }
762    let _ = dir;
763    names.sort();
764    PerlValue::array(names.into_iter().map(PerlValue::string).collect())
765}
766
767pub fn glob_patterns(patterns: &[String]) -> PerlValue {
768    let mut paths: Vec<String> = Vec::new();
769    for pat in patterns {
770        if !pattern_is_glob(pat) {
771            paths.push(normalize_glob_path_display(pat.clone()));
772            continue;
773        }
774        for s in stryke_glob(pat) {
775            paths.push(normalize_glob_path_display(s));
776        }
777    }
778    paths.sort();
779    paths.dedup();
780    PerlValue::array(paths.into_iter().map(PerlValue::string).collect())
781}
782
783/// Parallel recursive glob: same pattern semantics as [`glob_patterns`], but walks the
784/// filesystem with rayon per directory (and parallelizes across patterns).
785pub fn glob_par_patterns(patterns: &[String]) -> PerlValue {
786    glob_par_patterns_inner(patterns, None)
787}
788
789/// Same as [`glob_par_patterns`], with a stderr progress bar (one tick per pattern) when
790/// `progress` is true.
791pub fn glob_par_patterns_with_progress(patterns: &[String], progress: bool) -> PerlValue {
792    if patterns.is_empty() {
793        return PerlValue::array(Vec::new());
794    }
795    let pmap = PmapProgress::new(progress, patterns.len());
796    let v = glob_par_patterns_inner(patterns, Some(&pmap));
797    pmap.finish();
798    v
799}
800
801fn glob_par_patterns_inner(patterns: &[String], progress: Option<&PmapProgress>) -> PerlValue {
802    // Parallelize across patterns. Each pattern goes through `zsh::glob::glob`
803    // single-threaded so the full qualifier machinery is available; intra-
804    // pattern parallelism is sacrificed in exchange for `(/)`, `(.)`, `(om)`,
805    // `(L+N)`, etc. World-first: zsh glob qualifiers in a scripting language.
806    let out: Vec<String> = patterns
807        .par_iter()
808        .flat_map_iter(|pat| {
809            let rows: Vec<String> = if !pattern_is_glob(pat) {
810                vec![pat.clone()]
811            } else {
812                stryke_glob(pat)
813            };
814            if let Some(p) = progress {
815                p.tick();
816            }
817            rows
818        })
819        .collect();
820    let mut paths: Vec<String> = out.into_iter().map(normalize_glob_path_display).collect();
821    paths.sort();
822    paths.dedup();
823    PerlValue::array(paths.into_iter().map(PerlValue::string).collect())
824}
825
826/// Display form for glob results. Pass-through — zshrs is authoritative
827/// on prefixing (it already emits `./` for cwd matches and bare names
828/// for colon-modifier results like `(:t)` / `(:e)` / `(:r)`). Re-adding
829/// `./` here used to corrupt colon-modifier output.
830fn normalize_glob_path_display(s: String) -> String {
831    s
832}
833
834/// `rename OLD, NEW` — 1 on success, 0 on failure (Perl-style).
835pub fn rename_paths(old: &str, new: &str) -> PerlValue {
836    PerlValue::integer(if std::fs::rename(old, new).is_ok() {
837        1
838    } else {
839        0
840    })
841}
842
843#[inline]
844fn is_cross_device_rename(e: &io::Error) -> bool {
845    if e.kind() == io::ErrorKind::CrossesDevices {
846        return true;
847    }
848    #[cfg(unix)]
849    {
850        if e.raw_os_error() == Some(libc::EXDEV) {
851            return true;
852        }
853    }
854    false
855}
856
857fn try_move_path(from: &str, to: &str) -> io::Result<()> {
858    match std::fs::rename(from, to) {
859        Ok(()) => Ok(()),
860        Err(e) => {
861            if !is_cross_device_rename(&e) {
862                return Err(e);
863            }
864            let meta = std::fs::symlink_metadata(from)?;
865            if meta.is_dir() {
866                return Err(io::Error::new(
867                    io::ErrorKind::Unsupported,
868                    "move: cross-device directory move is not supported",
869                ));
870            }
871            if !meta.is_file() && !meta.is_symlink() {
872                return Err(io::Error::new(
873                    io::ErrorKind::Unsupported,
874                    "move: cross-device move supports files and symlinks only",
875                ));
876            }
877            std::fs::copy(from, to)?;
878            std::fs::remove_file(from)?;
879            Ok(())
880        }
881    }
882}
883
884/// `move OLD, NEW` / `mv` — like `rename`, but on cross-device failure copies the file then removes
885/// the source (directories not supported for cross-device).
886pub fn move_path(from: &str, to: &str) -> PerlValue {
887    PerlValue::integer(if try_move_path(from, to).is_ok() {
888        1
889    } else {
890        0
891    })
892}
893
894#[cfg(unix)]
895fn unix_path_executable(path: &Path) -> bool {
896    use std::os::unix::fs::PermissionsExt;
897    std::fs::metadata(path)
898        .ok()
899        .filter(|m| m.is_file())
900        .is_some_and(|m| m.permissions().mode() & 0o111 != 0)
901}
902
903#[cfg(not(unix))]
904fn unix_path_executable(path: &Path) -> bool {
905    path.is_file()
906}
907
908fn display_executable_path(path: &Path) -> Option<String> {
909    if !unix_path_executable(path) {
910        return None;
911    }
912    path.canonicalize()
913        .ok()
914        .map(|p| p.to_string_lossy().into_owned())
915        .or_else(|| Some(path.to_string_lossy().into_owned()))
916}
917
918#[cfg(windows)]
919fn pathext_suffixes() -> Vec<String> {
920    env::var_os("PATHEXT")
921        .map(|s| {
922            env::split_paths(&s)
923                .filter_map(|p| p.to_str().map(str::to_ascii_lowercase))
924                .collect()
925        })
926        .unwrap_or_else(|| vec![".exe".into(), ".cmd".into(), ".bat".into(), ".com".into()])
927}
928
929#[cfg(windows)]
930fn which_in_dir(dir: &Path, program: &str) -> Option<String> {
931    let plain = dir.join(program);
932    if let Some(s) = display_executable_path(&plain) {
933        return Some(s);
934    }
935    if !program.contains('.') {
936        for ext in pathext_suffixes() {
937            let cand = dir.join(format!("{program}{ext}"));
938            if let Some(s) = display_executable_path(&cand) {
939                return Some(s);
940            }
941        }
942    }
943    None
944}
945
946#[cfg(not(windows))]
947fn which_in_dir(dir: &Path, program: &str) -> Option<String> {
948    display_executable_path(&dir.join(program))
949}
950
951/// Resolve `program` using `PATH` (and optional current directory when `include_dot`).
952/// Returns a path string or `None` if not found.
953pub fn which_executable(program: &str, include_dot: bool) -> Option<String> {
954    if program.is_empty() {
955        return None;
956    }
957    if program.contains('/') || (cfg!(windows) && program.contains('\\')) {
958        return display_executable_path(Path::new(program));
959    }
960    let path_os = env::var_os("PATH")?;
961    for dir in env::split_paths(&path_os) {
962        if let Some(s) = which_in_dir(&dir, program) {
963            return Some(s);
964        }
965    }
966    if include_dot {
967        return which_in_dir(Path::new("."), program);
968    }
969    None
970}
971
972/// Read entire file as raw bytes (no text decoding).
973pub fn read_file_bytes(path: &str) -> io::Result<Arc<Vec<u8>>> {
974    Ok(Arc::new(std::fs::read(path)?))
975}
976
977/// Temp file adjacent to `target` for atomic replace (`rename` into place).
978fn adjacent_temp_path(target: &Path) -> PathBuf {
979    let dir = target.parent().unwrap_or_else(|| Path::new("."));
980    let name = target
981        .file_name()
982        .map(|s| s.to_string_lossy().into_owned())
983        .unwrap_or_else(|| "file".to_string());
984    let rnd: u32 = rand::thread_rng().gen();
985    dir.join(format!("{name}.spurt-tmp-{rnd}"))
986}
987
988/// Write bytes to `path`. When `mkdir_parents`, creates parent directories. When `atomic`, writes
989/// to a unique temp file in the same directory then `rename`s into place (best-effort crash safety).
990pub fn spurt_path(path: &str, data: &[u8], mkdir_parents: bool, atomic: bool) -> io::Result<()> {
991    let path = Path::new(path);
992    if mkdir_parents {
993        if let Some(parent) = path.parent() {
994            if !parent.as_os_str().is_empty() {
995                std::fs::create_dir_all(parent)?;
996            }
997        }
998    }
999    if !atomic {
1000        return std::fs::write(path, data);
1001    }
1002    let tmp = adjacent_temp_path(path);
1003    {
1004        let mut f = std::fs::File::create(&tmp)?;
1005        f.write_all(data)?;
1006        f.sync_all().ok();
1007    }
1008    std::fs::rename(&tmp, path)?;
1009    Ok(())
1010}
1011
1012/// `copy FROM, TO` — 1 on success, 0 on failure. When `preserve_metadata`, best-effort copy of
1013/// access/modification times from the source (after a successful byte copy).
1014pub fn copy_file(from: &str, to: &str, preserve_metadata: bool) -> PerlValue {
1015    let times = if preserve_metadata {
1016        std::fs::metadata(from).ok().map(|src_meta| {
1017            let at = src_meta
1018                .accessed()
1019                .ok()
1020                .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
1021                .map(|d| d.as_secs() as i64)
1022                .unwrap_or(0);
1023            let mt = src_meta
1024                .modified()
1025                .ok()
1026                .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
1027                .map(|d| d.as_secs() as i64)
1028                .unwrap_or(0);
1029            (at, mt)
1030        })
1031    } else {
1032        None
1033    };
1034    if std::fs::copy(from, to).is_err() {
1035        return PerlValue::integer(0);
1036    }
1037    if let Some((at, mt)) = times {
1038        let _ = utime_paths(at, mt, &[to.to_string()]);
1039    }
1040    PerlValue::integer(1)
1041}
1042
1043/// [`std::path::Path::file_name`] as a string (empty if none).
1044pub fn path_basename(path: &str) -> String {
1045    Path::new(path)
1046        .file_name()
1047        .map(|s| s.to_string_lossy().into_owned())
1048        .unwrap_or_default()
1049}
1050
1051/// Parent directory string; `"."` when absent; `"/"` for POSIX root-ish paths.
1052pub fn path_dirname(path: &str) -> String {
1053    if path.is_empty() {
1054        return String::new();
1055    }
1056    let p = Path::new(path);
1057    if path == "/" {
1058        return "/".to_string();
1059    }
1060    match p.parent() {
1061        None => ".".to_string(),
1062        Some(parent) => {
1063            let s = parent.to_string_lossy();
1064            if s.is_empty() {
1065                ".".to_string()
1066            } else {
1067                s.into_owned()
1068            }
1069        }
1070    }
1071}
1072
1073/// `(base, dir, suffix)` like Perl `File::Basename::fileparse` with a single optional suffix.
1074/// When `suffix` is `Some` and `full_base` ends with it, `base` has the suffix removed and `suffix`
1075/// is the matched suffix; otherwise `suffix` in the return is empty.
1076pub fn fileparse_path(path: &str, suffix: Option<&str>) -> (String, String, String) {
1077    let dir = path_dirname(path);
1078    let full_base = path_basename(path);
1079    let (base, sfx) = if let Some(suf) = suffix.filter(|s| !s.is_empty()) {
1080        if full_base.ends_with(suf) && full_base.len() > suf.len() {
1081            (
1082                full_base[..full_base.len() - suf.len()].to_string(),
1083                suf.to_string(),
1084            )
1085        } else {
1086            (full_base.clone(), String::new())
1087        }
1088    } else {
1089        (full_base.clone(), String::new())
1090    };
1091    (base, dir, sfx)
1092}
1093
1094/// `chmod MODE, FILES...` — count of files successfully chmod'd.
1095pub fn chmod_paths(paths: &[String], mode: i64) -> i64 {
1096    #[cfg(unix)]
1097    {
1098        use std::os::unix::fs::PermissionsExt;
1099        let mut count = 0i64;
1100        for path in paths {
1101            if let Ok(meta) = std::fs::metadata(path) {
1102                let mut perms = meta.permissions();
1103                let old = perms.mode();
1104                // Perl passes permission bits (e.g. 0644); preserve st_mode file-type bits.
1105                perms.set_mode((old & !0o777) | (mode as u32 & 0o777));
1106                if std::fs::set_permissions(path, perms).is_ok() {
1107                    count += 1;
1108                }
1109            }
1110        }
1111        count
1112    }
1113    #[cfg(not(unix))]
1114    {
1115        let _ = (paths, mode);
1116        0
1117    }
1118}
1119
1120/// `utime ATIME, MTIME, FILES...` — count of paths successfully updated (Unix `utimes`; 0 on non-Unix).
1121pub fn utime_paths(atime_sec: i64, mtime_sec: i64, paths: &[String]) -> i64 {
1122    #[cfg(unix)]
1123    {
1124        use std::ffi::CString;
1125        let mut count = 0i64;
1126        let tv = [
1127            libc::timeval {
1128                tv_sec: atime_sec as libc::time_t,
1129                tv_usec: 0,
1130            },
1131            libc::timeval {
1132                tv_sec: mtime_sec as libc::time_t,
1133                tv_usec: 0,
1134            },
1135        ];
1136        for path in paths {
1137            let Ok(cs) = CString::new(path.as_str()) else {
1138                continue;
1139            };
1140            if unsafe { libc::utimes(cs.as_ptr(), tv.as_ptr()) } == 0 {
1141                count += 1;
1142            }
1143        }
1144        count
1145    }
1146    #[cfg(not(unix))]
1147    {
1148        let _ = (atime_sec, mtime_sec, paths);
1149        0
1150    }
1151}
1152
1153/// `chown UID, GID, FILES...` — count of files successfully chown'd (Unix only; 0 on non-Unix).
1154pub fn chown_paths(paths: &[String], uid: i64, gid: i64) -> i64 {
1155    #[cfg(unix)]
1156    {
1157        use std::ffi::CString;
1158        let u = if uid < 0 {
1159            (!0u32) as libc::uid_t
1160        } else {
1161            uid as libc::uid_t
1162        };
1163        let g = if gid < 0 {
1164            (!0u32) as libc::gid_t
1165        } else {
1166            gid as libc::gid_t
1167        };
1168        let mut count = 0i64;
1169        for path in paths {
1170            let Ok(c) = CString::new(path.as_str()) else {
1171                continue;
1172            };
1173            let r = unsafe { libc::chown(c.as_ptr(), u, g) };
1174            if r == 0 {
1175                count += 1;
1176            }
1177        }
1178        count
1179    }
1180    #[cfg(not(unix))]
1181    {
1182        let _ = (paths, uid, gid);
1183        0
1184    }
1185}
1186
1187/// `touch FILES...` — create files if they don't exist, update atime/mtime to
1188/// now if they do.  Returns count of files successfully touched.
1189pub fn touch_paths(paths: &[String]) -> i64 {
1190    use std::fs::OpenOptions;
1191    let mut count = 0i64;
1192    for path in paths {
1193        if path.is_empty() {
1194            continue;
1195        }
1196        // Create the file if it doesn't exist (like coreutils touch).
1197        let created = OpenOptions::new()
1198            .create(true)
1199            .append(true)
1200            .open(path)
1201            .is_ok();
1202        if !created {
1203            continue;
1204        }
1205        // Update atime + mtime to now.
1206        #[cfg(unix)]
1207        {
1208            use std::ffi::CString;
1209            if let Ok(cs) = CString::new(path.as_str()) {
1210                // null timeval pointer ⇒ set both times to now
1211                unsafe { libc::utimes(cs.as_ptr(), std::ptr::null()) };
1212            }
1213        }
1214        count += 1;
1215    }
1216    count
1217}
1218
1219#[cfg(test)]
1220mod tests {
1221    use super::*;
1222    use std::collections::HashSet;
1223
1224    #[test]
1225    fn glob_par_matches_sequential_glob_set() {
1226        let base = std::env::temp_dir().join(format!("stryke_glob_par_{}", std::process::id()));
1227        let _ = std::fs::remove_dir_all(&base);
1228        std::fs::create_dir_all(base.join("a")).unwrap();
1229        std::fs::create_dir_all(base.join("b")).unwrap();
1230        std::fs::create_dir_all(base.join("b/nested")).unwrap();
1231        std::fs::File::create(base.join("a/x.log")).unwrap();
1232        std::fs::File::create(base.join("b/y.log")).unwrap();
1233        std::fs::File::create(base.join("b/nested/z.log")).unwrap();
1234        std::fs::File::create(base.join("root.txt")).unwrap();
1235
1236        // Absolute patterns only — never `set_current_dir`; other tests run in parallel.
1237        let pat = format!("{}/**/*.log", base.display());
1238        let a = glob_patterns(std::slice::from_ref(&pat));
1239        let b = glob_par_patterns(std::slice::from_ref(&pat));
1240        let _ = std::fs::remove_dir_all(&base);
1241
1242        let set_a: HashSet<String> = a
1243            .as_array_vec()
1244            .expect("expected array")
1245            .into_iter()
1246            .map(|x| x.to_string())
1247            .collect();
1248        let set_b: HashSet<String> = b
1249            .as_array_vec()
1250            .expect("expected array")
1251            .into_iter()
1252            .map(|x| x.to_string())
1253            .collect();
1254        assert_eq!(set_a, set_b);
1255    }
1256
1257    #[test]
1258    fn glob_par_src_rs_matches_when_src_tree_present() {
1259        let root = Path::new(env!("CARGO_MANIFEST_DIR"));
1260        let src = root.join("src");
1261        if !src.is_dir() {
1262            return;
1263        }
1264        let pat = src.join("*.rs").to_string_lossy().into_owned();
1265        let v = glob_par_patterns(&[pat])
1266            .as_array_vec()
1267            .expect("expected array");
1268        assert!(
1269            !v.is_empty(),
1270            "glob_par src/*.rs should find at least one .rs under src/"
1271        );
1272    }
1273
1274    #[test]
1275    fn glob_par_progress_false_same_as_plain() {
1276        let tmp = Path::new(env!("CARGO_MANIFEST_DIR"))
1277            .join("target")
1278            .join(format!("glob_par_prog_false_{}", std::process::id()));
1279        let _ = std::fs::remove_dir_all(&tmp);
1280        std::fs::create_dir_all(&tmp).unwrap();
1281        std::fs::write(tmp.join("probe.rs"), b"// x\n").unwrap();
1282        let pat = tmp.join("*.rs").to_string_lossy().replace('\\', "/");
1283        let a = glob_par_patterns(std::slice::from_ref(&pat));
1284        let b = glob_par_patterns_with_progress(std::slice::from_ref(&pat), false);
1285        let _ = std::fs::remove_dir_all(&tmp);
1286        let va = a.as_array_vec().expect("a");
1287        let vb = b.as_array_vec().expect("b");
1288        assert_eq!(va.len(), vb.len(), "glob_par vs glob_par(..., progress=>0)");
1289        for (x, y) in va.iter().zip(vb.iter()) {
1290            assert_eq!(x.to_string(), y.to_string());
1291        }
1292    }
1293
1294    #[test]
1295    fn read_file_text_perl_compat_maps_invalid_utf8_to_latin1_octets() {
1296        let path = std::env::temp_dir().join(format!("stryke_bad_utf8_{}.txt", std::process::id()));
1297        // Lone continuation bytes — invalid UTF-8 as a whole; per-line Latin-1.
1298        std::fs::write(&path, b"ok\xff\xfe\x80\n").unwrap();
1299        let s = read_file_text_perl_compat(&path).expect("read");
1300        assert!(s.starts_with("ok"));
1301        assert_eq!(&s[2..], "\u{00ff}\u{00fe}\u{0080}\n");
1302        let _ = std::fs::remove_file(&path);
1303    }
1304
1305    #[test]
1306    fn read_logical_line_perl_compat_splits_and_decodes_per_line() {
1307        use std::io::Cursor;
1308        let mut r = Cursor::new(b"a\xff\nb\n");
1309        assert_eq!(
1310            read_logical_line_perl_compat(&mut r).unwrap(),
1311            Some("a\u{00ff}".to_string())
1312        );
1313        assert_eq!(
1314            read_logical_line_perl_compat(&mut r).unwrap(),
1315            Some("b".to_string())
1316        );
1317        assert_eq!(read_logical_line_perl_compat(&mut r).unwrap(), None);
1318    }
1319}