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(¶m) || "-h".starts_with(¶m) {
190 return FileTestResult::Ok(IsFile(false));
191 }
192 let valid_path = is_potential_cd_path(
193 ¶m,
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}