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