Skip to main content

zsh/extensions/
zle_file_tester.rs

1//! Port of fish-shell's `highlight/file_tester.rs` (vendor/fish/highlight/file_tester.rs).
2//!
3//! fish:1-4 — Support for testing whether files exist and have the correct permissions,
4//! to support highlighting. Because this may perform blocking I/O, we compute results in a
5//! separate thread, and provide them optimistically.
6//!
7//! zshrs substrate swaps (each cited at its site):
8//!   * `wstr`/`WString`                → `&str`/`String`
9//!   * fish expansion markers (ANY_CHAR, VARIABLE_EXPAND, …) → zsh lexer token chars
10//!     (`Pound`..`Nularg`, zsh_h.rs:144-204); INTERNAL_SEPARATOR ↔ `Nularg`/quote-nulls
11//!   * `unescape_string(Script|SPECIAL)` → inputs arrive as zsh *tokenized* strings from
12//!     the lexer (`tokstr`); clean fragment built via `untokenize` (lex.rs:4812)
13//!   * `expand_one(FAIL_ON_CMDSUBST)`  → cmdsubst-token guard + `singsub` (subst.rs:1460)
14//!   * `expand_tilde`                  → `filesubstr` (subst.rs:1921, zsh Src/subst.c filesub)
15//!   * `wstat`/`waccess`/`DirIter`     → `std::fs::metadata`/`libc::access`/`std::fs::read_dir`
16//!   * `fpathconf(fd, _PC_CASE_SENSITIVE)` → `pathconf(path, …)` (read_dir exposes no fd)
17//!
18//! Also ports the three small fish helpers this file leans on, so the logic stays faithful:
19//!   * `normalize_path`                — fish wutil/mod.rs:170-211
20//!   * `path_apply_working_directory`  — fish path.rs:505-531
21//!   * `RedirectionMode`               — fish redirection.rs:10-23
22//!   * `fish_wcstoi`                   — fish wutil strtol-style base-10 integer parse
23
24#![allow(non_snake_case)]
25
26use crate::ported::lex::untokenize;
27use crate::ported::params::getsparam;
28use crate::ported::subst::{filesubstr, singsub};
29use crate::ported::utils::errflag;
30use crate::ported::zsh_h::{
31    Bang, Bnull, Bnullkeep, Comma, Dash, Dnull, Inpar, Inparmath, Nularg, Qtick, Snull, Tick,
32    Tilde,
33};
34use std::collections::{HashMap, HashSet};
35use std::ffi::CString;
36use std::sync::atomic::{AtomicBool, Ordering};
37use std::sync::Arc;
38
39// fish:29-30 — This is used only internally to this file, and is exposed only for testing.
40/// fish:31-42 — `PathFlags`.
41#[derive(Clone, Copy, Default)]
42pub struct PathFlags {
43    // fish:33 — The path must be to a directory.
44    pub require_dir: bool,
45    // fish:36 — Expand any leading tilde in the path.
46    pub expand_tilde: bool,
47    // fish:39 — Normalize directories before resolving, as "cd".
48    pub for_cd: bool,
49}
50
51// fish:44-45 — When a file test is OK, we may also return whether this was a file.
52// This is used for underlining and is dependent on the particular file test.
53/// fish:47-48 — `IsFile`.
54#[derive(Debug, Copy, Clone, PartialEq, Eq)]
55pub struct IsFile(pub bool);
56/// fish:49-51 — `IsErr`.
57#[derive(Debug, Copy, Clone, PartialEq, Eq)]
58pub struct IsErr;
59
60/// fish:53-54 — The result of a file test.
61pub type FileTestResult = Result<IsFile, IsErr>;
62
63/// fish:redirection.rs:10-23 — `RedirectionMode`. zsh redir-type mapping happens at the
64/// highlighter call site (WRITE→Overwrite, APP→Append, READ→Input, MERGEIN/MERGEOUT→Fd,
65/// noclobber WRITE→NoClob). `TryInput` (`<?`) has no zsh spelling but is kept for parity.
66#[derive(Debug, Copy, Clone, PartialEq, Eq)]
67pub enum RedirectionMode {
68    /// fish:redirection.rs:12 — normal redirection: > file.txt
69    Overwrite,
70    /// fish:redirection.rs:14 — appending redirection: >> file.txt
71    Append,
72    /// fish:redirection.rs:16 — input redirection: < file.txt
73    Input,
74    /// fish:redirection.rs:18 — try-input redirection: <? file.txt
75    TryInput,
76    /// fish:redirection.rs:20 — fd redirection: 2>&1
77    Fd,
78    /// fish:redirection.rs:22 — noclobber redirection: >? file.txt (zsh: > with NO_CLOBBER)
79    NoClob,
80}
81
82/// !!! WARNING: RUST-ONLY ADAPTER — NO DIRECT FISH COUNTERPART SHAPE !!!
83/// fish's `OperationContext` (operation_context.rs) bundles an `Environment` + expansion
84/// limits + a cancel checker. zshrs reads params via `getsparam` directly, so only the
85/// cancel signal is carried. The cancel flag exists so a future async compute thread can
86/// abandon stale requests exactly like fish's background highlight thread does.
87pub struct OperationContext {
88    cancel_flag: Arc<AtomicBool>,
89    /// Wall-clock budget: past this instant, check_cancel() reports cancelled.
90    /// fish runs its file tests on a background thread with unbounded time;
91    /// zshrs computes synchronously on the ZLE thread, so every IO loop
92    /// (directory walks, PATH scans, candidate validation) must be bounded
93    /// or a large directory turns each keystroke into a lag spike. All the
94    /// loops already poll check_cancel() — the fish shape — so the deadline
95    /// composes without touching them.
96    deadline: Option<std::time::Instant>,
97}
98
99impl OperationContext {
100    /// fish:operation_context.rs `OperationContext::empty()` — a context that never cancels.
101    pub fn empty() -> Self {
102        Self {
103            cancel_flag: Arc::new(AtomicBool::new(false)),
104            deadline: None,
105        }
106    }
107
108    /// Build a context sharing an external cancel flag.
109    pub fn with_cancel_flag(cancel_flag: Arc<AtomicBool>) -> Self {
110        Self {
111            cancel_flag,
112            deadline: None,
113        }
114    }
115
116    /// A context that self-cancels after `budget` (see `deadline` field docs).
117    pub fn with_budget(budget: std::time::Duration) -> Self {
118        Self {
119            cancel_flag: Arc::new(AtomicBool::new(false)),
120            deadline: Some(std::time::Instant::now() + budget),
121        }
122    }
123
124    /// fish:operation_context.rs `check_cancel()`.
125    pub fn check_cancel(&self) -> bool {
126        self.cancel_flag.load(Ordering::Relaxed)
127            || self
128                .deadline
129                .is_some_and(|d| std::time::Instant::now() >= d)
130    }
131}
132
133/// fish:55-63 — `FileTester`.
134pub struct FileTester<'s> {
135    // fish:57-59 — The working directory, for resolving paths against.
136    working_directory: String,
137    // fish:60-62 — The operation context.
138    ctx: &'s OperationContext,
139}
140
141impl<'s> FileTester<'s> {
142    /// fish:66-72 — `new`.
143    pub fn new(working_directory: String, ctx: &'s OperationContext) -> Self {
144        Self {
145            working_directory,
146            ctx,
147        }
148    }
149
150    /// fish:74-77 — Test whether a file exists and is readable.
151    /// The input string 'token' is given as a *tokenized* string (as produced by the zsh
152    /// lexer; fish receives an escaped-as-typed string — same information, zsh spelling).
153    /// If 'prefix' is true, instead check if the path is a prefix of a valid path.
154    /// Returns false on cancellation.
155    pub fn test_path(&self, token: &str, prefix: bool) -> bool {
156        // fish:79-83 — Skip strings exceeding PATH_MAX. See fish#7837.
157        // Note some paths may exceed PATH_MAX, but this is just for highlighting.
158        if token.len() > (libc::PATH_MAX as usize) {
159            return false;
160        }
161
162        // fish:85-96 — fish unescapes here and re-plants '~' over HOME_DIRECTORY. zshrs
163        // inputs stay tokenized: `is_potential_path` untokenizes and tilde-expands itself,
164        // so the token passes through unchanged.
165        is_potential_path(
166            token,
167            prefix,
168            std::slice::from_ref(&self.working_directory),
169            self.ctx,
170            PathFlags {
171                expand_tilde: true,
172                ..Default::default()
173            },
174        )
175    }
176
177    // fish:110-112 — Test if the string is a prefix of a valid path we could cd into, or is
178    // some other token we recognize (primarily --help).
179    // If is_prefix is true, we test if the string is a prefix of a valid path we could cd into.
180    /// fish:113-141 — `test_cd_path`.
181    pub fn test_cd_path(&self, token: &str, is_prefix: bool) -> FileTestResult {
182        let mut param = token.to_owned(); // fish:115
183        if !expand_one_no_cmdsubst(&mut param) {
184            // fish:116-119 — Failed expansion (e.g. may contain a command substitution).
185            // Ignore it.
186            return FileTestResult::Ok(IsFile(false));
187        }
188        // fish:120-124 — Maybe it's just --help.
189        if "--help".starts_with(&param) || "-h".starts_with(&param) {
190            return FileTestResult::Ok(IsFile(false));
191        }
192        let valid_path = is_potential_cd_path(
193            &param,
194            is_prefix,
195            &self.working_directory,
196            self.ctx,
197            PathFlags {
198                expand_tilde: true,
199                ..Default::default()
200            },
201        ); // fish:125-134
202           // fish:135-140 — cd into an invalid path is an error.
203        if valid_path {
204            Ok(IsFile(valid_path))
205        } else {
206            Err(IsErr)
207        }
208    }
209
210    // fish:143-145 — Test if the given string is a valid redirection target, and if so,
211    // whether it is a path to an existing file.
212    /// fish:146-231 — `test_redirection_target`.
213    pub fn test_redirection_target(&self, target: &str, mode: RedirectionMode) -> FileTestResult {
214        // fish:147-150 — Skip targets exceeding PATH_MAX. See fish#7837.
215        if target.len() > (libc::PATH_MAX as usize) {
216            return Err(IsErr);
217        }
218        let mut target = target.to_owned(); // fish:151
219        if !expand_one_no_cmdsubst(&mut target) {
220            // fish:152-155 — Could not be expanded.
221            return Err(IsErr);
222        }
223        // fish:156-159 — Ok, we successfully expanded our target. Now verify that it works
224        // with this redirection. We will probably need it as a path (but not in the case of
225        // fd redirections). Note that the target is now unescaped.
226        let target_path = path_apply_working_directory(&target, &self.working_directory);
227        match mode {
228            RedirectionMode::Fd => {
229                // fish:161-168
230                if target == "-" {
231                    return Ok(IsFile(false));
232                }
233                match fish_wcstoi(&target) {
234                    Ok(fd) if fd >= 0 => Ok(IsFile(false)),
235                    _ => Err(IsErr),
236                }
237            }
238            RedirectionMode::Input | RedirectionMode::TryInput => {
239                // fish:170-181 — Input redirections must have a readable non-directory.
240                // Note we color "try_input" files as errors if they are invalid,
241                // even though it's possible to execute these (replaced via /dev/null).
242                if waccess(&target_path, libc::R_OK)
243                    && wstat(&target_path).is_ok_and(|md| !md.file_type().is_dir())
244                {
245                    Ok(IsFile(true))
246                } else {
247                    Err(IsErr)
248                }
249            }
250            RedirectionMode::Overwrite | RedirectionMode::Append | RedirectionMode::NoClob => {
251                // fish:182-187 — Redirections to things that are directories is definitely
252                // not allowed.
253                if target.ends_with('/') {
254                    return Err(IsErr);
255                }
256                // fish:188-190 — Test whether the file exists, and whether it's writable
257                // (possibly after creating it). access() returns failure if the file does
258                // not exist.
259                let file_exists;
260                let file_is_writable;
261                match wstat(&target_path) {
262                    Ok(md) => {
263                        // fish:193-199 — No err. We can write to it if it's not a directory
264                        // and we have permission.
265                        file_exists = true;
266                        file_is_writable =
267                            !md.file_type().is_dir() && waccess(&target_path, libc::W_OK);
268                    }
269                    Err(err) => {
270                        if err.raw_os_error() == Some(libc::ENOENT) {
271                            // fish:201-203 — File does not exist. Check if its parent
272                            // directory is writable.
273                            let mut parent = wdirname(&target_path);
274
275                            // fish:205-210 — Ensure that the parent ends with the path
276                            // separator. This will ensure that we get an error if the
277                            // parent directory is not really a directory.
278                            if !parent.ends_with('/') {
279                                parent.push('/');
280                            }
281
282                            // fish:212-215 — Now the file is considered writable if the
283                            // parent directory is writable.
284                            file_exists = false;
285                            file_is_writable = waccess(&parent, libc::W_OK);
286                        } else {
287                            // fish:216-221 — Other errors we treat as not writable. This
288                            // includes things like ENOTDIR.
289                            file_exists = false;
290                            file_is_writable = false;
291                        }
292                    }
293                }
294                // fish:224-227 — NoClob means that we must not overwrite files that exist.
295                if !file_is_writable || (mode == RedirectionMode::NoClob && file_exists) {
296                    return Err(IsErr);
297                }
298                Ok(IsFile(file_exists)) // fish:228
299            }
300        }
301    }
302}
303
304/// fish:234-238 — Tests whether the specified string cpath is the prefix of anything we
305/// could cd to. directories is a list of possible parent directories (typically either the
306/// working directory, or the cdpath). This does I/O!
307///
308/// zshrs: expects the path *tokenized* (fish expects it unescaped with expansion markers —
309/// zsh token chars carry the identical "was this quoted/expandable" information).
310pub fn is_potential_path(
311    potential_path_fragment: &str,
312    at_cursor: bool,
313    directories: &[String],
314    ctx: &OperationContext,
315    flags: PathFlags,
316) -> bool {
317    // fish:246-250 — fish asserts this runs on its background thread; zshrs computes
318    // synchronously in the ZLE thread (path checks are budgeted by the cancel flag), so
319    // the assertion is dropped.
320
321    if ctx.check_cancel() {
322        // fish:252-254
323        return false;
324    }
325
326    let require_dir = flags.require_dir; // fish:256
327    let mut clean_potential_path_fragment = String::new(); // fish:257
328    let mut has_magic = false; // fish:258
329
330    let mut path_with_magic = potential_path_fragment.to_owned(); // fish:260
331    if flags.expand_tilde {
332        // fish:261-263 — fish expand_tilde; zsh analog is filesub (subst.rs filesubstr),
333        // which expands a leading (tokenized) `~`/`~user`/`~+`/`~-` prefix.
334        expand_tilde(&mut path_with_magic);
335    }
336
337    // fish:265-281 — fish scans for its expansion markers (PROCESS_EXPAND_SELF,
338    // VARIABLE_EXPAND[_SINGLE], BRACE_*, ANY_CHAR, ANY_STRING[_RECURSIVE]) and skips
339    // INTERNAL_SEPARATOR. zsh spelling: any lexer token char that triggers expansion or
340    // globbing is magic; quote-null artifacts (Snull/Dnull/Bnull/Bnullkeep/Nularg) are the
341    // INTERNAL_SEPARATOR analog; Tilde/Comma/Dash/Bang untokenize to harmless literals.
342    for c in path_with_magic.chars() {
343        match c {
344            Snull | Dnull | Bnull | Bnullkeep | Nularg => (), // fish:278 INTERNAL_SEPARATOR
345            Tilde => clean_potential_path_fragment.push('~'),
346            Comma => clean_potential_path_fragment.push(','),
347            Dash => clean_potential_path_fragment.push('-'),
348            Bang => clean_potential_path_fragment.push('!'),
349            c if ('\u{84}'..='\u{a1}').contains(&c) => {
350                // fish:267-277 — remaining ITOK range (Pound..Nularg, zsh_h.rs:144-204):
351                // globs (Star/Quest/Inbrack/Pound/Hat/Bar/Inang), expansions
352                // (String/Qstring/Tick/Qtick/Inpar/Inbrace/Equals) — all magic.
353                has_magic = true;
354            }
355            _ => clean_potential_path_fragment.push(c), // fish:279
356        }
357    }
358
359    if has_magic || clean_potential_path_fragment.is_empty() {
360        // fish:283-285
361        return false;
362    }
363
364    // fish:287-289 — Don't test the same path multiple times, which can happen if the path
365    // is absolute and the CDPATH contains multiple entries.
366    let mut checked_paths: HashSet<String> = HashSet::new();
367
368    // fish:291-292 — Keep a cache of which paths / filesystems are case sensitive.
369    let mut case_sensitivity_cache = CaseSensitivityCache::new();
370
371    for wd in directories {
372        // fish:294
373        if ctx.check_cancel() {
374            // fish:295-297
375            return false;
376        }
377        let mut abs_path = path_apply_working_directory(&clean_potential_path_fragment, wd); // fish:298
378        let must_be_full_dir = abs_path.ends_with('/'); // fish:299
379        if flags.for_cd {
380            // fish:300-302
381            abs_path = normalize_path(&abs_path, /*allow_leading_double_slashes=*/ true);
382        }
383
384        // fish:304-307 — Skip this if it's empty or we've already checked it.
385        if abs_path.is_empty() || checked_paths.contains(&abs_path) {
386            continue;
387        }
388        checked_paths.insert(abs_path.clone()); // fish:308
389
390        // fish:310-315 — If the user is still typing the argument, we want to highlight it
391        // if it's the prefix of a valid path. This means we need to potentially walk all
392        // files in some directory. There are two easy cases where we can skip this:
393        // 1. If the argument ends with a slash, it must be a valid directory, no prefix.
394        // 2. If the cursor is not at the argument, it means the user is definitely not
395        //    typing it, so we can skip the prefix-match.
396        if must_be_full_dir || !at_cursor {
397            // fish:316
398            if let Ok(md) = wstat(&abs_path) {
399                // fish:317
400                if !at_cursor || md.file_type().is_dir() {
401                    // fish:318-320
402                    return true;
403                }
404            }
405        } else {
406            // fish:322-323 — We do not end with a slash; it does not have to be a directory.
407            let dir_name = wdirname(&abs_path); // fish:324
408            let filename_fragment = wbasename(&abs_path); // fish:325
409            if dir_name == "/" && filename_fragment == "/" {
410                // fish:326-329 — cd ///.... No autosuggestion.
411                return true;
412            }
413
414            if let Ok(dir) = std::fs::read_dir(&dir_name) {
415                // fish:331
416                // fish:332-334 — Check if we're case insensitive.
417                let do_case_insensitive =
418                    fs_is_case_insensitive(&dir_name, &mut case_sensitivity_cache);
419
420                // fish:336 — We opened the dir_name; look for a string where the base name
421                // prefixes it.
422                for entry in dir {
423                    // fish:337
424                    let Ok(entry) = entry else { continue }; // fish:338
425                    if ctx.check_cancel() {
426                        // fish:339-341
427                        return false;
428                    }
429
430                    // fish:343-346 — Maybe skip directories.
431                    if require_dir && !dir_entry_is_dir(&entry) {
432                        continue;
433                    }
434
435                    let entry_name = entry.file_name().to_string_lossy().into_owned();
436                    if entry_name.starts_with(&filename_fragment)
437                        || (do_case_insensitive
438                            && string_prefixes_string_case_insensitive(
439                                &filename_fragment,
440                                &entry_name,
441                            ))
442                    {
443                        // fish:348-356
444                        return true;
445                    }
446                }
447            }
448        }
449    }
450    false // fish:361
451}
452
453// fish:364-365 — Given a string, return whether it prefixes a path that we could cd into.
454// Expects path to be unescaped (zshrs: tokenized, see is_potential_path).
455/// fish:366-402 — `is_potential_cd_path`.
456pub fn is_potential_cd_path(
457    path: &str,
458    at_cursor: bool,
459    working_directory: &str,
460    ctx: &OperationContext,
461    mut flags: PathFlags,
462) -> bool {
463    let mut directories: Vec<String> = vec![]; // fish:374
464
465    if path.starts_with("./") {
466        // fish:376-378 — Ignore the CDPATH in this case; just use the working directory.
467        directories.push(working_directory.to_owned());
468    } else {
469        // fish:380-384 — Get the CDPATH. zsh spelling: colon-split $CDPATH (same read the
470        // cd builtin does, builtin.rs:2179-2183).
471        let cdpath_str = getsparam("CDPATH").unwrap_or_default();
472        let mut pathsv: Vec<String> = if cdpath_str.is_empty() {
473            vec![".".to_owned()]
474        } else {
475            cdpath_str.split(':').map(str::to_owned).collect()
476        };
477        // fish:386-387 — The current $PWD is always valid.
478        pathsv.push(".".to_owned());
479
480        for mut next_path in pathsv {
481            // fish:389
482            if next_path.is_empty() {
483                // fish:390-392
484                next_path = ".".to_owned();
485            }
486            // fish:393-394 — Ensure that we use the working directory for relative cdpaths
487            // like ".".
488            directories.push(path_apply_working_directory(&next_path, working_directory));
489        }
490    }
491
492    // fish:398-401 — Call is_potential_path with all of these directories.
493    flags.require_dir = true;
494    flags.for_cd = true;
495    is_potential_path(path, at_cursor, &directories, ctx, flags)
496}
497
498/// fish:404-410 — Determine if the filesystem containing the given path is case insensitive
499/// for lookups regardless of whether it preserves the case when saving a pathname.
500///
501/// Returns:
502///     false: the filesystem is not case insensitive
503///     true: the file system is case insensitive
504pub type CaseSensitivityCache = HashMap<String, bool>;
505
506/// fish:412-427 — `fs_is_case_insensitive`. Adaptation: fish asks `fpathconf` on the open
507/// DirIter fd; `std::fs::read_dir` exposes no fd, so ask `pathconf` on the path instead —
508/// identical answer for the same filesystem object.
509#[cfg(any(target_os = "macos", target_os = "ios"))]
510fn fs_is_case_insensitive(path: &str, case_sensitivity_cache: &mut CaseSensitivityCache) -> bool {
511    if let Some(cached) = case_sensitivity_cache.get(path) {
512        // fish:418-420
513        return *cached;
514    }
515    // fish:421-423 — Ask the system. A -1 value means error (so assume case sensitive), a 1
516    // value means case sensitive, and a 0 value means case insensitive.
517    let Ok(cpath) = CString::new(path) else {
518        return false;
519    };
520    let ret = unsafe { libc::pathconf(cpath.as_ptr(), libc::_PC_CASE_SENSITIVE) };
521    let icase = ret == 0; // fish:424
522    case_sensitivity_cache.insert(path.to_owned(), icase); // fish:425
523    icase // fish:426
524}
525
526/// fish:428-437 — Other platforms don't have _PC_CASE_SENSITIVE.
527#[cfg(not(any(target_os = "macos", target_os = "ios")))]
528fn fs_is_case_insensitive(
529    _path: &str,
530    _case_sensitivity_cache: &mut CaseSensitivityCache,
531) -> bool {
532    false
533}
534
535// ---------------------------------------------------------------------------
536// Ported fish helpers this file depends on (each from its own fish home).
537// ---------------------------------------------------------------------------
538
539/// fish:wutil/mod.rs:170-211 — `normalize_path`: lexical path normalization (collapses
540/// `.`/`..`/repeated slashes) as "cd" does, without touching the filesystem.
541pub fn normalize_path(path: &str, allow_leading_double_slashes: bool) -> String {
542    // fish:wutil/mod.rs:171-179 — Count the leading slashes.
543    let sep = '/';
544    let mut leading_slashes: usize = 0;
545    for c in path.chars() {
546        if c != sep {
547            break;
548        }
549        leading_slashes += 1;
550    }
551
552    let comps: Vec<&str> = path.split(sep).collect(); // fish:wutil/mod.rs:181
553    let mut new_comps: Vec<&str> = Vec::new(); // fish:wutil/mod.rs:182
554    for comp in comps {
555        // fish:wutil/mod.rs:183
556        if comp.is_empty() || comp == "." {
557            // fish:wutil/mod.rs:184-185
558            continue;
559        } else if comp != ".." {
560            // fish:wutil/mod.rs:186-187
561            new_comps.push(comp);
562        } else if !new_comps.is_empty() && *new_comps.last().unwrap() != ".." {
563            // fish:wutil/mod.rs:188-190 — '..' with a real path component, drop that path
564            // component.
565            new_comps.pop();
566        } else if leading_slashes == 0 {
567            // fish:wutil/mod.rs:191-193 — We underflowed the .. and are a relative (not
568            // absolute) path.
569            new_comps.push("..");
570        }
571    }
572    let mut result = new_comps.join(&sep.to_string()); // fish:wutil/mod.rs:196
573                                                       // fish:wutil/mod.rs:197-198 — If we don't allow leading double slashes, collapse them
574                                                       // to 1 if there are any.
575    let mut numslashes = if leading_slashes > 0 { 1 } else { 0 };
576    // fish:wutil/mod.rs:199-203 — If we do, prepend one or two leading slashes.
577    // Yes, three+ slashes are collapsed to one. (!)
578    if allow_leading_double_slashes && leading_slashes == 2 {
579        numslashes = 2;
580    }
581    for _ in 0..numslashes {
582        result.insert(0, sep); // fish:wutil/mod.rs:204-206
583    }
584    // fish:wutil/mod.rs:207-210 — Ensure ./ normalizes to . and not empty.
585    if result.is_empty() {
586        result.push('.');
587    }
588    result
589}
590
591/// fish:path.rs:505-531 — `path_apply_working_directory`: resolve `path` against
592/// `working_directory` unless it is empty or absolute.
593pub fn path_apply_working_directory(path: &str, working_directory: &str) -> String {
594    if path.is_empty() || working_directory.is_empty() {
595        // fish:path.rs:506-508
596        return path.to_owned();
597    }
598
599    // fish:path.rs:510-512 — We're going to make sure that if we want to prepend the wd,
600    // that the string has no leading "/". (fish also exempts HOME_DIRECTORY; the zsh tilde
601    // is expanded before this point, and an unexpanded literal '~' resolves relative.)
602    let prepend_wd = !path.starts_with('/');
603
604    if !prepend_wd {
605        // fish:path.rs:514-517 — No need to prepend the wd, so just return the path we were
606        // given.
607        return path.to_owned();
608    }
609
610    // fish:path.rs:519-523 — Remove up to one "./".
611    let mut path_component = path.to_owned();
612    if path_component.starts_with("./") {
613        path_component.replace_range(0..2, "");
614    }
615
616    // fish:path.rs:525-528 — Removing leading /s.
617    while path_component.starts_with('/') {
618        path_component.replace_range(0..1, "");
619    }
620
621    // fish:path.rs:530-531 — Construct and return a new path.
622    let mut result = working_directory.to_owned();
623    if !result.ends_with('/') {
624        result.push('/');
625    }
626    result.push_str(&path_component);
627    result
628}
629
630/// fish:wutil `fish_wcstoi` — strtol-style base-10 parse: optional leading whitespace,
631/// optional single sign, one or more digits, NO trailing garbage, overflow is an error.
632/// ("We are base 10, despite the leading 0" — fish:692.)
633pub fn fish_wcstoi(s: &str) -> Result<i32, ()> {
634    let t = s.trim_start_matches([' ', '\t']);
635    let (neg, digits) = match t.strip_prefix('-') {
636        Some(rest) => (true, rest),
637        None => (false, t.strip_prefix('+').unwrap_or(t)),
638    };
639    if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
640        return Err(());
641    }
642    let mut acc: i64 = 0;
643    for b in digits.bytes() {
644        acc = acc.checked_mul(10).ok_or(())?;
645        acc = acc.checked_add((b - b'0') as i64).ok_or(())?;
646        if acc > i32::MAX as i64 + 1 {
647            return Err(());
648        }
649    }
650    let signed = if neg { -acc } else { acc };
651    i32::try_from(signed).map_err(|_| ())
652}
653
654// ---------------------------------------------------------------------------
655// zsh-substrate adapters (each replaces a fish API named in the header).
656// ---------------------------------------------------------------------------
657
658/// Keystroke-time expansion silencer — the zle_tricky.c pattern (zsh
659/// sets `noerrs` around completion-time expansion probes, e.g.
660/// doexpandhist/get_comp_string). With `noerrs = 1`, zerr sets errflag
661/// but NEVER prints (utils.c:175-177), so a history candidate like
662/// `~nouser` can't spray "no such user or named directory" onto the
663/// terminal on every keypress; the saved errflag is restored on drop so
664/// a failed probe never poisons the editor session, while the delta is
665/// still readable for failure detection before drop.
666struct QuietErrs {
667    saved_noerrs: i32,
668    saved_errflag: i32,
669}
670impl QuietErrs {
671    fn new() -> Self {
672        let mut g = crate::ported::utils::noerrs_lock().lock().unwrap();
673        let s = Self {
674            saved_noerrs: *g,
675            saved_errflag: errflag.load(Ordering::Relaxed),
676        };
677        *g = 1;
678        s
679    }
680    fn failed(&self) -> bool {
681        errflag.load(Ordering::Relaxed) != self.saved_errflag
682    }
683}
684impl Drop for QuietErrs {
685    fn drop(&mut self) {
686        *crate::ported::utils::noerrs_lock().lock().unwrap() = self.saved_noerrs;
687        errflag.store(self.saved_errflag, Ordering::Relaxed);
688    }
689}
690
691/// fish:file_tester.rs:116/152 — `expand_one(&mut s, ExpandFlags::FAIL_ON_CMDSUBST, ctx, None)`.
692/// zsh analog: `singsub` (subst.rs:1460, zsh Src/subst.c:514 — single-word prefork), after
693/// refusing any token that would *execute code* during highlighting: Tick/Qtick (`` ` ``),
694/// Inpar (`$(…)`/`(…)`) and Inparmath (`$((…))`, which can run `x++` side effects).
695/// Errors are silenced AND errflag restored via QuietErrs.
696pub fn expand_one_no_cmdsubst(param: &mut String) -> bool {
697    if param
698        .chars()
699        .any(|c| c == Tick || c == Qtick || c == Inpar || c == Inparmath)
700    {
701        return false; // FAIL_ON_CMDSUBST
702    }
703    let quiet = QuietErrs::new();
704    let expanded = singsub(param);
705    if quiet.failed() {
706        return false;
707    }
708    // singsub output can still carry token/quote-null chars; produce the plain string the
709    // fish call sites expect (fish's expand_one returns an unescaped string).
710    *param = untokenize(&expanded);
711    true
712}
713
714/// fish:expand.rs `expand_tilde` — zsh analog `filesubstr` (subst.rs:1921, zsh Src/subst.c
715/// filesub): expands a leading tokenized `~`-prefix. A plain leading '~' (already-clean
716/// caller input, e.g. tests) is promoted to the Tilde token first, matching what the lexer
717/// would have produced for an unquoted tilde.
718fn expand_tilde(input: &mut String) {
719    let mut s = input.clone();
720    if let Some(rest) = s.strip_prefix('~') {
721        s = format!("{Tilde}{rest}");
722    }
723    if s.starts_with(Tilde) {
724        // QuietErrs: `~nouser` reaches filesub's zerr ("no such user or
725        // named directory", zsh Src/subst.c:803) — silence + restore
726        // errflag; a miss just leaves the input unexpanded (fish
727        // expand_tilde semantics).
728        let _quiet = QuietErrs::new();
729        if let Some(expanded) = filesubstr(&s, false) {
730            *input = expanded;
731        }
732    }
733}
734
735/// fish:wutil `waccess` — access(2) on a path. Returns true when the mode is permitted.
736fn waccess(path: &str, mode: libc::c_int) -> bool {
737    let Ok(cpath) = CString::new(path) else {
738        return false;
739    };
740    unsafe { libc::access(cpath.as_ptr(), mode) == 0 }
741}
742
743/// fish:wutil `wstat` — stat(2), following symlinks.
744fn wstat(path: &str) -> std::io::Result<std::fs::Metadata> {
745    std::fs::metadata(path)
746}
747
748/// fish:wutil `wdirname` — POSIX dirname(3) semantics ("///" → "/", "a" → ".").
749fn wdirname(path: &str) -> String {
750    let trimmed = path.trim_end_matches('/');
751    if trimmed.is_empty() {
752        return "/".to_owned(); // all slashes (or empty → fish returns ".")
753    }
754    match trimmed.rfind('/') {
755        None => ".".to_owned(),
756        Some(0) => "/".to_owned(),
757        Some(idx) => trimmed[..idx].trim_end_matches('/').to_owned(),
758    }
759}
760
761/// fish:wutil `wbasename` — POSIX basename(3) semantics ("///" → "/", "a/b/" → "b").
762fn wbasename(path: &str) -> String {
763    if path.is_empty() {
764        return ".".to_owned();
765    }
766    let trimmed = path.trim_end_matches('/');
767    if trimmed.is_empty() {
768        return "/".to_owned(); // all slashes
769    }
770    match trimmed.rfind('/') {
771        None => trimmed.to_owned(),
772        Some(idx) => trimmed[idx + 1..].to_owned(),
773    }
774}
775
776/// fish:wutil/dir_iter.rs:78-80 — `DirEntry::is_dir` resolves the entry type (via d_type
777/// when available, stat otherwise). std spelling: file_type + follow symlinks via metadata.
778fn dir_entry_is_dir(entry: &std::fs::DirEntry) -> bool {
779    match entry.file_type() {
780        Ok(ft) if ft.is_dir() => true,
781        Ok(ft) if ft.is_symlink() => std::fs::metadata(entry.path())
782            .map(|md| md.is_dir())
783            .unwrap_or(false),
784        _ => false,
785    }
786}
787
788/// fish:wcstringutil `string_prefixes_string_case_insensitive` — is `proposed_prefix` a
789/// case-insensitive prefix of `value`?
790fn string_prefixes_string_case_insensitive(proposed_prefix: &str, value: &str) -> bool {
791    let mut vc = value.chars();
792    for pc in proposed_prefix.chars() {
793        match vc.next() {
794            Some(c) if c.to_lowercase().eq(pc.to_lowercase()) => (),
795            _ => return false,
796        }
797    }
798    true
799}
800
801// ---------------------------------------------------------------------------
802// fish:439-889 — tests, ported. Filesystem fixtures use tempfile (dev-dep);
803// permission-denial assertions are skipped for euid 0 (root ignores mode bits,
804// e.g. CI containers).
805// ---------------------------------------------------------------------------
806#[cfg(test)]
807mod tests {
808    use super::{
809        is_potential_path, FileTester, IsErr, IsFile, OperationContext, PathFlags,
810        RedirectionMode,
811    };
812    use std::fs::{self, create_dir_all, File, Permissions};
813    use std::os::unix::fs::PermissionsExt as _;
814    use std::path::PathBuf;
815
816    fn is_root() -> bool {
817        unsafe { libc::geteuid() == 0 }
818    }
819
820    // fish:456-476 — `TempDirWithCtx`.
821    struct TempDirWithCtx {
822        tempdir: tempfile::TempDir,
823        ctx: OperationContext,
824    }
825
826    impl TempDirWithCtx {
827        fn new() -> TempDirWithCtx {
828            TempDirWithCtx {
829                tempdir: tempfile::tempdir().unwrap(),
830                ctx: OperationContext::empty(),
831            }
832        }
833
834        fn filepath(&self, name: &str) -> PathBuf {
835            self.tempdir.path().join(name)
836        }
837
838        fn file_tester(&self) -> FileTester<'_> {
839            FileTester::new(
840                self.tempdir.path().to_string_lossy().into_owned(),
841                &self.ctx,
842            )
843        }
844    }
845
846    // fish:478-525 — `test_ispath`.
847    #[test]
848    fn test_ispath() {
849        let temp = TempDirWithCtx::new();
850        let tester = temp.file_tester();
851
852        let file_path = temp.filepath("file.txt");
853        File::create(file_path).unwrap();
854
855        assert!(tester.test_path("file.txt", false));
856        assert!(tester.test_path("file.txt", true));
857        assert!(!tester.test_path("fi", false));
858        assert!(tester.test_path("fi", true));
859        assert!(!tester.test_path("file.txt-more", false));
860        assert!(!tester.test_path("file.txt-more", true));
861        assert!(!tester.test_path("ffiledfk.txt", false));
862        assert!(!tester.test_path("ffiledfk.txt", true));
863
864        // fish:510 — Directories are also files.
865        let dir_path = temp.filepath("somedir");
866        create_dir_all(dir_path).unwrap();
867
868        assert!(tester.test_path("somedir", false));
869        assert!(tester.test_path("somedir", true));
870        assert!(!tester.test_path("some", false));
871        assert!(tester.test_path("some", true));
872    }
873
874    // fish:527-555 — `test_iscdpath`.
875    #[test]
876    fn test_iscdpath() {
877        let temp = TempDirWithCtx::new();
878        let tester = temp.file_tester();
879
880        // fish:531-533 — Note cd (unlike file paths) should report IsErr for invalid cd
881        // paths, rather than IsFile(false).
882        let dir_path = temp.filepath("somedir");
883        create_dir_all(dir_path).unwrap();
884
885        assert_eq!(tester.test_cd_path("somedir", false), Ok(IsFile(true)));
886        assert_eq!(tester.test_cd_path("somedir", true), Ok(IsFile(true)));
887        assert_eq!(tester.test_cd_path("some", false), Err(IsErr));
888        assert_eq!(tester.test_cd_path("some", true), Ok(IsFile(true)));
889        assert_eq!(tester.test_cd_path("notdir", false), Err(IsErr));
890        assert_eq!(tester.test_cd_path("notdir", true), Err(IsErr));
891    }
892
893    // fish:557-743 — `test_redirections`.
894    #[test]
895    fn test_redirections() {
896        // fish:559 — Note we use is_ok and is_err since we don't care about the IsFile part.
897        let temp = TempDirWithCtx::new();
898        let tester = temp.file_tester();
899        let file_path = temp.filepath("file.txt");
900        File::create(&file_path).unwrap();
901
902        let dir_path = temp.filepath("somedir");
903        create_dir_all(&dir_path).unwrap();
904
905        // fish:568-570 — Normal redirection.
906        assert_eq!(
907            tester.test_redirection_target("file.txt", RedirectionMode::Input),
908            Ok(IsFile(true))
909        );
910
911        // fish:572-577 — Can't redirect from a missing file.
912        assert_eq!(
913            tester.test_redirection_target("notfile.txt", RedirectionMode::Input),
914            Err(IsErr)
915        );
916        assert_eq!(
917            tester.test_redirection_target("bogus_path/file.txt", RedirectionMode::Input),
918            Err(IsErr)
919        );
920
921        // fish:579-581 — Can't redirect from a directory.
922        assert_eq!(
923            tester.test_redirection_target("somedir", RedirectionMode::Input),
924            Err(IsErr)
925        );
926
927        // fish:583-590 — Can't redirect from an unreadable file.
928        if !is_root() {
929            fs::set_permissions(&file_path, Permissions::from_mode(0o200)).unwrap();
930            assert_eq!(
931                tester.test_redirection_target("file.txt", RedirectionMode::Input),
932                Err(IsErr)
933            );
934            fs::set_permissions(&file_path, Permissions::from_mode(0o600)).unwrap();
935        }
936
937        // fish:592-599 — try_input syntax highlighting reports an error even though the
938        // command will succeed.
939        assert_eq!(
940            tester.test_redirection_target("file.txt", RedirectionMode::TryInput),
941            Ok(IsFile(true))
942        );
943        assert_eq!(
944            tester.test_redirection_target("notfile.txt", RedirectionMode::TryInput),
945            Err(IsErr)
946        );
947        assert_eq!(
948            tester.test_redirection_target("bogus_path/file.txt", RedirectionMode::TryInput),
949            Err(IsErr)
950        );
951
952        // fish:601-604 — Overwrite an existing file.
953        assert_eq!(
954            tester.test_redirection_target("file.txt", RedirectionMode::Overwrite),
955            Ok(IsFile(true))
956        );
957
958        // fish:606-608 — Append to an existing file.
959        assert_eq!(
960            tester.test_redirection_target("file.txt", RedirectionMode::Append),
961            Ok(IsFile(true))
962        );
963
964        // fish:610-612 — Write to a missing file.
965        assert_eq!(
966            tester.test_redirection_target("newfile.txt", RedirectionMode::Overwrite),
967            Ok(IsFile(false))
968        );
969
970        // fish:614-616 — No-clobber write to existing file should fail.
971        assert_eq!(
972            tester.test_redirection_target("file.txt", RedirectionMode::NoClob),
973            Err(IsErr)
974        );
975
976        // fish:618-620 — No-clobber write to missing file should succeed.
977        assert_eq!(
978            tester.test_redirection_target("unique.txt", RedirectionMode::NoClob),
979            Ok(IsFile(false))
980        );
981
982        let write_modes = &[
983            RedirectionMode::Overwrite,
984            RedirectionMode::Append,
985            RedirectionMode::NoClob,
986        ];
987
988        // fish:628-636 — Can't write to a directory.
989        for mode in write_modes {
990            assert_eq!(
991                tester.test_redirection_target("somedir", *mode),
992                Err(IsErr),
993                "Should not be able to write to a directory with mode {:?}",
994                mode
995            );
996        }
997
998        if !is_root() {
999            // fish:638-648 — Can't write without write permissions.
1000            fs::set_permissions(&file_path, Permissions::from_mode(0o400)).unwrap(); // Read-only.
1001            for mode in write_modes {
1002                assert_eq!(
1003                    tester.test_redirection_target("file.txt", *mode),
1004                    Err(IsErr),
1005                    "Should not be able to write to a read-only file with mode {:?}",
1006                    mode
1007                );
1008            }
1009            fs::set_permissions(&file_path, Permissions::from_mode(0o600)).unwrap(); // Restore permissions.
1010
1011            // fish:650-664 — Writing into a directory without write permissions (loop
1012            // through all modes).
1013            fs::set_permissions(&dir_path, Permissions::from_mode(0o500)).unwrap(); // Read and execute, no write.
1014            for mode in write_modes {
1015                assert_eq!(
1016                    tester.test_redirection_target("somedir/newfile.txt", *mode),
1017                    Err(IsErr),
1018                    "Should not be able to create/write in a read-only directory with mode {:?}",
1019                    mode
1020                );
1021            }
1022            fs::set_permissions(&dir_path, Permissions::from_mode(0o700)).unwrap();
1023            // Restore permissions.
1024        }
1025
1026        // fish:666-704 — Test fd redirections.
1027        for good in ["-", "0", "1", "2", "3", "500", "000", "01", "07"] {
1028            assert_eq!(
1029                tester.test_redirection_target(good, RedirectionMode::Fd),
1030                Ok(IsFile(false)),
1031                "fd target {:?} should be valid",
1032                good
1033            );
1034        }
1035
1036        // fish:706-742 — Invalid fd redirections.
1037        for bad in [
1038            "0x2",
1039            "0x3F",
1040            "0F",
1041            "-1",
1042            "-0009",
1043            "--",
1044            "derp",
1045            "123boo",
1046            "18446744073709551616",
1047        ] {
1048            assert_eq!(
1049                tester.test_redirection_target(bad, RedirectionMode::Fd),
1050                Err(IsErr),
1051                "fd target {:?} should be invalid",
1052                bad
1053            );
1054        }
1055    }
1056
1057    // fish:745-888 — `test_is_potential_path`. Adapted to tempdir-rooted absolute wds
1058    // (fish uses a cwd-relative "test/" fixture tree).
1059    #[test]
1060    fn test_is_potential_path() {
1061        let temp = TempDirWithCtx::new();
1062        let root = temp.tempdir.path();
1063
1064        // fish:749-751 — Directories.
1065        create_dir_all(root.join("is_potential_path_test/alpha")).unwrap();
1066        create_dir_all(root.join("is_potential_path_test/beta")).unwrap();
1067
1068        // fish:753-755 — Files.
1069        fs::write(root.join("is_potential_path_test/aardvark"), []).unwrap();
1070        fs::write(root.join("is_potential_path_test/gamma"), []).unwrap();
1071
1072        let wd = root
1073            .join("is_potential_path_test")
1074            .to_string_lossy()
1075            .into_owned()
1076            + "/";
1077        let root_str = root.to_string_lossy().into_owned();
1078        let wds = [root_str.clone(), wd];
1079
1080        let ctx = OperationContext::empty();
1081
1082        let path_require_dir = PathFlags {
1083            require_dir: true,
1084            ..Default::default()
1085        };
1086
1087        assert!(is_potential_path("al", true, &wds[1..], &ctx, path_require_dir));
1088        assert!(is_potential_path("alpha/", true, &wds[1..], &ctx, path_require_dir));
1089        assert!(is_potential_path("aard", true, &wds[1..], &ctx, PathFlags::default()));
1090        assert!(!is_potential_path("aard", false, &wds[1..], &ctx, PathFlags::default()));
1091        assert!(!is_potential_path(
1092            "alp/",
1093            true,
1094            &wds[1..],
1095            &ctx,
1096            PathFlags {
1097                require_dir: true,
1098                for_cd: true,
1099                ..Default::default()
1100            }
1101        ));
1102
1103        assert!(!is_potential_path("balpha/", true, &wds[1..], &ctx, path_require_dir));
1104        assert!(!is_potential_path("aard", true, &wds[1..], &ctx, path_require_dir));
1105        assert!(!is_potential_path("aarde", true, &wds[1..], &ctx, path_require_dir));
1106        assert!(!is_potential_path("aarde", true, &wds[1..], &ctx, PathFlags::default()));
1107
1108        assert!(is_potential_path(
1109            "is_potential_path_test/aardvark",
1110            true,
1111            &wds[..1],
1112            &ctx,
1113            PathFlags::default()
1114        ));
1115        assert!(is_potential_path(
1116            "is_potential_path_test/al",
1117            true,
1118            &wds[..1],
1119            &ctx,
1120            path_require_dir
1121        ));
1122        assert!(is_potential_path(
1123            "is_potential_path_test/aardv",
1124            true,
1125            &wds[..1],
1126            &ctx,
1127            PathFlags::default()
1128        ));
1129
1130        assert!(!is_potential_path(
1131            "is_potential_path_test/aardvark",
1132            true,
1133            &wds[..1],
1134            &ctx,
1135            path_require_dir
1136        ));
1137        assert!(!is_potential_path(
1138            "is_potential_path_test/al/",
1139            true,
1140            &wds[..1],
1141            &ctx,
1142            PathFlags::default()
1143        ));
1144        assert!(!is_potential_path(
1145            "is_potential_path_test/ar",
1146            true,
1147            &wds[..1],
1148            &ctx,
1149            PathFlags::default()
1150        ));
1151        assert!(is_potential_path("/usr", true, &wds[..1], &ctx, path_require_dir));
1152    }
1153}