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::StrykeValue;
13use parking_lot::{Mutex, RwLock};
14
15/// zshrs `glob()` and [`StrykeGlobOptsGuard`] both touch zshrs process-global option
16/// state. `glob_par` invokes `stryke_glob` from rayon workers while other threads run
17/// plain `glob` — interleaved guard `new`/`Drop` corrupts `bareglobqual` / `nullglob`
18/// and breaks qualifier parsing (e.g. `(N)`), yielding mass false positives. Serialize
19/// the option snapshot + expansion critical section (same cost order as one glob at a
20/// time; `glob_par` still parallelizes unrelated work outside zsh).
21static ZSH_GLOB_GLOBAL_MUTEX: Mutex<()> = Mutex::new(());
22
23/// Keys touched by [`StrykeGlobOptsGuard`] — must match restore in `Drop`.
24const STRYKE_GLOB_OPT_KEYS: &[&str] = &[
25    "nullglob",
26    "markdirs",
27    "dotglob",
28    "globdots",
29    "listtypes",
30    "numericglobsort",
31    "chaselinks",
32    "extendedglob",
33    "caseglob",
34    "nocaseglob",
35    "globstarshort",
36    "bareglobqual",
37    "braceccl",
38];
39
40/// zshrs `glob()` reads options from the global store (`opt_state_*`). Apply
41/// the former `GlobOptions` preset for this expansion only, then restore on drop.
42pub(crate) struct StrykeGlobOptsGuard {
43    snap: std::collections::HashMap<String, bool>,
44}
45
46impl StrykeGlobOptsGuard {
47    pub(crate) fn new() -> Self {
48        let snap = zsh::options::opt_state_snapshot();
49        zsh::options::opt_state_set("nullglob", true);
50        zsh::options::opt_state_set("markdirs", false);
51        zsh::options::opt_state_set("dotglob", false);
52        zsh::options::opt_state_set("globdots", false);
53        zsh::options::opt_state_set("listtypes", false);
54        zsh::options::opt_state_set("numericglobsort", false);
55        zsh::options::opt_state_set("chaselinks", false);
56        zsh::options::opt_state_set("extendedglob", true);
57        zsh::options::opt_state_set("caseglob", true);
58        zsh::options::opt_state_set("nocaseglob", false);
59        zsh::options::opt_state_set("globstarshort", true);
60        zsh::options::opt_state_set("bareglobqual", true);
61        zsh::options::opt_state_set("braceccl", true);
62        Self { snap }
63    }
64}
65
66impl Drop for StrykeGlobOptsGuard {
67    fn drop(&mut self) {
68        for &key in STRYKE_GLOB_OPT_KEYS {
69            if let Some(v) = self.snap.get(key) {
70                zsh::options::opt_state_set(key, *v);
71            } else {
72                zsh::options::opt_state_unset(key);
73            }
74        }
75    }
76}
77
78pub use crate::perl_decode::{
79    decode_utf8_or_latin1, decode_utf8_or_latin1_line, decode_utf8_or_latin1_read_until,
80};
81
82/// Read a file as text for Perl source or slurped data. Unlike [`std::fs::read_to_string`], this
83/// does not reject bytes that are not valid UTF-8 (stock `perl` accepts such files by default).
84pub fn read_file_text_perl_compat(path: impl AsRef<Path>) -> io::Result<String> {
85    let bytes = std::fs::read(path.as_ref())?;
86    Ok(decode_utf8_or_latin1(&bytes))
87}
88
89/// `haswilds` on a raw (untokenized) pattern. zshrs 0.11.47's `haswilds`
90/// recognizes only glob-TOKENIZED metacharacters (`Star` = U+0087, etc.); 0.11.29
91/// also matched bare `*` / `?` / `[`. strykelang hands the glob engine raw
92/// patterns, so without tokenizing a copy first every glob is misclassified as a
93/// literal path and returned verbatim instead of being expanded.
94pub(crate) fn raw_haswilds(pattern: &str) -> bool {
95    let mut tok = pattern.to_string();
96    zsh::glob::tokenize(&mut tok);
97    zsh::ported::pattern::haswilds(&tok)
98}
99
100/// Preset for stryke glob (via [`StrykeGlobOptsGuard`] + `zsh::glob::glob_path`): full
101/// extended glob, bare-qualifier shorthand on (`*(/)` works without `(#q.../)`),
102/// `nullglob` requested so a no-match yields an empty list (Perl `glob` semantics);
103/// since 0.11.47's `glob_path` no longer honors that on its own, the empty-on-no-match
104/// guarantee is enforced by the post-pass below that drops the echoed pattern.
105/// `globstarshort` makes `**.stk` match every `.stk` file at any depth; `braceccl`
106/// enables `{a,b,c}` / `{abc}` brace expansion — wired through zshrs's global option
107/// store like stock zsh.
108pub(crate) fn stryke_glob(pattern: &str) -> Vec<String> {
109    // zshrs glob fails to match wildcards behind a `./` directory segment
110    // (works for literal filenames but not patterns like `./lib/*.stk`,
111    // nor for `/abs/./lib/*.stk` which `resolve_stryke_path` produces when
112    // joining cwd with a `./`-prefixed relative path). Collapse leading
113    // `./` and mid-path `/./` before delegating, then re-prepend the
114    // leading `./` to each result so callers see the form they asked for.
115    // `.` as a directory component is always the current directory; no
116    // filesystem entry can legitimately bear that name, so collapsing is
117    // safe and matches OS / `std::fs` behaviour.
118    let (stripped_leading, had_dot_slash) = if let Some(rest) = pattern.strip_prefix("./") {
119        (rest.to_string(), true)
120    } else {
121        (pattern.to_string(), false)
122    };
123    let normalized = stripped_leading.replace("/./", "/");
124    let is_relative_pattern = !std::path::Path::new(&normalized).is_absolute();
125
126    // zshrs 0.11.47's `glob_path` is a thin wrapper over `globdata_glob`, whose
127    // matcher recognizes only glob-TOKENIZED metacharacters (`Star` = U+0087,
128    // etc.) as wildcards — the production prefork path hands it token bytes. A
129    // raw `*` reaches the matcher as a literal char, matches nothing, and the
130    // pattern echoes back (0.11.46's standalone `glob_path` matched raw strings;
131    // the 0.11.47 rewrite dropped that tolerance). Tokenize before delegating so
132    // the matcher sees real wildcards. EXCEPTION: a trailing `(...)` qualifier
133    // stays raw — `parse_qualifiers` scans for a literal `)` (a tokenized
134    // `Outpar` is invisible to it), and the qualifier-driven walk already
135    // handles raw `**`/`*` correctly, so tokenizing a qualifier pattern would
136    // strip its `**` recursion.
137    let has_qual = zsh::glob::split_qualifier(&normalized).1.is_some();
138    let glob_arg = if has_qual {
139        normalized.clone()
140    } else {
141        let mut tok = normalized.clone();
142        zsh::glob::tokenize(&mut tok);
143        tok
144    };
145    let results = {
146        let _lock = ZSH_GLOB_GLOBAL_MUTEX.lock();
147        let _opts = StrykeGlobOptsGuard::new();
148        zsh::glob::glob_path(&glob_arg)
149    };
150
151    // `globdata_glob` echoes the literal (untokenized) pattern back on no-match
152    // instead of honoring nullglob — the 0.11.47 port dropped the `gf_nullglob`
153    // empty-result guard from the c:1872-1888 terminal block, so the
154    // ordinary-string fallback fires even with `nullglob` set. stryke wants Perl
155    // nullglob semantics (empty list on no match), so when the sole "match" is
156    // the pattern itself echoed back AND the pattern actually globs (has a
157    // wildcard or qualifier — a literal path legitimately equals its own match),
158    // suppress it.
159    let results = if results.len() == 1
160        && results[0] == normalized
161        && (has_qual || zsh::ported::pattern::haswilds(&glob_arg))
162    {
163        Vec::new()
164    } else {
165        results
166    };
167    // zshrs emits absolute paths even when called with a relative pattern;
168    // strip the cwd prefix so the user sees what they asked for. Leading-`./`
169    // patterns get the prefix re-attached (existing contract); other relative
170    // patterns return bare relative paths. Check both the raw cwd and its
171    // canonical form because macOS reports `/var/...` via `current_dir()` but
172    // the glob engine emits the canonical `/private/var/...`.
173    let stripped: Vec<String> = if is_relative_pattern {
174        let cwd = std::env::current_dir().ok();
175        let cwd_canonical = cwd
176            .as_ref()
177            .and_then(|p| std::fs::canonicalize(p).ok())
178            .map(|p| p.to_string_lossy().into_owned());
179        let cwd_plain = cwd.as_ref().map(|p| p.to_string_lossy().into_owned());
180        results
181            .into_iter()
182            .map(|p| {
183                for pref in [cwd_canonical.as_deref(), cwd_plain.as_deref()]
184                    .into_iter()
185                    .flatten()
186                {
187                    if let Some(rest) = p.strip_prefix(pref) {
188                        let r = rest.trim_start_matches('/');
189                        return if r.is_empty() {
190                            ".".to_string()
191                        } else {
192                            r.to_string()
193                        };
194                    }
195                }
196                p
197            })
198            .collect()
199    } else {
200        results
201    };
202    if had_dot_slash {
203        stripped
204            .into_iter()
205            .map(|p| {
206                if p.starts_with("./") {
207                    p
208                } else {
209                    format!("./{}", p)
210                }
211            })
212            .collect()
213    } else {
214        stripped
215    }
216}
217
218/// `slurp`/`cat`/`c` payload — accepts a literal path OR a zsh-style glob
219/// pattern (forwarded to [`zsh::glob`], so the entire qualifier set `(/)`,
220/// `(.)`, `(@)`, `(L+10)`, `(mh-1)`, `(om)`, `(N)`, … all work). World-first:
221/// zsh glob qualifiers in a scripting language. When the qualifier filters
222/// away every regular file (e.g. `c("**(/)")` returns directories only) we
223/// hard-fail rather than silently return empty — slurping a directory is
224/// meaningless and asking for it is a bug.
225///
226/// Routing: a path goes through the glob expander when it has wildcards
227/// (per zshrs's [`zsh::ported::pattern::haswilds`]) OR a trailing bare qualifier
228/// suffix (`dir(/)` / `name(.)`). Otherwise we read the literal path so a
229/// missing file produces the proper `No such file or directory` instead of
230/// a silent empty.
231pub fn read_file_text_or_glob(path: &str) -> io::Result<String> {
232    let (stripped, qual) = zsh::glob::split_qualifier(path);
233    let is_glob = qual.is_some() || raw_haswilds(stripped);
234    if !is_glob {
235        return read_file_text_perl_compat(path);
236    }
237    let paths = stryke_glob(path);
238    if paths.is_empty() {
239        return Err(io::Error::new(
240            io::ErrorKind::NotFound,
241            format!("no files matched glob: {}", path),
242        ));
243    }
244    let strict = qual.is_some();
245    let mut out = String::new();
246    for p in &paths {
247        match slurp_glob_entry(p, strict, |p| read_file_text_perl_compat(p))? {
248            Some(s) => out.push_str(&s),
249            None => continue,
250        }
251    }
252    Ok(out)
253}
254
255/// Shared per-entry policy for slurp-over-a-glob. A qualifier glob (`**(/)`) is
256/// an explicit selection, so `strict` hard-fails on any non-regular or
257/// unreadable match (and the error now names the offending path). A plain
258/// wildcard sweep (`**.rs`) is broad and incidental: a dangling symlink,
259/// vanished file, or directory caught by the pattern is skipped (logged at
260/// debug) so one bad entry can't abort the whole sweep — matching `grep -r` /
261/// `cat`-over-a-glob. Returns `Ok(None)` for a skipped entry.
262fn slurp_glob_entry<T>(
263    p: &str,
264    strict: bool,
265    read: impl FnOnce(&str) -> io::Result<T>,
266) -> io::Result<Option<T>> {
267    let meta = match std::fs::metadata(p) {
268        Ok(m) => m,
269        Err(e) => {
270            if strict {
271                return Err(io::Error::new(e.kind(), format!("{}: {}", p, e)));
272            }
273            tracing::debug!("slurp: skipping unreadable glob match {}: {}", p, e);
274            return Ok(None);
275        }
276    };
277    if !meta.is_file() {
278        if strict {
279            return Err(io::Error::new(
280                io::ErrorKind::InvalidInput,
281                format!("not a regular file: {}", p),
282            ));
283        }
284        tracing::debug!("slurp: skipping non-regular glob match: {}", p);
285        return Ok(None);
286    }
287    match read(p) {
288        Ok(v) => Ok(Some(v)),
289        Err(e) => {
290            if strict {
291                return Err(io::Error::new(e.kind(), format!("{}: {}", p, e)));
292            }
293            tracing::debug!("slurp: skipping unreadable glob match {}: {}", p, e);
294            Ok(None)
295        }
296    }
297}
298
299/// Bytes-faithful slurp: matches Perl's default byte-string semantics. Returns the
300/// concatenated raw bytes for a glob, or the raw bytes of a single file. The
301/// returned [`StrykeValue::bytes`] stringifies via `decode_utf8_or_latin1` so
302/// `eq`/regex/substr on text content still match, while `length()` reports byte
303/// count and `spew`/encoders see the original bytes — making `slurp` byte-perfect
304/// for binary files without changing text-file behaviour.
305pub fn read_bytes_or_glob(path: &str) -> io::Result<Arc<Vec<u8>>> {
306    let (stripped, qual) = zsh::glob::split_qualifier(path);
307    let is_glob = qual.is_some() || raw_haswilds(stripped);
308    if !is_glob {
309        return read_file_bytes(path);
310    }
311    let paths = stryke_glob(path);
312    if paths.is_empty() {
313        return Err(io::Error::new(
314            io::ErrorKind::NotFound,
315            format!("no files matched glob: {}", path),
316        ));
317    }
318    let strict = qual.is_some();
319    let mut out = Vec::new();
320    for p in &paths {
321        match slurp_glob_entry(p, strict, |p| std::fs::read(p))? {
322            Some(bytes) => out.extend_from_slice(&bytes),
323            None => continue,
324        }
325    }
326    Ok(Arc::new(out))
327}
328
329/// Pattern routes to [`zsh::glob`] when it has wildcards or a bare qualifier
330/// suffix; literal paths short-circuit so callers can preserve "no such
331/// file" diagnostics. Wraps zshrs's own predicates so stryke owns no
332/// pattern-parsing logic of its own.
333fn pattern_is_glob(path: &str) -> bool {
334    let (stripped, qual) = zsh::glob::split_qualifier(path);
335    qual.is_some() || raw_haswilds(stripped) || zsh::glob::hasbraces(stripped, true)
336}
337
338/// Like [`BufRead::read_line`] but decodes with [`decode_utf8_or_latin1_read_until`] (no U+FFFD).
339pub fn read_line_perl_compat(reader: &mut impl BufRead, buf: &mut String) -> io::Result<usize> {
340    read_line_perl_compat_with_sep(reader, buf, Some(b'\n'))
341}
342
343/// Like [`read_line_perl_compat`] but honors an explicit input record separator byte. Mirrors
344/// perl `$/`:
345/// - `Some(byte)` — read until `byte`; trailing `byte` is included in the buffer.
346/// - `None`       — slurp: read to EOF.
347pub fn read_line_perl_compat_with_sep(
348    reader: &mut impl BufRead,
349    buf: &mut String,
350    sep: Option<u8>,
351) -> io::Result<usize> {
352    buf.clear();
353    let mut raw = Vec::new();
354    let n = match sep {
355        None => reader.read_to_end(&mut raw)?,
356        Some(byte) => reader.read_until(byte, &mut raw)?,
357    };
358    if n == 0 {
359        return Ok(0);
360    }
361    buf.push_str(&decode_utf8_or_latin1_read_until(&raw));
362    Ok(n)
363}
364
365/// One line from `reader` (delimiter `\n`), content **without** trailing `\n` / `\r\n` / `\r`,
366/// same as [`BufRead::lines`] but UTF-8 or Latin-1 per line.
367///
368/// Legacy newline-stripping reader kept for out-of-tree callers. New callers in line-mode
369/// processing should use [`read_logical_line_perl_compat_with_sep`] (which preserves the
370/// separator in the record per perl `<>` semantics) plus the caller's own chomp logic.
371pub fn read_logical_line_perl_compat(reader: &mut impl BufRead) -> io::Result<Option<String>> {
372    let mut buf = Vec::new();
373    let n = reader.read_until(b'\n', &mut buf)?;
374    if n == 0 {
375        return Ok(None);
376    }
377    if buf.ends_with(b"\n") {
378        buf.pop();
379        if buf.ends_with(b"\r") {
380            buf.pop();
381        }
382    }
383    Ok(Some(decode_utf8_or_latin1_line(&buf)))
384}
385
386/// One record from `reader` honoring an explicit input record separator byte. Matches perl's
387/// `<>` which leaves the IRS *in* `$_` — chomp / `-l` strip the IRS in the autoprint pipeline,
388/// not at the reader.
389///
390/// - `Some(byte)` — read until `byte`; trailing `byte` (and `\r` adjacent for `\n`) is kept in the
391///   returned record so the caller can emit it verbatim (perl `-p` behavior) or chomp it
392///   (perl `-l` / explicit chomp).
393/// - `None` — slurp mode (perl `-0777`): reads to EOF as one record.
394pub fn read_logical_line_perl_compat_with_sep(
395    reader: &mut impl BufRead,
396    sep: Option<u8>,
397) -> io::Result<Option<String>> {
398    let mut buf = Vec::new();
399    let n = match sep {
400        None => reader.read_to_end(&mut buf)?,
401        Some(byte) => reader.read_until(byte, &mut buf)?,
402    };
403    if n == 0 {
404        return Ok(None);
405    }
406    Ok(Some(decode_utf8_or_latin1_line(&buf)))
407}
408
409/// Perl `-t` — true if the handle/path refers to a terminal ([`libc::isatty`] on Unix).
410/// Recognizes `STDIN`/`STDOUT`/`STDERR`, `/dev/stdin` (etc.), `/dev/fd/N`, small numeric fds, or opens a path and tests its fd.
411pub fn filetest_is_tty(path: &str) -> bool {
412    #[cfg(unix)]
413    {
414        use std::os::unix::io::AsRawFd;
415        if let Some(fd) = tty_fd_literal(path) {
416            return unsafe { libc::isatty(fd) != 0 };
417        }
418        if let Ok(f) = std::fs::File::open(path) {
419            return unsafe { libc::isatty(f.as_raw_fd()) != 0 };
420        }
421    }
422    #[cfg(not(unix))]
423    {
424        let _ = path;
425    }
426    false
427}
428
429#[cfg(unix)]
430fn tty_fd_literal(path: &str) -> Option<i32> {
431    match path {
432        "" | "STDIN" | "-" | "/dev/stdin" => Some(0),
433        "STDOUT" | "/dev/stdout" => Some(1),
434        "STDERR" | "/dev/stderr" => Some(2),
435        p if p.starts_with("/dev/fd/") => p.strip_prefix("/dev/fd/").and_then(|s| s.parse().ok()),
436        _ => path.parse::<i32>().ok().filter(|&n| (0..128).contains(&n)),
437    }
438}
439
440/// Check if effective uid/gid has the given access to a file.
441/// `check` is one of 4 (read), 2 (write), 1 (execute).
442#[cfg(unix)]
443pub fn filetest_effective_access(path: &str, check: u32) -> bool {
444    use std::os::unix::fs::MetadataExt;
445    let meta = match std::fs::metadata(path) {
446        Ok(m) => m,
447        Err(_) => return false,
448    };
449    let mode = meta.mode();
450    let euid = unsafe { libc::geteuid() };
451    let egid = unsafe { libc::getegid() };
452    // Root can read/write anything, execute if any x bit set
453    if euid == 0 {
454        return if check == 1 { mode & 0o111 != 0 } else { true };
455    }
456    if meta.uid() == euid {
457        return mode & (check << 6) != 0;
458    }
459    if meta.gid() == egid {
460        return mode & (check << 3) != 0;
461    }
462    mode & check != 0
463}
464
465/// Check if real uid/gid has the given access (uses libc::access).
466#[cfg(unix)]
467pub fn filetest_real_access(path: &str, amode: libc::c_int) -> bool {
468    match std::ffi::CString::new(path) {
469        Ok(c) => unsafe { libc::access(c.as_ptr(), amode) == 0 },
470        Err(_) => false,
471    }
472}
473
474/// Is the file owned by effective uid?
475#[cfg(unix)]
476pub fn filetest_owned_effective(path: &str) -> bool {
477    use std::os::unix::fs::MetadataExt;
478    std::fs::metadata(path)
479        .map(|m| m.uid() == unsafe { libc::geteuid() })
480        .unwrap_or(false)
481}
482
483/// Is the file owned by real uid?
484#[cfg(unix)]
485pub fn filetest_owned_real(path: &str) -> bool {
486    use std::os::unix::fs::MetadataExt;
487    std::fs::metadata(path)
488        .map(|m| m.uid() == unsafe { libc::getuid() })
489        .unwrap_or(false)
490}
491
492/// Is the file a named pipe (FIFO)?
493#[cfg(unix)]
494pub fn filetest_is_pipe(path: &str) -> bool {
495    use std::os::unix::fs::FileTypeExt;
496    std::fs::metadata(path)
497        .map(|m| m.file_type().is_fifo())
498        .unwrap_or(false)
499}
500
501/// Is the file a socket?
502#[cfg(unix)]
503pub fn filetest_is_socket(path: &str) -> bool {
504    use std::os::unix::fs::FileTypeExt;
505    std::fs::metadata(path)
506        .map(|m| m.file_type().is_socket())
507        .unwrap_or(false)
508}
509
510/// Is the file a block device?
511#[cfg(unix)]
512pub fn filetest_is_block_device(path: &str) -> bool {
513    use std::os::unix::fs::FileTypeExt;
514    std::fs::metadata(path)
515        .map(|m| m.file_type().is_block_device())
516        .unwrap_or(false)
517}
518
519/// Is the file a character device?
520#[cfg(unix)]
521pub fn filetest_is_char_device(path: &str) -> bool {
522    use std::os::unix::fs::FileTypeExt;
523    std::fs::metadata(path)
524        .map(|m| m.file_type().is_char_device())
525        .unwrap_or(false)
526}
527
528/// Is setuid bit set?
529#[cfg(unix)]
530pub fn filetest_is_setuid(path: &str) -> bool {
531    use std::os::unix::fs::MetadataExt;
532    std::fs::metadata(path)
533        .map(|m| m.mode() & 0o4000 != 0)
534        .unwrap_or(false)
535}
536
537/// Is setgid bit set?
538#[cfg(unix)]
539pub fn filetest_is_setgid(path: &str) -> bool {
540    use std::os::unix::fs::MetadataExt;
541    std::fs::metadata(path)
542        .map(|m| m.mode() & 0o2000 != 0)
543        .unwrap_or(false)
544}
545
546/// Is sticky bit set?
547#[cfg(unix)]
548pub fn filetest_is_sticky(path: &str) -> bool {
549    use std::os::unix::fs::MetadataExt;
550    std::fs::metadata(path)
551        .map(|m| m.mode() & 0o1000 != 0)
552        .unwrap_or(false)
553}
554
555/// Is the file a text file? (Perl heuristic: read first block, check for high proportion of printable chars)
556pub fn filetest_is_text(path: &str) -> bool {
557    filetest_text_binary(path, true)
558}
559
560/// Is the file a binary file? (opposite of text)
561pub fn filetest_is_binary(path: &str) -> bool {
562    filetest_text_binary(path, false)
563}
564
565fn filetest_text_binary(path: &str, want_text: bool) -> bool {
566    use std::io::Read;
567    let mut f = match std::fs::File::open(path) {
568        Ok(f) => f,
569        Err(_) => return false,
570    };
571    let mut buf = [0u8; 512];
572    let n = match f.read(&mut buf) {
573        Ok(n) => n,
574        Err(_) => return false,
575    };
576    if n == 0 {
577        // Empty files are considered text in Perl
578        return want_text;
579    }
580    let slice = &buf[..n];
581    // Count bytes that are "non-text": NUL and control chars (except \t \n \r \x1b)
582    let non_text = slice
583        .iter()
584        .filter(|&&b| b == 0 || (b < 0x20 && b != b'\t' && b != b'\n' && b != b'\r' && b != 0x1b))
585        .count();
586    let is_text = (non_text as f64 / n as f64) < 0.30;
587    if want_text {
588        is_text
589    } else {
590        !is_text
591    }
592}
593
594/// File age in fractional days since now. `which`: 'M' = mtime, 'A' = atime, 'C' = ctime.
595#[cfg(unix)]
596pub fn filetest_age_days(path: &str, which: char) -> Option<f64> {
597    use std::os::unix::fs::MetadataExt;
598    let meta = std::fs::metadata(path).ok()?;
599    let t = match which {
600        'M' => meta.mtime() as f64,
601        'A' => meta.atime() as f64,
602        _ => meta.ctime() as f64,
603    };
604    let now = std::time::SystemTime::now()
605        .duration_since(std::time::UNIX_EPOCH)
606        .unwrap_or_default()
607        .as_secs_f64();
608    Some((now - t) / 86400.0)
609}
610
611/// 13-element `stat` / `lstat` list (empty vector on failure).
612pub fn stat_path(path: &str, symlink: bool) -> StrykeValue {
613    let res = if symlink {
614        std::fs::symlink_metadata(path)
615    } else {
616        std::fs::metadata(path)
617    };
618    match res {
619        Ok(meta) => StrykeValue::array(perl_stat_from_metadata(&meta)),
620        Err(_) => StrykeValue::array(vec![]),
621    }
622}
623/// `perl_stat_from_metadata` — see implementation.
624pub fn perl_stat_from_metadata(meta: &std::fs::Metadata) -> Vec<StrykeValue> {
625    #[cfg(unix)]
626    {
627        use std::os::unix::fs::MetadataExt;
628        vec![
629            StrykeValue::integer(meta.dev() as i64),
630            StrykeValue::integer(meta.ino() as i64),
631            StrykeValue::integer(meta.mode() as i64),
632            StrykeValue::integer(meta.nlink() as i64),
633            StrykeValue::integer(meta.uid() as i64),
634            StrykeValue::integer(meta.gid() as i64),
635            StrykeValue::integer(meta.rdev() as i64),
636            StrykeValue::integer(meta.len() as i64),
637            StrykeValue::integer(meta.atime()),
638            StrykeValue::integer(meta.mtime()),
639            StrykeValue::integer(meta.ctime()),
640            StrykeValue::integer(meta.blksize() as i64),
641            StrykeValue::integer(meta.blocks() as i64),
642        ]
643    }
644    #[cfg(not(unix))]
645    {
646        let len = meta.len() as i64;
647        vec![
648            StrykeValue::integer(0),
649            StrykeValue::integer(0),
650            StrykeValue::integer(0),
651            StrykeValue::integer(0),
652            StrykeValue::integer(0),
653            StrykeValue::integer(0),
654            StrykeValue::integer(0),
655            StrykeValue::integer(len),
656            StrykeValue::integer(0),
657            StrykeValue::integer(0),
658            StrykeValue::integer(0),
659            StrykeValue::integer(0),
660            StrykeValue::integer(0),
661        ]
662    }
663}
664/// `link_hard` — see implementation.
665pub fn link_hard(old: &str, new: &str) -> StrykeValue {
666    StrykeValue::integer(if std::fs::hard_link(old, new).is_ok() {
667        1
668    } else {
669        0
670    })
671}
672/// `link_sym` — see implementation.
673pub fn link_sym(old: &str, new: &str) -> StrykeValue {
674    #[cfg(unix)]
675    {
676        use std::os::unix::fs::symlink;
677        StrykeValue::integer(if symlink(old, new).is_ok() { 1 } else { 0 })
678    }
679    #[cfg(not(unix))]
680    {
681        let _ = (old, new);
682        StrykeValue::integer(0)
683    }
684}
685/// `read_link` — see implementation.
686pub fn read_link(path: &str) -> StrykeValue {
687    match std::fs::read_link(path) {
688        Ok(p) => StrykeValue::string(p.to_string_lossy().into_owned()),
689        Err(_) => StrykeValue::UNDEF,
690    }
691}
692
693/// Absolute path with symlinks resolved (`std::fs::canonicalize`); all path components must exist.
694pub fn realpath_resolved(path: &str) -> io::Result<String> {
695    std::fs::canonicalize(path).map(|p| p.to_string_lossy().into_owned())
696}
697
698/// Normalize `.` / `..` and redundant separators without touching the disk (Perl
699/// `File::Spec->canonpath`-like). Unlike [`std::path::Path::components`] alone, this collapses
700/// `foo/..` in relative paths instead of preserving `..` for symlink safety.
701pub fn canonpath_logical(path: &str) -> String {
702    use std::path::Component;
703    if path.is_empty() {
704        return String::new();
705    }
706    let mut stack: Vec<String> = Vec::new();
707    let mut anchored = false;
708    for c in Path::new(path).components() {
709        match c {
710            Component::Prefix(p) => {
711                stack.push(p.as_os_str().to_string_lossy().into_owned());
712            }
713            Component::RootDir => {
714                anchored = true;
715                stack.clear();
716            }
717            Component::CurDir => {}
718            Component::Normal(s) => {
719                stack.push(s.to_string_lossy().into_owned());
720            }
721            Component::ParentDir => {
722                if anchored {
723                    if !stack.is_empty() {
724                        stack.pop();
725                    }
726                } else if stack.is_empty() || stack.last().is_some_and(|t| t == "..") {
727                    stack.push("..".to_string());
728                } else {
729                    stack.pop();
730                }
731            }
732        }
733    }
734    let body = stack.join("/");
735    if anchored {
736        if body.is_empty() {
737            "/".to_string()
738        } else {
739            format!("/{body}")
740        }
741    } else if body.is_empty() {
742        ".".to_string()
743    } else {
744        body
745    }
746}
747
748/// List file/directory names inside `dir` (non-recursive), sorted.
749/// Returns an empty list if `dir` cannot be read.
750pub fn list_files(dir: &str) -> StrykeValue {
751    let mut names: Vec<String> = Vec::new();
752    if let Ok(entries) = std::fs::read_dir(dir) {
753        for entry in entries.flatten() {
754            if let Some(name) = entry.file_name().to_str() {
755                names.push(name.to_string());
756            }
757        }
758    }
759    names.sort();
760    StrykeValue::array(names.into_iter().map(StrykeValue::string).collect())
761}
762
763/// List only regular file names inside `dir` (non-recursive), sorted.
764/// Excludes directories, symlinks, and special files.
765/// Returns an empty list if `dir` cannot be read.
766pub fn list_filesf(dir: &str) -> StrykeValue {
767    let mut names: Vec<String> = Vec::new();
768    if let Ok(entries) = std::fs::read_dir(dir) {
769        for entry in entries.flatten() {
770            if entry.file_type().map(|ft| ft.is_file()).unwrap_or(false) {
771                if let Some(name) = entry.file_name().to_str() {
772                    names.push(name.to_string());
773                }
774            }
775        }
776    }
777    names.sort();
778    StrykeValue::array(names.into_iter().map(StrykeValue::string).collect())
779}
780
781/// List only regular file paths under `dir` **recursively**, sorted.
782/// Returns relative paths from `dir` (e.g. `"sub/file.txt"`).
783/// Returns an empty list if `dir` cannot be read.
784pub fn list_filesf_recursive(dir: &str) -> StrykeValue {
785    let root = std::path::Path::new(dir);
786    let mut paths: Vec<String> = Vec::new();
787    fn walk(base: &std::path::Path, rel: &str, out: &mut Vec<String>) {
788        let Ok(entries) = std::fs::read_dir(base) else {
789            return;
790        };
791        for entry in entries.flatten() {
792            let ft = match entry.file_type() {
793                Ok(ft) => ft,
794                Err(_) => continue,
795            };
796            let name = match entry.file_name().into_string() {
797                Ok(n) => n,
798                Err(_) => continue,
799            };
800            let child_rel = if rel.is_empty() {
801                name.clone()
802            } else {
803                format!("{rel}/{name}")
804            };
805            if ft.is_file() {
806                out.push(child_rel);
807            } else if ft.is_dir() {
808                walk(&base.join(&name), &child_rel, out);
809            }
810        }
811    }
812    walk(root, "", &mut paths);
813    paths.sort();
814    StrykeValue::array(paths.into_iter().map(StrykeValue::string).collect())
815}
816
817/// List only directory names inside `dir` (non-recursive), sorted.
818/// Returns an empty list if `dir` cannot be read.
819pub fn list_dirs(dir: &str) -> StrykeValue {
820    let mut names: Vec<String> = Vec::new();
821    if let Ok(entries) = std::fs::read_dir(dir) {
822        for entry in entries.flatten() {
823            if entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false) {
824                if let Some(name) = entry.file_name().to_str() {
825                    names.push(name.to_string());
826                }
827            }
828        }
829    }
830    names.sort();
831    StrykeValue::array(names.into_iter().map(StrykeValue::string).collect())
832}
833
834/// List subdirectory paths under `dir` **recursively**, sorted.
835/// Returns relative paths from `dir` (e.g. `"sub/nested"`).
836/// Returns an empty list if `dir` cannot be read.
837pub fn list_dirs_recursive(dir: &str) -> StrykeValue {
838    let root = std::path::Path::new(dir);
839    let mut paths: Vec<String> = Vec::new();
840    fn walk(base: &std::path::Path, rel: &str, out: &mut Vec<String>) {
841        let Ok(entries) = std::fs::read_dir(base) else {
842            return;
843        };
844        for entry in entries.flatten() {
845            let ft = match entry.file_type() {
846                Ok(ft) => ft,
847                Err(_) => continue,
848            };
849            if !ft.is_dir() {
850                continue;
851            }
852            let name = match entry.file_name().into_string() {
853                Ok(n) => n,
854                Err(_) => continue,
855            };
856            let child_rel = if rel.is_empty() {
857                name.clone()
858            } else {
859                format!("{rel}/{name}")
860            };
861            out.push(child_rel.clone());
862            walk(&base.join(&name), &child_rel, out);
863        }
864    }
865    walk(root, "", &mut paths);
866    paths.sort();
867    StrykeValue::array(paths.into_iter().map(StrykeValue::string).collect())
868}
869
870/// List only symlink names inside `dir` (non-recursive), sorted.
871/// Returns an empty list if `dir` cannot be read.
872pub fn list_sym_links(dir: &str) -> StrykeValue {
873    let mut names: Vec<String> = Vec::new();
874    if let Ok(entries) = std::fs::read_dir(dir) {
875        for entry in entries.flatten() {
876            if entry.file_type().map(|ft| ft.is_symlink()).unwrap_or(false) {
877                if let Some(name) = entry.file_name().to_str() {
878                    names.push(name.to_string());
879                }
880            }
881        }
882    }
883    names.sort();
884    StrykeValue::array(names.into_iter().map(StrykeValue::string).collect())
885}
886
887/// List only Unix socket names inside `dir` (non-recursive), sorted.
888/// Returns an empty list if `dir` cannot be read or on non-Unix platforms.
889pub fn list_sockets(dir: &str) -> StrykeValue {
890    let mut names: Vec<String> = Vec::new();
891    #[cfg(unix)]
892    {
893        use std::os::unix::fs::FileTypeExt;
894        if let Ok(entries) = std::fs::read_dir(dir) {
895            for entry in entries.flatten() {
896                if entry.file_type().map(|ft| ft.is_socket()).unwrap_or(false) {
897                    if let Some(name) = entry.file_name().to_str() {
898                        names.push(name.to_string());
899                    }
900                }
901            }
902        }
903    }
904    let _ = dir;
905    names.sort();
906    StrykeValue::array(names.into_iter().map(StrykeValue::string).collect())
907}
908
909/// List only named-pipe (FIFO) names inside `dir` (non-recursive), sorted.
910/// Returns an empty list if `dir` cannot be read or on non-Unix platforms.
911pub fn list_pipes(dir: &str) -> StrykeValue {
912    let mut names: Vec<String> = Vec::new();
913    #[cfg(unix)]
914    {
915        use std::os::unix::fs::FileTypeExt;
916        if let Ok(entries) = std::fs::read_dir(dir) {
917            for entry in entries.flatten() {
918                if entry.file_type().map(|ft| ft.is_fifo()).unwrap_or(false) {
919                    if let Some(name) = entry.file_name().to_str() {
920                        names.push(name.to_string());
921                    }
922                }
923            }
924        }
925    }
926    let _ = dir;
927    names.sort();
928    StrykeValue::array(names.into_iter().map(StrykeValue::string).collect())
929}
930
931/// List only block device names inside `dir` (non-recursive), sorted.
932/// Returns an empty list if `dir` cannot be read or on non-Unix platforms.
933pub fn list_block_devices(dir: &str) -> StrykeValue {
934    let mut names: Vec<String> = Vec::new();
935    #[cfg(unix)]
936    {
937        use std::os::unix::fs::FileTypeExt;
938        if let Ok(entries) = std::fs::read_dir(dir) {
939            for entry in entries.flatten() {
940                if entry
941                    .file_type()
942                    .map(|ft| ft.is_block_device())
943                    .unwrap_or(false)
944                {
945                    if let Some(name) = entry.file_name().to_str() {
946                        names.push(name.to_string());
947                    }
948                }
949            }
950        }
951    }
952    let _ = dir;
953    names.sort();
954    StrykeValue::array(names.into_iter().map(StrykeValue::string).collect())
955}
956
957/// List only executable file names inside `dir` (non-recursive), sorted.
958/// Returns an empty list if `dir` cannot be read.
959pub fn list_executables(dir: &str) -> StrykeValue {
960    let mut names: Vec<String> = Vec::new();
961    #[cfg(unix)]
962    {
963        if let Ok(entries) = std::fs::read_dir(dir) {
964            for entry in entries.flatten() {
965                if entry.file_type().map(|ft| ft.is_file()).unwrap_or(false)
966                    && unix_path_executable(&entry.path())
967                {
968                    if let Some(name) = entry.file_name().to_str() {
969                        names.push(name.to_string());
970                    }
971                }
972            }
973        }
974    }
975    #[cfg(not(unix))]
976    {
977        if let Ok(entries) = std::fs::read_dir(dir) {
978            for entry in entries.flatten() {
979                if entry.file_type().map(|ft| ft.is_file()).unwrap_or(false) {
980                    let p = entry.path();
981                    if let Some(ext) = p.extension() {
982                        if ext == "exe" || ext == "bat" || ext == "cmd" {
983                            if let Some(name) = entry.file_name().to_str() {
984                                names.push(name.to_string());
985                            }
986                        }
987                    }
988                }
989            }
990        }
991    }
992    let _ = dir;
993    names.sort();
994    StrykeValue::array(names.into_iter().map(StrykeValue::string).collect())
995}
996
997/// List only character device names inside `dir` (non-recursive), sorted.
998/// Returns an empty list if `dir` cannot be read or on non-Unix platforms.
999pub fn list_char_devices(dir: &str) -> StrykeValue {
1000    let mut names: Vec<String> = Vec::new();
1001    #[cfg(unix)]
1002    {
1003        use std::os::unix::fs::FileTypeExt;
1004        if let Ok(entries) = std::fs::read_dir(dir) {
1005            for entry in entries.flatten() {
1006                if entry
1007                    .file_type()
1008                    .map(|ft| ft.is_char_device())
1009                    .unwrap_or(false)
1010                {
1011                    if let Some(name) = entry.file_name().to_str() {
1012                        names.push(name.to_string());
1013                    }
1014                }
1015            }
1016        }
1017    }
1018    let _ = dir;
1019    names.sort();
1020    StrykeValue::array(names.into_iter().map(StrykeValue::string).collect())
1021}
1022/// `glob_patterns` — see implementation.
1023pub fn glob_patterns(patterns: &[String]) -> StrykeValue {
1024    let mut paths: Vec<String> = Vec::new();
1025    for pat in patterns {
1026        if !pattern_is_glob(pat) {
1027            paths.push(normalize_glob_path_display(pat.clone()));
1028            continue;
1029        }
1030        for s in stryke_glob(pat) {
1031            paths.push(normalize_glob_path_display(s));
1032        }
1033    }
1034    paths.sort();
1035    paths.dedup();
1036    StrykeValue::array(paths.into_iter().map(StrykeValue::string).collect())
1037}
1038
1039/// `swallow PATTERN` — expand `pattern` through the zsh glob engine (same one
1040/// `glob`/`slurp` use, full qualifier support), read every matched regular file
1041/// as raw bytes, and return a hash `{ canonicalized_abspath => bytes }`.
1042///
1043/// Symlinks are flattened (`fs::canonicalize`) so a symlink farm collapses to
1044/// the underlying real paths. Hard-fails on a non-regular-file match the same
1045/// way [`read_file_text_or_glob`] does — users opt into silence with the `(N)`
1046/// null-glob qualifier (`swallow "missing*(N)"` returns an empty hash).
1047///
1048/// Literal (non-glob) paths are treated as a one-element match so
1049/// `swallow("README.md")` is equivalent to a single-key hash.
1050pub fn swallow_to_hash(pattern: &str) -> io::Result<StrykeValue> {
1051    let paths: Vec<String> = if pattern_is_glob(pattern) {
1052        stryke_glob(pattern)
1053    } else {
1054        vec![pattern.to_string()]
1055    };
1056    let (_, qual) = zsh::glob::split_qualifier(pattern);
1057    let strict = qual.is_some();
1058    let mut out: indexmap::IndexMap<String, StrykeValue> = indexmap::IndexMap::new();
1059    for p in &paths {
1060        // Same policy as `slurp`: a plain wildcard sweep skips matches it can't
1061        // read as a regular file (dangling symlink, vanished file, directory) —
1062        // a bad reference is skipped, not a fatal hole; a qualifier glob is an
1063        // explicit selection and stays strict.
1064        let entry = slurp_glob_entry(p, strict, |p| {
1065            let canon = std::fs::canonicalize(p)?.to_string_lossy().into_owned();
1066            let bytes = read_file_bytes(p)?;
1067            Ok((canon, StrykeValue::bytes(bytes)))
1068        })?;
1069        if let Some((canon, val)) = entry {
1070            out.insert(canon, val);
1071        }
1072    }
1073    Ok(StrykeValue::hash(out))
1074}
1075
1076/// Streaming sibling of [`swallow_to_hash`]: pre-resolves the glob match set
1077/// (same zsh engine, same plain-sweep-skips / qualifier-strict policy, same
1078/// canonicalize), then defers the bytes read until each `next_item()` call.
1079/// Only one file's
1080/// bytes are resident at any time — useful when iterating directories whose
1081/// concatenated contents would not fit in memory.
1082///
1083/// Per-yield value: `[canonical_abspath, raw_bytes]` as an array ref so users
1084/// can destructure with `for my ($p, $b) (ingest "**/*.log") { ... }`.
1085pub struct IngestIterator {
1086    /// `queue` field.
1087    queue: Mutex<std::collections::VecDeque<String>>,
1088}
1089
1090impl IngestIterator {
1091    /// `new` — see implementation.
1092    pub fn new(paths: Vec<String>) -> Self {
1093        Self {
1094            queue: Mutex::new(paths.into_iter().collect()),
1095        }
1096    }
1097}
1098
1099impl crate::value::StrykeIterator for IngestIterator {
1100    fn next_item(&self) -> Option<StrykeValue> {
1101        let path = self.queue.lock().pop_front()?;
1102        let bytes = match std::fs::read(&path) {
1103            Ok(b) => Arc::new(b),
1104            // Pre-flight stat/canonicalize already happened in `ingest_iterator`,
1105            // so a read failure here means the file was removed mid-iteration.
1106            // End the stream rather than panicking — matches Perl/Unix tradition.
1107            Err(_) => return None,
1108        };
1109        let pair = vec![StrykeValue::string(path), StrykeValue::bytes(bytes)];
1110        Some(StrykeValue::array_ref(Arc::new(RwLock::new(pair))))
1111    }
1112}
1113
1114/// `ingest PATTERN` — same surface as [`swallow_to_hash`] but returns a lazy
1115/// iterator instead of materializing a hash. Path discovery + stat +
1116/// canonicalize are eager (so qualifier support is identical to swallow/slurp,
1117/// and a plain sweep skips matches it can't stat as a regular file while a
1118/// qualifier glob stays strict); file content reads are deferred to
1119/// per-iteration `next_item()` calls via [`IngestIterator`].
1120pub fn ingest_iterator(pattern: &str) -> io::Result<StrykeValue> {
1121    let raw_paths: Vec<String> = if pattern_is_glob(pattern) {
1122        stryke_glob(pattern)
1123    } else {
1124        vec![pattern.to_string()]
1125    };
1126    let (_, qual) = zsh::glob::split_qualifier(pattern);
1127    let strict = qual.is_some();
1128    let mut canon: Vec<String> = Vec::with_capacity(raw_paths.len());
1129    for p in &raw_paths {
1130        // Same policy as `slurp`/`swallow`: a plain wildcard sweep skips matches
1131        // it can't stat as a regular file (dangling symlink, vanished file,
1132        // directory); a qualifier glob is an explicit selection and stays strict.
1133        let resolved = slurp_glob_entry(p, strict, |p| {
1134            std::fs::canonicalize(p).map(|c| c.to_string_lossy().into_owned())
1135        })?;
1136        if let Some(c) = resolved {
1137            canon.push(c);
1138        }
1139    }
1140    Ok(StrykeValue::iterator(Arc::new(IngestIterator::new(canon))))
1141}
1142
1143/// `burp HASH` — inverse of `swallow`. Walk a `{ path => content }` hash and
1144/// write each entry to disk. Parent directories are created on the fly so the
1145/// canonical `swallow %h |> mutate |> burp` round-trip works even when the
1146/// destination tree doesn't yet exist. Values that are byte buffers
1147/// (`StrykeValue::bytes`) write raw bytes; any other scalar stringifies via
1148/// `to_string()` before being written (matches `spew`/`spurt` semantics).
1149///
1150/// Returns the count of files written. Hard-fails on the first I/O error so a
1151/// half-applied burp surfaces immediately rather than silently dropping
1152/// entries.
1153pub fn burp_hash_to_disk(v: &StrykeValue) -> io::Result<i64> {
1154    let entries: Vec<(String, Vec<u8>)> = if let Some(h) = v.as_hash_map() {
1155        h.into_iter()
1156            .map(|(k, val)| (k, value_to_bytes(&val)))
1157            .collect()
1158    } else if let Some(href) = v.as_hash_ref() {
1159        href.read()
1160            .iter()
1161            .map(|(k, val)| (k.clone(), value_to_bytes(val)))
1162            .collect()
1163    } else {
1164        return Err(io::Error::new(
1165            io::ErrorKind::InvalidInput,
1166            format!(
1167                "burp: expected a HASH or HASHREF, got {} \
1168                 — pass `\\%h` or an inline hashref `{{ ... }}`",
1169                v.type_name()
1170            ),
1171        ));
1172    };
1173    let mut written: i64 = 0;
1174    for (path, bytes) in &entries {
1175        spurt_path(
1176            path, bytes, /*mkdir_parents=*/ true, /*atomic=*/ false,
1177        )?;
1178        written += 1;
1179    }
1180    Ok(written)
1181}
1182
1183/// Bytes-faithful conversion mirroring `builtins::perl_scalar_as_bytes`: a
1184/// `bytes` value writes its raw bytes; anything else stringifies through the
1185/// standard `to_string()` path (which itself byte-decodes a bytes value, so
1186/// strings written via `burp` round-trip the way Perl callers expect).
1187fn value_to_bytes(v: &StrykeValue) -> Vec<u8> {
1188    if let Some(b) = v.as_bytes_arc() {
1189        return b.as_ref().clone();
1190    }
1191    v.to_string().into_bytes()
1192}
1193
1194/// Parallel recursive glob: same pattern semantics as [`glob_patterns`], but walks the
1195/// filesystem with rayon per directory (and parallelizes across patterns).
1196pub fn glob_par_patterns(patterns: &[String]) -> StrykeValue {
1197    glob_par_patterns_inner(patterns, None)
1198}
1199
1200/// Same as [`glob_par_patterns`], with a stderr progress bar (one tick per pattern) when
1201/// `progress` is true.
1202pub fn glob_par_patterns_with_progress(patterns: &[String], progress: bool) -> StrykeValue {
1203    if patterns.is_empty() {
1204        return StrykeValue::array(Vec::new());
1205    }
1206    let pmap = PmapProgress::new(progress, patterns.len());
1207    let v = glob_par_patterns_inner(patterns, Some(&pmap));
1208    pmap.finish();
1209    v
1210}
1211
1212fn glob_par_patterns_inner(patterns: &[String], progress: Option<&PmapProgress>) -> StrykeValue {
1213    // Parallelize across patterns. Each pattern goes through `zsh::glob::glob_path`
1214    // single-threaded so the full qualifier machinery is available; intra-
1215    // pattern parallelism is sacrificed in exchange for `(/)`, `(.)`, `(om)`,
1216    // `(L+N)`, etc. World-first: zsh glob qualifiers in a scripting language.
1217    let out: Vec<String> = patterns
1218        .par_iter()
1219        .flat_map_iter(|pat| {
1220            let rows: Vec<String> = if !pattern_is_glob(pat) {
1221                vec![pat.clone()]
1222            } else {
1223                stryke_glob(pat)
1224            };
1225            if let Some(p) = progress {
1226                p.tick();
1227            }
1228            rows
1229        })
1230        .collect();
1231    let mut paths: Vec<String> = out.into_iter().map(normalize_glob_path_display).collect();
1232    paths.sort();
1233    paths.dedup();
1234    StrykeValue::array(paths.into_iter().map(StrykeValue::string).collect())
1235}
1236
1237/// Display form for glob results. Pass-through — zshrs is authoritative
1238/// on prefixing (it already emits `./` for cwd matches and bare names
1239/// for colon-modifier results like `(:t)` / `(:e)` / `(:r)`). Re-adding
1240/// `./` here used to corrupt colon-modifier output.
1241fn normalize_glob_path_display(s: String) -> String {
1242    s
1243}
1244
1245/// `rename OLD, NEW` — 1 on success, 0 on failure (Perl-style).
1246pub fn rename_paths(old: &str, new: &str) -> StrykeValue {
1247    StrykeValue::integer(if std::fs::rename(old, new).is_ok() {
1248        1
1249    } else {
1250        0
1251    })
1252}
1253
1254#[inline]
1255fn is_cross_device_rename(e: &io::Error) -> bool {
1256    if e.kind() == io::ErrorKind::CrossesDevices {
1257        return true;
1258    }
1259    #[cfg(unix)]
1260    {
1261        if e.raw_os_error() == Some(libc::EXDEV) {
1262            return true;
1263        }
1264    }
1265    false
1266}
1267
1268fn try_move_path(from: &str, to: &str) -> io::Result<()> {
1269    match std::fs::rename(from, to) {
1270        Ok(()) => Ok(()),
1271        Err(e) => {
1272            if !is_cross_device_rename(&e) {
1273                return Err(e);
1274            }
1275            let meta = std::fs::symlink_metadata(from)?;
1276            if meta.is_dir() {
1277                return Err(io::Error::new(
1278                    io::ErrorKind::Unsupported,
1279                    "move: cross-device directory move is not supported",
1280                ));
1281            }
1282            if !meta.is_file() && !meta.is_symlink() {
1283                return Err(io::Error::new(
1284                    io::ErrorKind::Unsupported,
1285                    "move: cross-device move supports files and symlinks only",
1286                ));
1287            }
1288            std::fs::copy(from, to)?;
1289            std::fs::remove_file(from)?;
1290            Ok(())
1291        }
1292    }
1293}
1294
1295/// `move OLD, NEW` / `mv` — like `rename`, but on cross-device failure copies the file then removes
1296/// the source (directories not supported for cross-device).
1297pub fn move_path(from: &str, to: &str) -> StrykeValue {
1298    StrykeValue::integer(if try_move_path(from, to).is_ok() {
1299        1
1300    } else {
1301        0
1302    })
1303}
1304
1305#[cfg(unix)]
1306fn unix_path_executable(path: &Path) -> bool {
1307    use std::os::unix::fs::PermissionsExt;
1308    std::fs::metadata(path)
1309        .ok()
1310        .filter(|m| m.is_file())
1311        .is_some_and(|m| m.permissions().mode() & 0o111 != 0)
1312}
1313
1314#[cfg(not(unix))]
1315fn unix_path_executable(path: &Path) -> bool {
1316    path.is_file()
1317}
1318
1319fn display_executable_path(path: &Path) -> Option<String> {
1320    if !unix_path_executable(path) {
1321        return None;
1322    }
1323    path.canonicalize()
1324        .ok()
1325        .map(|p| p.to_string_lossy().into_owned())
1326        .or_else(|| Some(path.to_string_lossy().into_owned()))
1327}
1328
1329#[cfg(windows)]
1330fn pathext_suffixes() -> Vec<String> {
1331    env::var_os("PATHEXT")
1332        .map(|s| {
1333            env::split_paths(&s)
1334                .filter_map(|p| p.to_str().map(str::to_ascii_lowercase))
1335                .collect()
1336        })
1337        .unwrap_or_else(|| vec![".exe".into(), ".cmd".into(), ".bat".into(), ".com".into()])
1338}
1339
1340#[cfg(windows)]
1341fn which_in_dir(dir: &Path, program: &str) -> Option<String> {
1342    let plain = dir.join(program);
1343    if let Some(s) = display_executable_path(&plain) {
1344        return Some(s);
1345    }
1346    if !program.contains('.') {
1347        for ext in pathext_suffixes() {
1348            let cand = dir.join(format!("{program}{ext}"));
1349            if let Some(s) = display_executable_path(&cand) {
1350                return Some(s);
1351            }
1352        }
1353    }
1354    None
1355}
1356
1357#[cfg(not(windows))]
1358fn which_in_dir(dir: &Path, program: &str) -> Option<String> {
1359    display_executable_path(&dir.join(program))
1360}
1361
1362/// Resolve `program` using `PATH` (and optional current directory when `include_dot`).
1363/// Returns a path string or `None` if not found.
1364pub fn which_executable(program: &str, include_dot: bool) -> Option<String> {
1365    if program.is_empty() {
1366        return None;
1367    }
1368    if program.contains('/') || (cfg!(windows) && program.contains('\\')) {
1369        return display_executable_path(Path::new(program));
1370    }
1371    let path_os = env::var_os("PATH")?;
1372    for dir in env::split_paths(&path_os) {
1373        if let Some(s) = which_in_dir(&dir, program) {
1374            return Some(s);
1375        }
1376    }
1377    if include_dot {
1378        return which_in_dir(Path::new("."), program);
1379    }
1380    None
1381}
1382
1383/// Read entire file as raw bytes (no text decoding).
1384pub fn read_file_bytes(path: &str) -> io::Result<Arc<Vec<u8>>> {
1385    Ok(Arc::new(std::fs::read(path)?))
1386}
1387
1388/// Temp file adjacent to `target` for atomic replace (`rename` into place).
1389fn adjacent_temp_path(target: &Path) -> PathBuf {
1390    let dir = target.parent().unwrap_or_else(|| Path::new("."));
1391    let name = target
1392        .file_name()
1393        .map(|s| s.to_string_lossy().into_owned())
1394        .unwrap_or_else(|| "file".to_string());
1395    let rnd: u32 = rand::thread_rng().gen();
1396    dir.join(format!("{name}.spurt-tmp-{rnd}"))
1397}
1398
1399/// Write bytes to `path`. When `mkdir_parents`, creates parent directories. When `atomic`, writes
1400/// to a unique temp file in the same directory then `rename`s into place (best-effort crash safety).
1401pub fn spurt_path(path: &str, data: &[u8], mkdir_parents: bool, atomic: bool) -> io::Result<()> {
1402    let path = Path::new(path);
1403    if mkdir_parents {
1404        if let Some(parent) = path.parent() {
1405            if !parent.as_os_str().is_empty() {
1406                std::fs::create_dir_all(parent)?;
1407            }
1408        }
1409    }
1410    if !atomic {
1411        return std::fs::write(path, data);
1412    }
1413    let tmp = adjacent_temp_path(path);
1414    {
1415        let mut f = std::fs::File::create(&tmp)?;
1416        f.write_all(data)?;
1417        f.sync_all().ok();
1418    }
1419    std::fs::rename(&tmp, path)?;
1420    Ok(())
1421}
1422
1423/// `copy FROM, TO` — 1 on success, 0 on failure. When `preserve_metadata`, best-effort copy of
1424/// access/modification times from the source (after a successful byte copy).
1425pub fn copy_file(from: &str, to: &str, preserve_metadata: bool) -> StrykeValue {
1426    let times = if preserve_metadata {
1427        std::fs::metadata(from).ok().map(|src_meta| {
1428            let at = src_meta
1429                .accessed()
1430                .ok()
1431                .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
1432                .map(|d| d.as_secs() as i64)
1433                .unwrap_or(0);
1434            let mt = src_meta
1435                .modified()
1436                .ok()
1437                .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
1438                .map(|d| d.as_secs() as i64)
1439                .unwrap_or(0);
1440            (at, mt)
1441        })
1442    } else {
1443        None
1444    };
1445    if std::fs::copy(from, to).is_err() {
1446        return StrykeValue::integer(0);
1447    }
1448    if let Some((at, mt)) = times {
1449        let _ = utime_paths(at, mt, &[to.to_string()]);
1450    }
1451    StrykeValue::integer(1)
1452}
1453
1454/// [`std::path::Path::file_name`] as a string (empty if none).
1455pub fn path_basename(path: &str) -> String {
1456    Path::new(path)
1457        .file_name()
1458        .map(|s| s.to_string_lossy().into_owned())
1459        .unwrap_or_default()
1460}
1461
1462/// Parent directory string; `"."` when absent; `"/"` for POSIX root-ish paths.
1463pub fn path_dirname(path: &str) -> String {
1464    if path.is_empty() {
1465        return String::new();
1466    }
1467    let p = Path::new(path);
1468    if path == "/" {
1469        return "/".to_string();
1470    }
1471    match p.parent() {
1472        None => ".".to_string(),
1473        Some(parent) => {
1474            let s = parent.to_string_lossy();
1475            if s.is_empty() {
1476                ".".to_string()
1477            } else {
1478                s.into_owned()
1479            }
1480        }
1481    }
1482}
1483
1484/// `(base, dir, suffix)` like Perl `File::Basename::fileparse` with a single optional suffix.
1485/// When `suffix` is `Some` and `full_base` ends with it, `base` has the suffix removed and `suffix`
1486/// is the matched suffix; otherwise `suffix` in the return is empty.
1487pub fn fileparse_path(path: &str, suffix: Option<&str>) -> (String, String, String) {
1488    let dir = path_dirname(path);
1489    let full_base = path_basename(path);
1490    let (base, sfx) = if let Some(suf) = suffix.filter(|s| !s.is_empty()) {
1491        if full_base.ends_with(suf) && full_base.len() > suf.len() {
1492            (
1493                full_base[..full_base.len() - suf.len()].to_string(),
1494                suf.to_string(),
1495            )
1496        } else {
1497            (full_base.clone(), String::new())
1498        }
1499    } else {
1500        (full_base.clone(), String::new())
1501    };
1502    (base, dir, sfx)
1503}
1504
1505/// `chmod MODE, FILES...` — count of files successfully chmod'd.
1506pub fn chmod_paths(paths: &[String], mode: i64) -> i64 {
1507    #[cfg(unix)]
1508    {
1509        use std::os::unix::fs::PermissionsExt;
1510        let mut count = 0i64;
1511        for path in paths {
1512            if let Ok(meta) = std::fs::metadata(path) {
1513                let mut perms = meta.permissions();
1514                let old = perms.mode();
1515                // Perl passes permission bits (e.g. 0644); preserve st_mode file-type bits.
1516                perms.set_mode((old & !0o777) | (mode as u32 & 0o777));
1517                if std::fs::set_permissions(path, perms).is_ok() {
1518                    count += 1;
1519                }
1520            }
1521        }
1522        count
1523    }
1524    #[cfg(not(unix))]
1525    {
1526        let _ = (paths, mode);
1527        0
1528    }
1529}
1530
1531/// `utime ATIME, MTIME, FILES...` — count of paths successfully updated (Unix `utimes`; 0 on non-Unix).
1532pub fn utime_paths(atime_sec: i64, mtime_sec: i64, paths: &[String]) -> i64 {
1533    #[cfg(unix)]
1534    {
1535        use std::ffi::CString;
1536        let mut count = 0i64;
1537        let tv = [
1538            libc::timeval {
1539                tv_sec: atime_sec as libc::time_t,
1540                tv_usec: 0,
1541            },
1542            libc::timeval {
1543                tv_sec: mtime_sec as libc::time_t,
1544                tv_usec: 0,
1545            },
1546        ];
1547        for path in paths {
1548            let Ok(cs) = CString::new(path.as_str()) else {
1549                continue;
1550            };
1551            if unsafe { libc::utimes(cs.as_ptr(), tv.as_ptr()) } == 0 {
1552                count += 1;
1553            }
1554        }
1555        count
1556    }
1557    #[cfg(not(unix))]
1558    {
1559        let _ = (atime_sec, mtime_sec, paths);
1560        0
1561    }
1562}
1563
1564/// `chown UID, GID, FILES...` — count of files successfully chown'd (Unix only; 0 on non-Unix).
1565pub fn chown_paths(paths: &[String], uid: i64, gid: i64) -> i64 {
1566    #[cfg(unix)]
1567    {
1568        use std::ffi::CString;
1569        let u = if uid < 0 {
1570            (!0u32) as libc::uid_t
1571        } else {
1572            uid as libc::uid_t
1573        };
1574        let g = if gid < 0 {
1575            (!0u32) as libc::gid_t
1576        } else {
1577            gid as libc::gid_t
1578        };
1579        let mut count = 0i64;
1580        for path in paths {
1581            let Ok(c) = CString::new(path.as_str()) else {
1582                continue;
1583            };
1584            let r = unsafe { libc::chown(c.as_ptr(), u, g) };
1585            if r == 0 {
1586                count += 1;
1587            }
1588        }
1589        count
1590    }
1591    #[cfg(not(unix))]
1592    {
1593        let _ = (paths, uid, gid);
1594        0
1595    }
1596}
1597
1598/// `touch FILES...` — create files if they don't exist, update atime/mtime to
1599/// now if they do.  Returns count of files successfully touched.
1600pub fn touch_paths(paths: &[String]) -> i64 {
1601    use std::fs::OpenOptions;
1602    let mut count = 0i64;
1603    for path in paths {
1604        if path.is_empty() {
1605            continue;
1606        }
1607        // Create the file if it doesn't exist (like coreutils touch).
1608        let created = OpenOptions::new()
1609            .create(true)
1610            .append(true)
1611            .open(path)
1612            .is_ok();
1613        if !created {
1614            continue;
1615        }
1616        // Update atime + mtime to now.
1617        #[cfg(unix)]
1618        {
1619            use std::ffi::CString;
1620            if let Ok(cs) = CString::new(path.as_str()) {
1621                // null timeval pointer ⇒ set both times to now
1622                unsafe { libc::utimes(cs.as_ptr(), std::ptr::null()) };
1623            }
1624        }
1625        count += 1;
1626    }
1627    count
1628}
1629
1630#[cfg(test)]
1631mod tests {
1632    use super::*;
1633    use std::collections::HashSet;
1634
1635    #[test]
1636    fn glob_par_matches_sequential_glob_set() {
1637        let base = std::env::temp_dir().join(format!("stryke_glob_par_{}", std::process::id()));
1638        let _ = std::fs::remove_dir_all(&base);
1639        std::fs::create_dir_all(base.join("a")).unwrap();
1640        std::fs::create_dir_all(base.join("b")).unwrap();
1641        std::fs::create_dir_all(base.join("b/nested")).unwrap();
1642        std::fs::File::create(base.join("a/x.log")).unwrap();
1643        std::fs::File::create(base.join("b/y.log")).unwrap();
1644        std::fs::File::create(base.join("b/nested/z.log")).unwrap();
1645        std::fs::File::create(base.join("root.txt")).unwrap();
1646
1647        // Absolute patterns only — never `set_current_dir`; other tests run in parallel.
1648        let pat = format!("{}/**/*.log", base.display());
1649        let a = glob_patterns(std::slice::from_ref(&pat));
1650        let b = glob_par_patterns(std::slice::from_ref(&pat));
1651        let _ = std::fs::remove_dir_all(&base);
1652
1653        let set_a: HashSet<String> = a
1654            .as_array_vec()
1655            .expect("expected array")
1656            .into_iter()
1657            .map(|x| x.to_string())
1658            .collect();
1659        let set_b: HashSet<String> = b
1660            .as_array_vec()
1661            .expect("expected array")
1662            .into_iter()
1663            .map(|x| x.to_string())
1664            .collect();
1665        assert_eq!(set_a, set_b);
1666    }
1667
1668    #[test]
1669    fn glob_par_src_rs_matches_when_src_tree_present() {
1670        let root = Path::new(env!("CARGO_MANIFEST_DIR"));
1671        let src = root.join("src");
1672        if !src.is_dir() {
1673            return;
1674        }
1675        let pat = src.join("*.rs").to_string_lossy().into_owned();
1676        let v = glob_par_patterns(&[pat])
1677            .as_array_vec()
1678            .expect("expected array");
1679        assert!(
1680            !v.is_empty(),
1681            "glob_par src/*.rs should find at least one .rs under src/"
1682        );
1683    }
1684
1685    #[test]
1686    fn glob_par_progress_false_same_as_plain() {
1687        let tmp = Path::new(env!("CARGO_MANIFEST_DIR"))
1688            .join("target")
1689            .join(format!("glob_par_prog_false_{}", std::process::id()));
1690        let _ = std::fs::remove_dir_all(&tmp);
1691        std::fs::create_dir_all(&tmp).unwrap();
1692        std::fs::write(tmp.join("probe.rs"), b"// x\n").unwrap();
1693        let pat = tmp.join("*.rs").to_string_lossy().replace('\\', "/");
1694        let a = glob_par_patterns(std::slice::from_ref(&pat));
1695        let b = glob_par_patterns_with_progress(std::slice::from_ref(&pat), false);
1696        let _ = std::fs::remove_dir_all(&tmp);
1697        let va = a.as_array_vec().expect("a");
1698        let vb = b.as_array_vec().expect("b");
1699        assert_eq!(va.len(), vb.len(), "glob_par vs glob_par(..., progress=>0)");
1700        for (x, y) in va.iter().zip(vb.iter()) {
1701            assert_eq!(x.to_string(), y.to_string());
1702        }
1703    }
1704
1705    #[test]
1706    fn read_file_text_perl_compat_maps_invalid_utf8_to_latin1_octets() {
1707        let path = std::env::temp_dir().join(format!("stryke_bad_utf8_{}.txt", std::process::id()));
1708        // Lone continuation bytes — invalid UTF-8 as a whole; per-line Latin-1.
1709        std::fs::write(&path, b"ok\xff\xfe\x80\n").unwrap();
1710        let s = read_file_text_perl_compat(&path).expect("read");
1711        assert!(s.starts_with("ok"));
1712        assert_eq!(&s[2..], "\u{00ff}\u{00fe}\u{0080}\n");
1713        let _ = std::fs::remove_file(&path);
1714    }
1715
1716    #[cfg(unix)]
1717    #[test]
1718    fn slurp_glob_skips_dangling_symlink_in_plain_sweep() {
1719        // A plain wildcard sweep that incidentally matches a broken symlink
1720        // must skip it and keep reading the real files — one dead entry can't
1721        // abort the whole `c("**.rs")`-style sweep. Regression for the
1722        // `slurp: No such file or directory (os error 2)` that std::fs::metadata
1723        // raised when it followed a dangling link.
1724        use std::os::unix::fs::symlink;
1725        let dir = std::env::temp_dir().join(format!("stryke_slurp_dangling_{}", std::process::id()));
1726        let _ = std::fs::remove_dir_all(&dir);
1727        std::fs::create_dir_all(&dir).unwrap();
1728        std::fs::write(dir.join("real.txt"), b"hello").unwrap();
1729        symlink(dir.join("does_not_exist"), dir.join("dead.txt")).unwrap();
1730        let pat = format!("{}/*.txt", dir.display());
1731        let out = read_file_text_or_glob(&pat).expect("plain sweep must not fail on a dangling link");
1732        assert_eq!(out, "hello");
1733        let _ = std::fs::remove_dir_all(&dir);
1734    }
1735
1736    #[cfg(unix)]
1737    #[test]
1738    fn swallow_and_ingest_skip_dangling_symlink_in_plain_sweep() {
1739        // swallow/ingest share slurp's policy: a plain wildcard sweep skips a
1740        // bad reference (dangling symlink) rather than aborting the per-file
1741        // result with `not a regular file` / `os error 2`.
1742        use std::os::unix::fs::symlink;
1743        let dir = std::env::temp_dir().join(format!("stryke_swallow_dangling_{}", std::process::id()));
1744        let _ = std::fs::remove_dir_all(&dir);
1745        std::fs::create_dir_all(&dir).unwrap();
1746        std::fs::write(dir.join("real.txt"), b"hello").unwrap();
1747        symlink(dir.join("does_not_exist"), dir.join("dead.txt")).unwrap();
1748        let pat = format!("{}/*.txt", dir.display());
1749
1750        let hash = swallow_to_hash(&pat).expect("swallow must skip the dangling link");
1751        let keys: Vec<String> = hash.as_hash_map().unwrap().keys().cloned().collect();
1752        assert_eq!(keys.len(), 1, "only the real file survives: {:?}", keys);
1753        assert!(keys[0].ends_with("real.txt"));
1754
1755        // ingest pre-resolves the same match set without aborting.
1756        ingest_iterator(&pat).expect("ingest must skip the dangling link");
1757        let _ = std::fs::remove_dir_all(&dir);
1758    }
1759
1760    #[test]
1761    fn read_logical_line_perl_compat_splits_and_decodes_per_line() {
1762        use std::io::Cursor;
1763        let mut r = Cursor::new(b"a\xff\nb\n");
1764        assert_eq!(
1765            read_logical_line_perl_compat(&mut r).unwrap(),
1766            Some("a\u{00ff}".to_string())
1767        );
1768        assert_eq!(
1769            read_logical_line_perl_compat(&mut r).unwrap(),
1770            Some("b".to_string())
1771        );
1772        assert_eq!(read_logical_line_perl_compat(&mut r).unwrap(), None);
1773    }
1774
1775    /// Regression for the `-0` (NUL-separator) bug: stryke v0.16.4 ignored the
1776    /// configured input record separator and hardcoded `\n`, so a NUL-free file
1777    /// got split per line instead of read as one record. perl `-0` on the same
1778    /// input reads the whole 5-byte buffer in one shot. Reported via the
1779    /// powerliners `togg` shell function whose add-path regex needs to match
1780    /// across newlines.
1781    ///
1782    /// Returns the record with the trailing input (no NUL exists in the file →
1783    /// trailing `\n` from EOF is preserved) — perl `<>` leaves the IRS / trailing
1784    /// bytes in `$_`; chomp / `-l` strip them in the autoprint pipeline.
1785    #[test]
1786    fn read_logical_line_with_sep_nul_reads_whole_no_nul_file_as_one_record() {
1787        use std::io::Cursor;
1788        let mut r = Cursor::new(b"{\n\n}\n");
1789        assert_eq!(
1790            read_logical_line_perl_compat_with_sep(&mut r, Some(0u8)).unwrap(),
1791            Some("{\n\n}\n".to_string()),
1792        );
1793        assert_eq!(
1794            read_logical_line_perl_compat_with_sep(&mut r, Some(0u8)).unwrap(),
1795            None,
1796        );
1797    }
1798
1799    /// `-0NN` octal separator (e.g. `-040` = space) splits on that byte. Returns each record
1800    /// with the trailing separator included (perl `$_` semantics).
1801    #[test]
1802    fn read_logical_line_with_sep_splits_on_arbitrary_byte() {
1803        use std::io::Cursor;
1804        let mut r = Cursor::new(b"a b c");
1805        assert_eq!(
1806            read_logical_line_perl_compat_with_sep(&mut r, Some(b' ')).unwrap(),
1807            Some("a ".to_string()),
1808        );
1809        assert_eq!(
1810            read_logical_line_perl_compat_with_sep(&mut r, Some(b' ')).unwrap(),
1811            Some("b ".to_string()),
1812        );
1813        assert_eq!(
1814            read_logical_line_perl_compat_with_sep(&mut r, Some(b' ')).unwrap(),
1815            Some("c".to_string()),
1816        );
1817        assert_eq!(
1818            read_logical_line_perl_compat_with_sep(&mut r, Some(b' ')).unwrap(),
1819            None,
1820        );
1821    }
1822
1823    /// `-0777` slurp mode reads everything as one record.
1824    #[test]
1825    fn read_logical_line_with_sep_none_slurps_to_eof() {
1826        use std::io::Cursor;
1827        let mut r = Cursor::new(b"line1\nline2\nline3");
1828        assert_eq!(
1829            read_logical_line_perl_compat_with_sep(&mut r, None).unwrap(),
1830            Some("line1\nline2\nline3".to_string()),
1831        );
1832        assert_eq!(
1833            read_logical_line_perl_compat_with_sep(&mut r, None).unwrap(),
1834            None,
1835        );
1836    }
1837
1838    /// Default line mode (`Some(b'\n')`) returns each line *with* trailing `\n`. Distinct from
1839    /// `read_logical_line_perl_compat` which strips per its legacy contract.
1840    #[test]
1841    fn read_logical_line_with_sep_line_mode_preserves_trailing_newline() {
1842        use std::io::Cursor;
1843        let mut r = Cursor::new(b"foo\nbar\n");
1844        assert_eq!(
1845            read_logical_line_perl_compat_with_sep(&mut r, Some(b'\n')).unwrap(),
1846            Some("foo\n".to_string()),
1847        );
1848        assert_eq!(
1849            read_logical_line_perl_compat_with_sep(&mut r, Some(b'\n')).unwrap(),
1850            Some("bar\n".to_string()),
1851        );
1852        assert_eq!(
1853            read_logical_line_perl_compat_with_sep(&mut r, Some(b'\n')).unwrap(),
1854            None,
1855        );
1856    }
1857}