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