From 25b944e3e62cd7df4f814bfbcd57c1aab24c0b8a Mon Sep 17 00:00:00 2001 From: Cuichen Li Date: Tue, 6 May 2025 11:28:46 +0800 Subject: [PATCH 01/70] Revert "Work around $PATH issues under WSL (#10506)" This reverts commit 3374692b9113e85c690059ac3def5f4aa190294f. --- src/path.rs | 25 +------------------------ 1 file changed, 1 insertion(+), 24 deletions(-) diff --git a/src/path.rs b/src/path.rs index a08c1248b..f706d1560 100644 --- a/src/path.rs +++ b/src/path.rs @@ -2,7 +2,7 @@ //! for testing if a command with a given name can be found in the PATH, and various other //! path-related issues. -use crate::common::{is_windows_subsystem_for_linux as is_wsl, wcs2osstring, wcs2zstring, WSL}; +use crate::common::{wcs2osstring, wcs2zstring}; use crate::env::{EnvMode, EnvStack, Environment}; use crate::expand::{expand_tilde, HOME_DIRECTORY}; use crate::flog::{FLOG, FLOGF}; @@ -308,29 +308,6 @@ fn path_get_path_core>(cmd: &wstr, pathsv: &[S]) -> GetPathResult return GetPathResult::new(test_path(cmd).err(), cmd.to_owned()); } - // WSLv1/WSLv2 tack on the entire Windows PATH to the end of the PATH environment variable, and - // accessing these paths from WSL binaries is pathalogically slow. We also don't expect to find - // any "normal" nix binaries under these paths, so we can skip them unless we are executing bins - // with Windows-ish names. We try to keep paths manually added to $fish_user_paths by only - // chopping off entries after the last "normal" PATH entry. - let pathsv = if is_wsl(WSL::Any) && !cmd.contains('.') { - let win_path_count = pathsv - .iter() - .rev() - .take_while(|p| { - let p = p.as_ref(); - p.starts_with("/mnt/") - && p.chars() - .nth("/mnt/x".len()) - .map(|c| c == '/') - .unwrap_or(false) - }) - .count(); - &pathsv[..pathsv.len() - win_path_count] - } else { - pathsv - }; - let mut best = noent_res; for next_path in pathsv { let next_path: &wstr = next_path.as_ref(); From 057dd930b49f4f1008f65b7576ab104170bdb6bc Mon Sep 17 00:00:00 2001 From: Peter Ammon Date: Thu, 8 May 2025 18:42:57 -0700 Subject: [PATCH 02/70] Changelog fix for #11354 --- CHANGELOG.rst | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 7ff40cade..30319501a 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,3 +1,10 @@ +fish 4.0.3 (released ???) +==================================== + +This release of fish fixes a number of issues identified in fish 4.0.2: + +- fish now properly inherits $PATH under Windows WSL2 (:issue:`11354`). + fish 4.0.2 (released April 20, 2025) ==================================== From 2d8d377ddcdd3ebd9418ed6d6022eac012935f86 Mon Sep 17 00:00:00 2001 From: Daniel Rainer Date: Sat, 3 May 2025 01:43:53 +0200 Subject: [PATCH 03/70] Make printf unicode-aware Specifically, the width and precision format specifiers are interpreted as referring to the width of the grapheme clusters rather than the byte count of the string. Note that grapheme clusters can differ in width. If a precision is specified for a string, meaning its "maximum number of characters", we consider this to limit the width displayed. If there is a grapheme cluster whose width is greater than 1, it might not be possible to get precisely the desired width. In such cases, this last grapheme cluster is excluded from the output. Note that the definitions used here are not consistent with the `string length` builtin at the moment, but this has already been the case. (cherry picked from commit 09eae92888602422e06bb623b91684c9d83df7b1) --- Cargo.lock | 14 +++++++ printf/Cargo.toml | 2 + printf/src/lib.rs | 2 +- printf/src/printf_impl.rs | 83 +++++++++++++++++++++++++++++---------- printf/src/tests.rs | 15 ++++++- tests/checks/printf.fish | 18 +++++++++ 6 files changed, 112 insertions(+), 22 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 726882d94..924c17d2a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -139,6 +139,8 @@ name = "fish-printf" version = "0.2.1" dependencies = [ "libc", + "unicode-segmentation", + "unicode-width", "widestring", ] @@ -567,6 +569,18 @@ version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e91b56cd4cadaeb79bbf1a5645f6b4f8dc5bde8834ad5894a8db35fda9efa1fe" +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" + [[package]] name = "version_check" version = "0.9.5" diff --git a/printf/Cargo.toml b/printf/Cargo.toml index cf387ec72..8ac5003a5 100644 --- a/printf/Cargo.toml +++ b/printf/Cargo.toml @@ -9,3 +9,5 @@ license = "MIT" [dependencies] libc = "0.2.155" widestring = { version = "1.0.2", optional = true } +unicode-segmentation = "1.12.0" +unicode-width = "0.2.0" diff --git a/printf/src/lib.rs b/printf/src/lib.rs index 6e9b7b581..4c2539138 100644 --- a/printf/src/lib.rs +++ b/printf/src/lib.rs @@ -73,7 +73,7 @@ macro_rules! sprintf { /// - `args`: Iterator over the arguments to format. /// /// # Returns -/// A `Result` which is `Ok` containing the number of characters written on success, or an `Error`. +/// A `Result` which is `Ok` containing the width of the string written on success, or an `Error`. /// /// # Example /// diff --git a/printf/src/printf_impl.rs b/printf/src/printf_impl.rs index 8a09137ac..1f26e9264 100644 --- a/printf/src/printf_impl.rs +++ b/printf/src/printf_impl.rs @@ -5,6 +5,8 @@ use std::fmt::{self, Write}; use std::mem; use std::result::Result; +use unicode_segmentation::UnicodeSegmentation; +use unicode_width::UnicodeWidthStr; #[cfg(feature = "widestring")] use widestring::Utf32Str as wstr; @@ -382,7 +384,7 @@ pub fn sprintf_locale( } // Read field width. We do not support $. - let width = if s.at(0) == Some('*') { + let desired_width = if s.at(0) == Some('*') { let arg_width = args.next().ok_or(Error::MissingArg)?.as_sint()?; s.advance_by(1); if arg_width < 0 { @@ -397,7 +399,7 @@ pub fn sprintf_locale( }; // Optionally read precision. We do not support $. - let mut prec: Option = if s.at(0) == Some('.') && s.at(1) == Some('*') { + let mut desired_precision: Option = if s.at(0) == Some('.') && s.at(1) == Some('*') { // "A negative precision is treated as though it were missing." // Here we assume the precision is always signed. s.advance_by(2); @@ -410,7 +412,7 @@ pub fn sprintf_locale( None }; // Disallow precisions larger than i32::MAX, in keeping with C. - if prec.unwrap_or(0) > i32::MAX as usize { + if desired_precision.unwrap_or(0) > i32::MAX as usize { return Err(Error::Overflow); } @@ -429,7 +431,7 @@ pub fn sprintf_locale( // "If a precision is given with a numeric conversion (d, i, o, u, i, x, and X), // the 0 flag is ignored." p is included here. let spec_is_numeric = matches!(conv_spec, CS::d | CS::u | CS::o | CS::p | CS::x | CS::X); - if spec_is_numeric && prec.is_some() { + if spec_is_numeric && desired_precision.is_some() { flags.zero_pad = false; } @@ -443,13 +445,22 @@ pub fn sprintf_locale( CS::e | CS::f | CS::g | CS::a | CS::E | CS::F | CS::G | CS::A => { // Floating point types handle output on their own. let float = arg.as_float()?; - let len = format_float(f, float, width, prec, flags, locale, conv_spec, buf)?; + let len = format_float( + f, + float, + desired_width, + desired_precision, + flags, + locale, + conv_spec, + buf, + )?; out_len = out_len.checked_add(len).ok_or(Error::Overflow)?; continue 'main; } CS::p => { const PTR_HEX_DIGITS: usize = 2 * mem::size_of::<*const u8>(); - prec = prec.map(|p| p.max(PTR_HEX_DIGITS)); + desired_precision = desired_precision.map(|p| p.max(PTR_HEX_DIGITS)); let uint = arg.as_uint()?; if uint != 0 { prefix = "0x"; @@ -479,8 +490,8 @@ pub fn sprintf_locale( if uint != 0 { write!(buf, "{:o}", uint)?; } - if flags.alt_form && prec.unwrap_or(0) <= buf.len() + 1 { - prec = Some(buf.len() + 1); + if flags.alt_form && desired_precision.unwrap_or(0) <= buf.len() + 1 { + desired_precision = Some(buf.len() + 1); } buf } @@ -514,10 +525,38 @@ pub fn sprintf_locale( CS::s => { // also 'S' let s = arg.as_str(buf)?; - let p = prec.unwrap_or(s.len()).min(s.len()); - prec = Some(p); flags.zero_pad = false; - &s[..p] + match desired_precision { + Some(precision) => { + // from man printf(3) + // "the maximum number of characters to be printed from a string" + // We interpret this to mean the maximum width when printed, as defined by + // Unicode grapheme cluster width. + let mut byte_len = 0; + let mut width = 0; + let mut graphemes = s.graphemes(true); + // Iteratively add single grapheme clusters as long as the fit within the + // width limited by precision. + while width < precision { + match graphemes.next() { + Some(grapheme) => { + let grapheme_width = grapheme.width(); + if width + grapheme_width <= precision { + byte_len += grapheme.len(); + width += grapheme_width; + } else { + break; + } + } + None => break, + } + } + let p = precision.min(width); + desired_precision = Some(p); + &s[..byte_len] + } + None => s, + } } }; // Numeric output should be empty iff the value is 0. @@ -528,23 +567,26 @@ pub fn sprintf_locale( // Decide if we want to apply thousands grouping to the body, and compute its size. // Note we have already errored out if grouped is set and this is non-numeric. let wants_grouping = flags.grouped && locale.thousands_sep.is_some(); - let body_len = match wants_grouping { + let body_width = match wants_grouping { + // We assume that text representing numbers is ASCII, so len == width. true => body.len() + locale.separator_count(body.len()), - false => body.len(), + false => body.width(), }; // Resolve the precision. // In the case of a non-numeric conversion, update the precision to at least the // length of the string. - let prec = if !spec_is_numeric { - prec.unwrap_or(body_len) + let desired_precision = if !spec_is_numeric { + desired_precision.unwrap_or(body_width) } else { - prec.unwrap_or(1).max(body_len) + desired_precision.unwrap_or(1).max(body_width) }; - let prefix_len = prefix.len(); - let unpadded_width = prefix_len.checked_add(prec).ok_or(Error::Overflow)?; - let width = width.max(unpadded_width); + let prefix_width = prefix.width(); + let unpadded_width = prefix_width + .checked_add(desired_precision) + .ok_or(Error::Overflow)?; + let width = desired_width.max(unpadded_width); // Pad on the left with spaces to the desired width? if !flags.left_adj && !flags.zero_pad { @@ -560,7 +602,8 @@ pub fn sprintf_locale( } // Pad on the left to the given precision? - pad(f, '0', prec, body_len)?; + // TODO: why pad with 0 here? + pad(f, '0', desired_precision, body_width)?; // Output the actual value, perhaps with grouping. if wants_grouping { diff --git a/printf/src/tests.rs b/printf/src/tests.rs index e87e41b96..4724ae344 100644 --- a/printf/src/tests.rs +++ b/printf/src/tests.rs @@ -13,6 +13,7 @@ macro_rules! sprintf_check { $(,)? // optional trailing comma ) => { { + use unicode_width::UnicodeWidthStr; let mut target = String::new(); let mut args = [$($arg.to_arg()),*]; let len = $crate::printf_c_locale( @@ -20,7 +21,7 @@ macro_rules! sprintf_check { $fmt.as_ref() as &str, &mut args, ).expect("printf failed"); - assert!(len == target.len(), "Wrong length returned: {} vs {}", len, target.len()); + assert_eq!(len, target.width(), "Wrong length returned"); target } }; @@ -723,6 +724,18 @@ fn test_huge_precision_g() { sprintf_err!("%.2147483648g", f => Error::Overflow); } +#[test] +fn test_non_ascii() { + assert_fmt!("%3s", "ΓΆ" => " ΓΆ"); + assert_fmt!("%3s", "πŸ‡ΊπŸ‡³" => " πŸ‡ΊπŸ‡³"); + assert_fmt!("%.3s", "πŸ‡ΊπŸ‡³πŸ‡ΊπŸ‡³" => "πŸ‡ΊπŸ‡³"); + assert_fmt!("%.3s", "aπŸ‡ΊπŸ‡³" => "aπŸ‡ΊπŸ‡³"); + assert_fmt!("%.3s", "aaπŸ‡ΊπŸ‡³" => "aa"); + assert_fmt!("%3.3s", "aaπŸ‡ΊπŸ‡³" => " aa"); + assert_fmt!("%.1s", "π’ˆ™a" => "π’ˆ™"); + assert_fmt!("%3.3s", "πŸ‘¨β€πŸ‘¨β€πŸ‘§β€πŸ‘§" => " πŸ‘¨β€πŸ‘¨β€πŸ‘§β€πŸ‘§"); +} + #[test] fn test_errors() { use Error::*; diff --git a/tests/checks/printf.fish b/tests/checks/printf.fish index c905a4ebe..99c7a3fd0 100644 --- a/tests/checks/printf.fish +++ b/tests/checks/printf.fish @@ -154,3 +154,21 @@ printf '%b\n' '\0057foo\0057bar\0057' printf %18446744073709551616s # CHECKERR: Number out of range + +# Test non-ASCII behavior +printf '|%3s|\n' 'ΓΆ' +# CHECK: | ΓΆ| +printf '|%3s|\n' 'πŸ‡ΊπŸ‡³' +#CHECK: | πŸ‡ΊπŸ‡³| +printf '|%.3s|\n' 'πŸ‡ΊπŸ‡³πŸ‡ΊπŸ‡³' +#CHECK: |πŸ‡ΊπŸ‡³| +printf '|%.3s|\n' 'aπŸ‡ΊπŸ‡³' +#CHECK: |aπŸ‡ΊπŸ‡³| +printf '|%.3s|\n' 'aaπŸ‡ΊπŸ‡³' +#CHECK: |aa| +printf '|%3.3s|\n' 'aaπŸ‡ΊπŸ‡³' +#CHECK: | aa| +printf '|%.1s|\n' 'π’ˆ™a' +#CHECK: |π’ˆ™| +printf '|%3.3s|\n' 'πŸ‘¨β€πŸ‘¨β€πŸ‘§β€πŸ‘§' +#CHECK: | πŸ‘¨β€πŸ‘¨β€πŸ‘§β€πŸ‘§| From edb1b5f33316fa0d1834e1f314d96dc5f6b1fc5f Mon Sep 17 00:00:00 2001 From: Johannes Altmanninger Date: Sun, 11 May 2025 21:20:37 +0200 Subject: [PATCH 04/70] Share alt-{b,f} with Vi mode, to work around Terminal.app/Ghostty more Commit f4503af037c (Make alt-{b,f} move in directory history if commandline is empty, 2025-01-06) had the intentional side effect of making alt-{left,right} (move in directory history) work in Terminal.app and Ghostty without other, less reliable workarounds. That commit says "that [workaround] alone should not be the reason for this change."; maybe this was wrong. Extend the workaround to Vi mode. The intention here is to provide alt-{left,right} in Vi mode. This also adds alt-{b,f} which is odd but mostly harmless (?) because those don't do anything else in Vi mode. It might be confusing when studying "bind" output but that one already has almost 400 lines for Vi mode. Closes #11479 (cherry picked from commit 3081d0157bffed6ded47a8cc3b219b228f924a9f) --- share/functions/__fish_shared_key_bindings.fish | 3 +++ share/functions/fish_default_key_bindings.fish | 2 -- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/share/functions/__fish_shared_key_bindings.fish b/share/functions/__fish_shared_key_bindings.fish index aabf9fd72..419430124 100644 --- a/share/functions/__fish_shared_key_bindings.fish +++ b/share/functions/__fish_shared_key_bindings.fish @@ -59,6 +59,9 @@ function __fish_shared_key_bindings -d "Bindings shared between emacs and vi mod $legacy_bind --preset $argv \e\[1\;9C nextd-or-forward-word # iTerm2 < 3.5.12 $legacy_bind --preset $argv \e\[1\;9D prevd-or-backward-word # iTerm2 < 3.5.12 + bind --preset $argv alt-b prevd-or-backward-word + bind --preset $argv alt-f nextd-or-forward-word + bind --preset $argv alt-up history-token-search-backward bind --preset $argv alt-down history-token-search-forward $legacy_bind --preset $argv \e\[1\;9A history-token-search-backward # iTerm2 < 3.5.12 diff --git a/share/functions/fish_default_key_bindings.fish b/share/functions/fish_default_key_bindings.fish index 2271eb0a7..327c597c5 100644 --- a/share/functions/fish_default_key_bindings.fish +++ b/share/functions/fish_default_key_bindings.fish @@ -60,8 +60,6 @@ function fish_default_key_bindings -d "emacs-like key binds" bind --preset $argv alt-backspace backward-kill-word bind --preset $argv ctrl-backspace backward-kill-word bind --preset $argv ctrl-delete kill-word - bind --preset $argv alt-b prevd-or-backward-word - bind --preset $argv alt-f nextd-or-forward-word bind --preset $argv alt-\< beginning-of-buffer bind --preset $argv alt-\> end-of-buffer From db323348c7c552bfe56e3d8ee16042401b7f1fb8 Mon Sep 17 00:00:00 2001 From: Johannes Altmanninger Date: Thu, 24 Apr 2025 17:02:09 +0200 Subject: [PATCH 05/70] Set transient command line in custom completions (Rust port regression) Commit df3b0bd89fa (Fix commandline state for custom completions with variable overrides, 2022-01-26) made us push a transient command line for custom completions based on a tautological null-pointer check ("var_assignments"). Commit 77aeb6a2a88 (Port execution, 2023-10-08) turned the null pointer into a reference and replaced the check with "!ad.var_assignments.is_empty()". This broke scenarios that relied on the transient commandline. In particular the attached test cases rely on the transient commandline implicitly placing the cursor at the end, irrespective of the cursor in the actual commandline. I'm not sure if there is an easy way to identify these scenarios. Let's restore historical behavior by always pushing the transient command line. Fixes #11423 (cherry picked from commit 97641c7bf6d1094f3b3bcfafd7a07cd9f0f74e66) --- src/complete.rs | 11 ++++------- tests/pexpects/complete.py | 14 +++++++++++++- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/src/complete.rs b/src/complete.rs index 28852b369..5f984c123 100644 --- a/src/complete.rs +++ b/src/complete.rs @@ -1935,19 +1935,16 @@ fn complete_custom(&mut self, cmd: &wstr, cmdline: &wstr, ad: &mut CustomArgData // Perhaps set a transient commandline so that custom completions // builtin_commandline will refer to the wrapped command. But not if // we're doing autosuggestions. - let mut _remove_transient = None; - let wants_transient = - (ad.wrap_depth > 0 || !ad.var_assignments.is_empty()) && !is_autosuggest; - if wants_transient { + let _remove_transient = (!is_autosuggest).then(|| { let parser = self.ctx.parser(); parser .libdata_mut() .transient_commandlines .push(cmdline.to_owned()); - _remove_transient = Some(ScopeGuard::new((), move |_| { + ScopeGuard::new((), move |_| { parser.libdata_mut().transient_commandlines.pop(); - })); - } + }) + }); // Maybe apply variable assignments. let _restore_vars = self.apply_var_assignments(ad.var_assignments); diff --git a/tests/pexpects/complete.py b/tests/pexpects/complete.py index 843d4d192..ce432f40b 100644 --- a/tests/pexpects/complete.py +++ b/tests/pexpects/complete.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -from pexpect_helper import SpawnedProc +from pexpect_helper import SpawnedProc, control sp = SpawnedProc() send, sendline, sleep, expect_prompt, expect_re, expect_str = ( @@ -78,3 +78,15 @@ sendline("echo bar") expect_re("\n.*bar") sendline("echo fo\t") expect_re("foooo") + +# Custom completions that access the command line. +sendline("complete -e :; complete : -a '(echo (commandline -ct)-completed)'") +send(": abcd" + control("b") * 2 + "\t") +expect_str(": abcd-completed") +send(control("u")) +# Another one. +sendline("mkdir -p foo/bar; touch foo/bar/baz.fish") +send("source foo/b/baz.fish") +send(control("b") * 9 + "\t") +expect_str("source foo/bar/baz.fish") +send(control("u")) From 27504658ce614467b23f26561c8a973c6f9732ff Mon Sep 17 00:00:00 2001 From: Johannes Altmanninger Date: Fri, 2 May 2025 06:38:20 +0200 Subject: [PATCH 06/70] Remove code clone in parse_util_locate_cmdsub (cherry picked from commit bd178c8ba8db70c42637b4c6bbcaccf9100eaeeb) --- src/parse_util.rs | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/src/parse_util.rs b/src/parse_util.rs index c9a4f5239..32d734356 100644 --- a/src/parse_util.rs +++ b/src/parse_util.rs @@ -228,7 +228,9 @@ fn process_opening_quote( quote: char, ) -> Option { let q_end = quote_end(input.into(), pos, quote)?; + // Found a valid closing quote. if input[q_end] == '$' { + // The closing quote is another quoted command substitution. *last_dollar = Some(q_end); quoted_cmdsubs.push(paran_count); } @@ -307,21 +309,19 @@ fn process_opening_quote( if quoted_cmdsubs.last() == Some(¶n_count) { quoted_cmdsubs.pop(); // Quoted command substitutions temporarily close double quotes. - // In "foo$(bar)baz$(qux)" - // We are here ^ - // After the ) in a quoted command substitution, we need to act as if - // there was an invisible double quote. - match quote_end(input.into(), pos, '"') { - Some(q_end) => { - // Found a valid closing quote. - // Stop at $(qux), which is another quoted command substitution. - if input[q_end] == '$' { - quoted_cmdsubs.push(paran_count); - } - pos = q_end; - } + // In "foo$(bar)baz$(qux)", after the ), we need to act as if there was a double quote. + match process_opening_quote( + input, + &mut inout_is_quoted, + paran_count, + &mut quoted_cmdsubs, + pos, + &mut last_dollar, + '"', + ) { + Some(q_end) => pos = q_end, None => break, - }; + } } } is_token_begin = is_token_delimiter(c, input.get(pos + 1).copied()); From 35849c57dca7c0ba8e6debfa9a520001f5f14560 Mon Sep 17 00:00:00 2001 From: Johannes Altmanninger Date: Fri, 2 May 2025 06:38:20 +0200 Subject: [PATCH 07/70] Explicit type for "$()" hack in parse_util_locate_cmdsub (cherry picked from commit 8abab0e2cc5dbdc9e100ba3ee43b7de91895e988) --- src/parse_util.rs | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/parse_util.rs b/src/parse_util.rs index 32d734356..552f3224c 100644 --- a/src/parse_util.rs +++ b/src/parse_util.rs @@ -218,6 +218,10 @@ fn parse_util_locate_cmdsub( let mut last_dollar = None; let mut paran_begin = None; let mut paran_end = None; + enum Quote { + Real(char), + VirtualDouble, + } fn process_opening_quote( input: &[char], inout_is_quoted: &mut Option<&mut bool>, @@ -225,8 +229,12 @@ fn process_opening_quote( quoted_cmdsubs: &mut Vec, pos: usize, last_dollar: &mut Option, - quote: char, + quote: Quote, ) -> Option { + let quote = match quote { + Quote::Real(q) => q, + Quote::VirtualDouble => '"', + }; let q_end = quote_end(input.into(), pos, quote)?; // Found a valid closing quote. if input[q_end] == '$' { @@ -256,7 +264,7 @@ fn process_opening_quote( &mut quoted_cmdsubs, pos, &mut last_dollar, - '"', + Quote::VirtualDouble, ) .unwrap_or(input.len()); } @@ -272,7 +280,7 @@ fn process_opening_quote( &mut quoted_cmdsubs, pos, &mut last_dollar, - c, + Quote::Real(c), ) { Some(q_end) => pos = q_end, None => break, @@ -317,7 +325,7 @@ fn process_opening_quote( &mut quoted_cmdsubs, pos, &mut last_dollar, - '"', + Quote::VirtualDouble, ) { Some(q_end) => pos = q_end, None => break, From 6fb22a4fd16a7290a0d00ac479b7e1dd136c50e5 Mon Sep 17 00:00:00 2001 From: Johannes Altmanninger Date: Fri, 2 May 2025 06:38:20 +0200 Subject: [PATCH 08/70] Fix regression causing crash indenting commandline with "$()" Commit b00899179f1 (Don't indent multi-line quoted strings; do indent inside (), 2024-04-28) changed how we compute indents for string tokens with command substitutions: echo "begin not indented end $( begin indented end)"( begin indented end ) For the leading quoted part of the string, we compute indentation only for the first character (the opening quote), see 4c43819d329 (Fix crash indenting quoted suffix after command substitution, 2024-09-28). The command substitutions, we do indent as usual. To implement the above, we need to separate quoted from non-quoted parts. This logic crashes when indent_string_part() is wrongly passed is_double_quoted=true. This is because, given the string "$()"$(), parse_util_locate_cmdsub calls quote_end() at index 4 (the second quote). This is wrong because that function should only be called at opening quotes; this is a closing quote. The opening quote is virtual here. Hack around this. Fixes #11444 (cherry picked from commit 48704dc6129632dc4fa8f38c13f2786ccb68ba9f) --- src/parse_util.rs | 9 ++++++--- src/tests/parse_util.rs | 5 +++++ 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/parse_util.rs b/src/parse_util.rs index 552f3224c..e10839b95 100644 --- a/src/parse_util.rs +++ b/src/parse_util.rs @@ -227,13 +227,16 @@ fn process_opening_quote( inout_is_quoted: &mut Option<&mut bool>, paran_count: i32, quoted_cmdsubs: &mut Vec, - pos: usize, + mut pos: usize, last_dollar: &mut Option, quote: Quote, ) -> Option { let quote = match quote { Quote::Real(q) => q, - Quote::VirtualDouble => '"', + Quote::VirtualDouble => { + pos = pos.saturating_sub(1); + '"' + } }; let q_end = quote_end(input.into(), pos, quote)?; // Found a valid closing quote. @@ -266,7 +269,7 @@ fn process_opening_quote( &mut last_dollar, Quote::VirtualDouble, ) - .unwrap_or(input.len()); + .map_or(input.len(), |pos| pos + 1); } while pos < input.len() { diff --git a/src/tests/parse_util.rs b/src/tests/parse_util.rs index a1d67ad1a..86e64d138 100644 --- a/src/tests/parse_util.rs +++ b/src/tests/parse_util.rs @@ -439,5 +439,10 @@ macro_rules! validate { 0, r#"echo "$()"'"#, 0, "\n" ); + validate!( + 0, r#"""#, + 0, "\n", + 0, r#"$()"$() ""# + ); })(); } From b8cfd6d12b9f2b6708372ace7e355832e7114335 Mon Sep 17 00:00:00 2001 From: Johannes Altmanninger Date: Mon, 12 May 2025 18:28:56 +0200 Subject: [PATCH 09/70] Fix typo causing wrong cursor position after Vi mode paste Regressed in d51f669647f (Vi mode: avoid placing cursor beyond last character, 2024-02-14). (cherry picked from commit 50500ec5b9bd667e89ac627e9fa6c6e37d3132b6) --- share/functions/fish_vi_key_bindings.fish | 2 +- tests/checks/tmux-vi-key-bindings.fish | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) create mode 100644 tests/checks/tmux-vi-key-bindings.fish diff --git a/share/functions/fish_vi_key_bindings.fish b/share/functions/fish_vi_key_bindings.fish index 56ab5baa3..014b2087f 100644 --- a/share/functions/fish_vi_key_bindings.fish +++ b/share/functions/fish_vi_key_bindings.fish @@ -247,7 +247,7 @@ function fish_vi_key_bindings --description 'vi-like key bindings for fish' # in vim p means paste *after* current character, so go forward a char before pasting # also in vim, P means paste *at* current position (like at '|' with cursor = line), # \ so there's no need to go back a char, just paste it without moving - bind -s --preset p 'set -g fish_cursor_end_mode exclusive' forward-char 'set -g fish_cursor_end_modefish_cursor_end_modeinclusive' yank + bind -s --preset p 'set -g fish_cursor_end_mode exclusive' forward-char 'set -g fish_cursor_end_mode inclusive' yank bind -s --preset P yank bind -s --preset g,p yank-pop diff --git a/tests/checks/tmux-vi-key-bindings.fish b/tests/checks/tmux-vi-key-bindings.fish new file mode 100644 index 000000000..9eb5b16ee --- /dev/null +++ b/tests/checks/tmux-vi-key-bindings.fish @@ -0,0 +1,14 @@ +#RUN: %fish %s +#REQUIRES: command -v tmux + +set -g isolated_tmux_fish_extra_args -C ' + set -g fish_key_bindings fish_vi_key_bindings +' +isolated-tmux-start + +isolated-tmux send-keys 'echo 124' Escape +tmux-sleep # disambiguate escape from alt +isolated-tmux send-keys v b y p i 3 +tmux-sleep +isolated-tmux capture-pane -p +# CHECK: [I] prompt 0> echo 1241234 From d4b4d44f14bee888dedf4cfd2855a129018fdbef Mon Sep 17 00:00:00 2001 From: Johannes Altmanninger Date: Mon, 12 May 2025 22:50:30 +0200 Subject: [PATCH 10/70] Fix Vi mode glitch when replacing at last character MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Another regression from d51f669647f (Vi mode: avoid placing cursor beyond last character, 2024-02-14) "Unfortunately Vi mode sometimes needs to temporarily select past end". So do the replace_one mode bindings which were forgotten. Fix this. This surfaces a tricky problem: when we use something like bind '' self-insert some-command When key event "x" matches this generic binding, we insert both "self-insert" and "some-command" at the front of the queue, and do *not* consume "x", since the binding is empty. Since there is a command (that might call "exit"), we insert a check-exit event too, after "self-insert some-command" but _before_ "x". The check-exit event makes "self-insert" do nothing. I don't think there's a good reason for this; self-insert can only be triggered by a key event that maps to self-insert; so there must always be a real key available for it to consume. AΒ "commandline -f self-insert" is a nop. Skip check-exit here. Fixes #11484 (cherry picked from commit 107e4d11de721797a17f780e29185a3ec2887cec) --- share/functions/fish_vi_key_bindings.fish | 8 ++++---- src/input.rs | 2 +- tests/checks/tmux-vi-key-bindings.fish | 7 +++++++ 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/share/functions/fish_vi_key_bindings.fish b/share/functions/fish_vi_key_bindings.fish index 014b2087f..d1ec29835 100644 --- a/share/functions/fish_vi_key_bindings.fish +++ b/share/functions/fish_vi_key_bindings.fish @@ -261,10 +261,10 @@ function fish_vi_key_bindings --description 'vi-like key bindings for fish' # Lowercase r, enters replace_one mode # bind -s --preset -m replace_one r repaint-mode - bind -s --preset -M replace_one -m default '' delete-char self-insert backward-char repaint-mode - bind -s --preset -M replace_one -m default enter 'commandline -f delete-char; commandline -i \n; commandline -f backward-char; commandline -f repaint-mode' - bind -s --preset -M replace_one -m default ctrl-j 'commandline -f delete-char; commandline -i \n; commandline -f backward-char; commandline -f repaint-mode' - bind -s --preset -M replace_one -m default ctrl-m 'commandline -f delete-char; commandline -i \n; commandline -f backward-char; commandline -f repaint-mode' + bind -s --preset -M replace_one -m default '' 'set -g fish_cursor_end_mode exclusive' delete-char self-insert backward-char repaint-mode 'set -g fish_cursor_end_mode inclusive' + bind -s --preset -M replace_one -m default enter 'set -g fish_cursor_end_mode exclusive' 'commandline -f delete-char; commandline -i \n; commandline -f backward-char' repaint-mode 'set -g fish_cursor_end_mode inclusive' + bind -s --preset -M replace_one -m default ctrl-j 'set -g fish_cursor_end_mode exclusive' 'commandline -f delete-char; commandline -i \n; commandline -f backward-char' repaint-mode 'set -g fish_cursor_end_mode inclusive' + bind -s --preset -M replace_one -m default ctrl-m 'set -g fish_cursor_end_mode exclusive' 'commandline -f delete-char; commandline -i \n; commandline -f backward-char' repaint-mode 'set -g fish_cursor_end_mode inclusive' bind -s --preset -M replace_one -m default escape cancel repaint-mode bind -s --preset -M replace_one -m default ctrl-\[ cancel repaint-mode diff --git a/src/input.rs b/src/input.rs index 537074677..ec7b9a91a 100644 --- a/src/input.rs +++ b/src/input.rs @@ -841,7 +841,7 @@ fn read_characters_no_readline(&mut self) -> CharEvent { let evt_to_return: CharEvent; loop { let evt = self.readch(); - if evt.is_readline_or_command() { + if evt.is_readline_or_command() || evt.is_check_exit() { saved_events.push(evt); } else { evt_to_return = evt; diff --git a/tests/checks/tmux-vi-key-bindings.fish b/tests/checks/tmux-vi-key-bindings.fish index 9eb5b16ee..0e1e059c5 100644 --- a/tests/checks/tmux-vi-key-bindings.fish +++ b/tests/checks/tmux-vi-key-bindings.fish @@ -12,3 +12,10 @@ isolated-tmux send-keys v b y p i 3 tmux-sleep isolated-tmux capture-pane -p # CHECK: [I] prompt 0> echo 1241234 + +isolated-tmux send-keys Escape +tmux-sleep # disambiguate escape from alt +isolated-tmux send-keys e r 5 +tmux-sleep +isolated-tmux capture-pane -p +# CHECK: [N] prompt 0> echo 1241235 From d5b46d6535e521b778433be4894214119bfcbd38 Mon Sep 17 00:00:00 2001 From: Alan Somers Date: Mon, 12 May 2025 13:18:21 -0600 Subject: [PATCH 11/70] Fix remote filesystem detection on FreeBSD Need an extra include to get the definition of MNT_LOCAL Fixes #11483 (cherry picked from commit 7f4998ad9b618f14f51fbf5d9d1acadc9767fb26) --- src/libc.c | 1 + 1 file changed, 1 insertion(+) diff --git a/src/libc.c b/src/libc.c index 93183e5a1..04482d58a 100644 --- a/src/libc.c +++ b/src/libc.c @@ -4,6 +4,7 @@ #include #include #include +#include #include #include From 7228cb15bfacfc88f97a1733a8e55b0ae78ec53c Mon Sep 17 00:00:00 2001 From: Johannes Altmanninger Date: Fri, 16 May 2025 07:12:56 +0200 Subject: [PATCH 12/70] Include sys/statvfs.h for the definition of ST_LOCAL (Rust port regression) See https://man.netbsd.org/statvfs.5. According to https://github.com/NetBSD/src/blob/trunk/sys/sys/statvfs.h#L135, NetBSD has "#define ST_LOCAL MNT_LOCAL". So this commit likely makes no difference on existing systems. While at it - comment include statements - remove a code clone See #11486 (cherry picked from commit d68f8bdd3b60a4a4cee0c938b5b3e2ff23c1ed86) --- src/libc.c | 11 +++++------ src/path.rs | 24 +++++------------------- 2 files changed, 10 insertions(+), 25 deletions(-) diff --git a/src/libc.c b/src/libc.c index 04482d58a..621c8c847 100644 --- a/src/libc.c +++ b/src/libc.c @@ -1,14 +1,13 @@ #include -#include +#include // _PATH_BSHELL #include #include #include -#include -#include +#include // MB_CUR_MAX +#include // MNT_LOCAL #include -#include - -#define UNUSED(x) (void)(x) +#include // ST_LOCAL +#include // _CS_PATH, _PC_CASE_SENSITIVE size_t C_MB_CUR_MAX() { return MB_CUR_MAX; } diff --git a/src/path.rs b/src/path.rs index f706d1560..f4b80cbfd 100644 --- a/src/path.rs +++ b/src/path.rs @@ -709,24 +709,10 @@ fn path_remoteness(path: &wstr) -> DirRemoteness { } #[cfg(not(target_os = "linux"))] { - let st_local = ST_LOCAL(); - if st_local != 0 { - // ST_LOCAL is a flag to statvfs, which is itself standardized. - // In practice the only system to use this path is NetBSD. - let mut buf: libc::statvfs = unsafe { std::mem::zeroed() }; - if unsafe { libc::statvfs(narrow.as_ptr(), &mut buf) } < 0 { - return DirRemoteness::unknown; - } - // statvfs::f_flag is `unsigned long`, which is 4-bytes on most 32-bit targets. - #[cfg_attr(target_pointer_width = "64", allow(clippy::useless_conversion))] - return if u64::from(buf.f_flag) & st_local != 0 { - DirRemoteness::local - } else { - DirRemoteness::remote - }; - } - let mnt_local = MNT_LOCAL(); - if mnt_local != 0 { + // ST_LOCAL is a flag to statvfs, which is itself standardized. + // In practice the only system to define it is NetBSD. + let local_flag = ST_LOCAL() | MNT_LOCAL(); + if local_flag != 0 { let mut buf: libc::statvfs = unsafe { std::mem::zeroed() }; if unsafe { libc::statvfs(narrow.as_ptr(), &mut buf) } < 0 { return DirRemoteness::unknown; @@ -734,7 +720,7 @@ fn path_remoteness(path: &wstr) -> DirRemoteness { // statfs::f_flag is hard-coded as 64-bits on 32/64-bit FreeBSD but it's a (4-byte) // long on 32-bit NetBSD.. and always 4-bytes on macOS (even on 64-bit builds). #[allow(clippy::useless_conversion)] - return if u64::from(buf.f_flag) & mnt_local != 0 { + return if u64::from(buf.f_flag) & local_flag != 0 { DirRemoteness::local } else { DirRemoteness::remote From c4a26cb2b191c56b97a04bfba46f0c95f8e58f9a Mon Sep 17 00:00:00 2001 From: Johannes Altmanninger Date: Tue, 20 May 2025 12:27:57 +0200 Subject: [PATCH 13/70] builtin status: remove spurious newline from current-command (Rust port regression) WHen "status current-command" is called outside a function it always returns "fish". An extra newline crept in, fix that. Fixes 77aeb6a2a88 (Port execution, 2023-10-08). Fixes #11503 (cherry picked from commit e26b585ce5d5bc0b49f50eec4eec7764a6bfe78f) --- src/builtins/status.rs | 3 +-- tests/checks/status.fish | 3 +++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/builtins/status.rs b/src/builtins/status.rs index 7446fc54a..24234bd8b 100644 --- a/src/builtins/status.rs +++ b/src/builtins/status.rs @@ -582,11 +582,10 @@ pub fn status(parser: &Parser, streams: &mut IoStreams, args: &mut [&wstr]) -> O STATUS_CURRENT_CMD => { let command = &parser.libdata().status_vars.command; if !command.is_empty() { - streams.out.append(command); + streams.out.appendln(command); } else { streams.out.appendln(*PROGRAM_NAME.get().unwrap()); } - streams.out.append_char('\n'); } STATUS_CURRENT_COMMANDLINE => { let commandline = &parser.libdata().status_vars.commandline; diff --git a/tests/checks/status.fish b/tests/checks/status.fish index d861611fc..7626d437e 100644 --- a/tests/checks/status.fish +++ b/tests/checks/status.fish @@ -38,6 +38,9 @@ status --job-control=1none # Now set it to a valid mode. status job-control none +status current-command | sed s/^/^/ +# CHECK: ^fish + # Check status -u outside functions status current-function #CHECK: Not a function From 0f8d3a517466001b7719fd09872e85312d2ad6fd Mon Sep 17 00:00:00 2001 From: Johannes Altmanninger Date: Tue, 20 May 2025 15:09:12 +0200 Subject: [PATCH 14/70] Revert "Temporarily enable history_file debug category by default" Commit f906a949cf1 (Temporarily enable history_file debug category by default, 2024-10-09) enabled the "history_file" debug category by default to gather more data. Judging from https://github.com/fish-shell/fish-shell/issues/10300#issuecomment-2876718382 the logs didn't help, or were at least not visible when logging to stderr (due to reboot). Let's disable "history_file" logs again to remove potential noise if the file system is read-only, disk is full etc., see https://github.com/fish-shell/fish-shell/pull/11492#discussion_r2094781120 See #10300 (cherry picked from commit 285a810814d0a82faceb996cac5fd9edfe95f3b8) --- src/flog.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/flog.rs b/src/flog.rs index 94dcc7c8c..d223ee964 100644 --- a/src/flog.rs +++ b/src/flog.rs @@ -120,7 +120,7 @@ pub fn all_categories() -> Vec<&'static category_t> { (char_encoding, "char-encoding", "Character encoding issues"); (history, "history", "Command history events"); - (history_file, "history-file", "Reading/Writing the history file", true); + (history_file, "history-file", "Reading/Writing the history file"); (profile_history, "profile-history", "History performance measurements"); From 5ccd15517787ba273623cc1c1d7333f758ad2ee9 Mon Sep 17 00:00:00 2001 From: Johannes Altmanninger Date: Tue, 20 May 2025 15:21:00 +0200 Subject: [PATCH 15/70] Retry history file flock() on EINTR When locking the uvar file, we retry whenever flock() fails with EINTR (e.g. due to ctrl-c). But not when locking the history file. This seems wrong; all other libc functions in the "history_file" code path do retry. Fix that. In future we should extract a function. Note that there are other inconsistencies; flock_uvar_file() does not shy away from remote file systems and does not respect ABANDONED_LOCKING. This means that empirically probably neither are necessary; let's make things consistent in future. See https://github.com/fish-shell/fish-shell/pull/11492#discussion_r2095096200 Might help #10300 (cherry picked from commit 4d84e68dd4f8d4c15a36b8d91c6562b8e93ecdee) --- src/history.rs | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/history.rs b/src/history.rs index 565c83f53..1c6e9747a 100644 --- a/src/history.rs +++ b/src/history.rs @@ -44,7 +44,7 @@ }; use bitflags::bitflags; -use libc::{fchmod, fchown, flock, LOCK_EX, LOCK_SH, LOCK_UN}; +use libc::{fchmod, fchown, flock, EINTR, LOCK_EX, LOCK_SH, LOCK_UN}; use lru::LruCache; use nix::{fcntl::OFlag, sys::stat::Mode}; use rand::Rng; @@ -1353,8 +1353,15 @@ unsafe fn maybe_lock_file(file: &mut File, lock_type: libc::c_int) -> bool { return false; } - let start_time = SystemTime::now(); - let retval = unsafe { flock(raw_fd, lock_type) }; + let (ok, start_time) = loop { + let start_time = SystemTime::now(); + if unsafe { flock(raw_fd, lock_type) } == -1 { + if errno::errno().0 != EINTR { + break (false, start_time); + } + } + break (true, start_time); + }; if let Ok(duration) = start_time.elapsed() { if duration > Duration::from_millis(250) { FLOG!( @@ -1367,7 +1374,7 @@ unsafe fn maybe_lock_file(file: &mut File, lock_type: libc::c_int) -> bool { ABANDONED_LOCKING.store(true); } } - retval != -1 + ok } /// Unlock a history file. From 33f84157854cc2a532e531fa79332d60c409ed41 Mon Sep 17 00:00:00 2001 From: Johannes Altmanninger Date: Tue, 20 May 2025 17:16:09 +0200 Subject: [PATCH 16/70] Fixup history file EINTR loop to actually loop Fixes d84e68dd4f (Retry history file flock() on EINTR, 2025-05-20). (cherry picked from commit 386716319344a0a1e1f6b0ff82af057e4f20cd36) --- src/history.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/history.rs b/src/history.rs index 1c6e9747a..040d20240 100644 --- a/src/history.rs +++ b/src/history.rs @@ -1355,12 +1355,12 @@ unsafe fn maybe_lock_file(file: &mut File, lock_type: libc::c_int) -> bool { let (ok, start_time) = loop { let start_time = SystemTime::now(); - if unsafe { flock(raw_fd, lock_type) } == -1 { - if errno::errno().0 != EINTR { - break (false, start_time); - } + if unsafe { flock(raw_fd, lock_type) } != -1 { + break (true, start_time); + } + if errno::errno().0 != EINTR { + break (false, start_time); } - break (true, start_time); }; if let Ok(duration) = start_time.elapsed() { if duration > Duration::from_millis(250) { From b11e22d9052c71acc77fac3e52cfacec1aeafe34 Mon Sep 17 00:00:00 2001 From: Johannes Altmanninger Date: Thu, 22 May 2025 14:09:16 +0200 Subject: [PATCH 17/70] Fix uvar file mtime force-update (Rust port regression) When two fish processes rewrite the uvar file concurrent, they rely on the uvar file's mtime (queried after taking a lock, if locking is supported) to tell us whether their view of the uvar file is still up-to-date. If it is, they proceed to move it into place atomically via rename(). Since the observable mtime only updates on every OS clock tick, we call futimens() manually to force-update that, to make sure that -- unless both fish conincide on the same *nanosecond* -- other fish will notice that the file changed. Unfortunately, commit 77aeb6a2a88 (Port execution, 2023-10-08) accidentally made us call futimens() only if clock_gettime() failed, instead of when it succeeded. This means that we need to wait for the next clock tick to observe a change in mtime. Any resulting false negatives might have caused us to drop universal variable updates. Reported in https://github.com/fish-shell/fish-shell/pull/11492#discussion_r2098948362 See #10300 (cherry picked from commit 8617964d4dbf03d752fbf094600ef977a36a83ec) --- src/env_universal_common.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/env_universal_common.rs b/src/env_universal_common.rs index 8ee9d8466..3e3fd88cc 100644 --- a/src/env_universal_common.rs +++ b/src/env_universal_common.rs @@ -801,7 +801,7 @@ fn save(&mut self, directory: &wstr) -> bool { { let mut times: [libc::timespec; 2] = unsafe { std::mem::zeroed() }; times[0].tv_nsec = libc::UTIME_OMIT; // don't change ctime - if unsafe { libc::clock_gettime(libc::CLOCK_REALTIME, &mut times[1]) } != 0 { + if unsafe { libc::clock_gettime(libc::CLOCK_REALTIME, &mut times[1]) } == 0 { unsafe { libc::futimens(private_fd.as_raw_fd(), ×[0]); } From 028b60cad6c60b34dcaa46c1acc18f45bc6ca4b7 Mon Sep 17 00:00:00 2001 From: Johannes Altmanninger Date: Thu, 29 May 2025 14:20:11 +0200 Subject: [PATCH 18/70] Fix "set fish_complete_path" accidentally disabling autoloading Commit 5918bca1eba (Make "complete -e" prevent completion autoloading, 2024-08-24) makes "complete -e foo" add a tombstone for "foo", meaning we will never again load completions for "foo". Due to an oversight, the same tombstone is added when we clear cached completions after changing "fish_complete_path", preventing completions from being loaded in that case. Fix this by restoring the old behavior unless the user actually used "complete -e". (cherry picked from commit a7c04890c947ff39600d5938ffc3c4019bdb7e6c) --- src/builtins/complete.rs | 2 +- src/complete.rs | 6 +++--- tests/checks/autoload.fish | 21 +++++++++++++++++++++ 3 files changed, 25 insertions(+), 4 deletions(-) create mode 100644 tests/checks/autoload.fish diff --git a/src/builtins/complete.rs b/src/builtins/complete.rs index e744570f4..1c4c238b9 100644 --- a/src/builtins/complete.rs +++ b/src/builtins/complete.rs @@ -176,7 +176,7 @@ fn builtin_complete_remove_cmd( if !removed { // This means that all loops were empty. - complete_remove_all(cmd.to_owned(), cmd_is_path); + complete_remove_all(cmd.to_owned(), cmd_is_path, /*explicit=*/ true); } } diff --git a/src/complete.rs b/src/complete.rs index 5f984c123..947085caa 100644 --- a/src/complete.rs +++ b/src/complete.rs @@ -2339,14 +2339,14 @@ pub fn complete_remove(cmd: WString, cmd_is_path: bool, option: &wstr, typ: Comp } /// Removes all completions for a given command. -pub fn complete_remove_all(cmd: WString, cmd_is_path: bool) { +pub fn complete_remove_all(cmd: WString, cmd_is_path: bool, explicit: bool) { let mut completion_map = COMPLETION_MAP.lock().expect("mutex poisoned"); let idx = CompletionEntryIndex { name: cmd, is_path: cmd_is_path, }; let removed = completion_map.remove(&idx).is_some(); - if !removed && !idx.is_path { + if explicit && !removed && !idx.is_path { COMPLETION_TOMBSTONES.lock().unwrap().insert(idx.name); } } @@ -2519,7 +2519,7 @@ pub fn complete_invalidate_path() { .expect("mutex poisoned") .get_autoloaded_commands(); for cmd in cmds { - complete_remove_all(cmd, false /* not a path */); + complete_remove_all(cmd, /*cmd_is_path=*/ false, /*explicit=*/ false); } } diff --git a/tests/checks/autoload.fish b/tests/checks/autoload.fish new file mode 100644 index 000000000..35a8f62fd --- /dev/null +++ b/tests/checks/autoload.fish @@ -0,0 +1,21 @@ +#RUN: %fish %s + +set -g fish_complete_path c1 c2 +mkdir c1 c2 + +function foo; end +for i in c1 c2 + echo >$i/foo.fish "echo auto-loading $i/foo.fish" +end +complete -C "foo " >/dev/null +# CHECK: auto-loading c1/foo.fish +complete -C "foo " >/dev/null +# already loaded + +set -g fish_complete_path c2 +complete -C "foo " >/dev/null +# CHECK: auto-loading c2/foo.fish + +set -g fish_complete_path c1 c2 +complete -C "foo " >/dev/null +# CHECK: auto-loading c1/foo.fish From f7e639504a94571010db1ad389ee37eabef062e7 Mon Sep 17 00:00:00 2001 From: Johannes Altmanninger Date: Wed, 28 May 2025 13:31:24 +0200 Subject: [PATCH 19/70] completions/git: improve idempotency in case of double load As mentioned in the previous few commits and in #11535, running "set fish_complete_path ..." and "complete -C 'git ...'" may result in "share/completions/git.fish" being loaded multiple times. This is usually fine because fish internally erases all cached completions whenever fish_complete_path changes. Unfortunately there is at least global variable that grows each time git.fish is sourced. This doesn't make a functional difference but it does slow down completions. Fix that by resetting the variable at load time. (cherry picked from commit 4b5650ee4fa778ae88fcfddbe680df29cd91783f) --- share/completions/git.fish | 1 + 1 file changed, 1 insertion(+) diff --git a/share/completions/git.fish b/share/completions/git.fish index 033516279..be512f750 100644 --- a/share/completions/git.fish +++ b/share/completions/git.fish @@ -653,6 +653,7 @@ function __fish_git_aliased_command end end +set -g __fish_git_aliases git config -z --get-regexp 'alias\..*' | while read -lz alias cmdline set -l command (__fish_git_aliased_command $cmdline) string match -q --regex '\w+' -- $command; or continue From a014166795dc29600e4f1c3e8c79a2cdde6d2ba0 Mon Sep 17 00:00:00 2001 From: Fabian Boehm Date: Mon, 27 Jan 2025 20:45:30 +0100 Subject: [PATCH 20/70] completions/nmcli: Complete at runtime This used to get all the interfaces and ssids when the completions were loaded. That's obviously wrong, given that ssids especially can, you know, change (cherry picked from commit 9116c6173686558ba873f1667e4abd325c394b04) cherry-picking since this easy to trigger (seen again in https://github.com/fish-shell/fish-shell/pull/11549) --- share/completions/nmcli.fish | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/share/completions/nmcli.fish b/share/completions/nmcli.fish index 947313b17..996683544 100644 --- a/share/completions/nmcli.fish +++ b/share/completions/nmcli.fish @@ -1,10 +1,9 @@ -set -l nmoutput (nmcli -g NAME connection show --active 2>/dev/null) -or exit # networkmanager isn't running, no point in completing -set -l cname (string escape -- $nmoutput\t"Active connection") -set -a cname (string escape -- (nmcli -g NAME connection show)\t"Connection") -set -l ifname (string escape -- (nmcli -g DEVICE device status)\t"Interface name") -set -l ssid (string escape -- (nmcli -g SSID device wifi list)\t"SSID") -set -l bssid (string escape -- (nmcli -g BSSID device wifi list | string replace --all \\ '')\t"BSSID") +set -l nmoutput '(nmcli -g NAME connection show --active 2>/dev/null)' +set -l cname "$nmoutput"\t"Active connection" +set -a cname '(nmcli -g NAME connection show 2>/dev/null)\t"Connection"' +set -l ifname '(nmcli -g DEVICE device status 2>/dev/null)\t"Interface name"' +set -l ssid '(nmcli -g SSID device wifi list 2>/dev/null)\t"SSID"' +set -l bssid '(nmcli -g BSSID device wifi list 2>/dev/null | string replace --all \\\ "")\t"BSSID"' set -l nmcli_commands general networking radio connection device agent monitor help set -l nmcli_general status hostname permissions logging help From f787e6858c52fbf4c422f99410b7398360462b67 Mon Sep 17 00:00:00 2001 From: Johannes Altmanninger Date: Mon, 9 Jun 2025 22:36:01 +0200 Subject: [PATCH 21/70] Fix tests/checks/autoload.fish Apparently this test runs with "build/tests" as CWD when run with cmake. --- tests/checks/autoload.fish | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/checks/autoload.fish b/tests/checks/autoload.fish index 35a8f62fd..f6be71774 100644 --- a/tests/checks/autoload.fish +++ b/tests/checks/autoload.fish @@ -1,7 +1,7 @@ #RUN: %fish %s set -g fish_complete_path c1 c2 -mkdir c1 c2 +mkdir -p c1 c2 function foo; end for i in c1 c2 From 8222ed891b32a5909cce75aeb72bfc98c22d410a Mon Sep 17 00:00:00 2001 From: Johannes Altmanninger Date: Mon, 30 Dec 2024 07:47:54 +0100 Subject: [PATCH 22/70] Stop parsing invalid CSI/SS3 sequences as alt-[/alt-o This situation can be triggered in practice inside a terminal like tmux 3.5 by running tmux new-session fish -C 'sleep 2' -d reader -o log-file and typing "alt-escape x" The log shows that we drop treat this as alt-[ and drop the x on the floor. reader: Read char alt-\[ -- Key { modifiers: Modifiers { ctrl: false, alt: true, shift: false }, codepoint: '[' } -- [27, 91, 120] This input ("\e[x") is ambiguous. It looks like it could mean "alt-[,x". However that conflicts with a potential future CSI code, so it makes no sense to try to support this. Returning "None" from parse_csi() causes this weird behavior of returning "alt-[" and dropping the rest of the parsed sequence. This is too easy; it has even crept into a bunch of places where the input sequence is actually valid like "VT200 button released" but where we definitely don't want to report any key. Fix the default: report no key for all unknown sequences and intentionally-suppressed sequences. Treat it at "alt-[" only when there is no input byte available, which is more or less unambiguous, hence a strong enough signal that this is a actually "alt-[". (cherry picked from commit 3201cb9f012b6f6afab80200da0476fef6d8b7ee) --- src/input_common.rs | 34 ++++++++++++++++++---------------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/src/input_common.rs b/src/input_common.rs index ebe050135..704bfbb72 100644 --- a/src/input_common.rs +++ b/src/input_common.rs @@ -11,8 +11,8 @@ use crate::future_feature_flags::{feature_test, FeatureFlag}; use crate::global_safety::RelaxedAtomicBool; use crate::key::{ - self, alt, canonicalize_control_char, canonicalize_keyed_control_char, function_key, shift, - Key, Modifiers, + self, alt, canonicalize_control_char, canonicalize_keyed_control_char, ctrl, function_key, + shift, Key, Modifiers, }; use crate::reader::{reader_current_data, reader_test_and_clear_interrupted}; use crate::threads::{iothread_port, is_main_thread}; @@ -762,6 +762,7 @@ fn parse_escape_sequence( } return None; }; + let invalid = Key::from_raw(key::Invalid); if buffer.len() == 2 && next == b'\x1b' { return Some( match self.parse_escape_sequence(buffer, have_escape_prefix) { @@ -769,17 +770,17 @@ fn parse_escape_sequence( nested_sequence.modifiers.alt = true; nested_sequence } - None => Key::from_raw(key::Invalid), + None => invalid, }, ); } if next == b'[' { // potential CSI - return Some(self.parse_csi(buffer).unwrap_or(alt('['))); + return Some(self.parse_csi(buffer).unwrap_or(invalid)); } if next == b'O' { // potential SS3 - return Some(self.parse_ss3(buffer).unwrap_or(alt('O'))); + return Some(self.parse_ss3(buffer).unwrap_or(invalid)); } match canonicalize_control_char(next) { Some(mut key) => { @@ -794,10 +795,12 @@ fn parse_escape_sequence( } fn parse_csi(&mut self, buffer: &mut Vec) -> Option { - let mut next_char = |zelf: &mut Self| zelf.try_readb(buffer).unwrap_or(0xff); // The maximum number of CSI parameters is defined by NPAR, nominally 16. let mut params = [[0_u32; 4]; 16]; - let mut c = next_char(self); + let Some(mut c) = self.try_readb(buffer) else { + return Some(ctrl('[')); + }; + let mut next_char = |zelf: &mut Self| zelf.try_readb(buffer).unwrap_or(0xff); let private_mode; if matches!(c, b'?' | b'<' | b'=' | b'>') { // private mode @@ -943,11 +946,11 @@ fn parse_csi(&mut self, buffer: &mut Vec) -> Option { } // rxvt style 200 => { self.paste_start_buffering(); - return Some(Key::from_raw(key::Invalid)); + return None; } 201 => { self.paste_commit(); - return Some(Key::from_raw(key::Invalid)); + return None; } _ => return None, }, @@ -993,11 +996,11 @@ fn parse_csi(&mut self, buffer: &mut Vec) -> Option { b'Z' => shift(key::Tab), b'I' => { self.push_front(CharEvent::from_readline(ReadlineCmd::FocusIn)); - return Some(Key::from_raw(key::Invalid)); + return None; } b'O' => { self.push_front(CharEvent::from_readline(ReadlineCmd::FocusOut)); - return Some(Key::from_raw(key::Invalid)); + return None; } _ => return None, }; @@ -1020,13 +1023,12 @@ fn disable_mouse_tracking(&mut self) { fn parse_ss3(&mut self, buffer: &mut Vec) -> Option { let mut raw_mask = 0; - let mut code = b'0'; - loop { + let Some(mut code) = self.try_readb(buffer) else { + return Some(alt('O')); + }; + while (b'0'..=b'9').contains(&code) { raw_mask = raw_mask * 10 + u32::from(code - b'0'); code = self.try_readb(buffer).unwrap_or(0xff); - if !(b'0'..=b'9').contains(&code) { - break; - } } let modifiers = parse_mask(raw_mask.saturating_sub(1)); #[rustfmt::skip] From 3475531ef7e81f301ee0247cda2ee4c3c769e54c Mon Sep 17 00:00:00 2001 From: Johannes Altmanninger Date: Mon, 30 Dec 2024 08:12:32 +0100 Subject: [PATCH 23/70] Also ignore invalid recursive escape sequences We parse "\e\e[x" as alt-modified "Invalid" key. Due to this extra modifier, we accidentally add it to the input queue, instead of dropping this invalid key. We don't really want to try to extract some valid keys from this invalid sequence, see also the parent commit. This allows us to remove misplaced validation that was added by e8e91c97a6 (fish_key_reader: ignore sentinel key, 2024-04-02) but later obsoleted by 66c6e89f98 (Don't add collateral sentinel key to input queue, 2024-04-03). (cherry picked from commit 84f19a931d609e3ca6ecc2d53234427887f2f0f5) --- src/bin/fish_key_reader.rs | 5 +---- src/input_common.rs | 6 +++++- src/key.rs | 2 +- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/bin/fish_key_reader.rs b/src/bin/fish_key_reader.rs index b401621b1..7412b27fc 100644 --- a/src/bin/fish_key_reader.rs +++ b/src/bin/fish_key_reader.rs @@ -23,7 +23,7 @@ terminal_protocol_hacks, terminal_protocols_enable_ifn, CharEvent, InputEventQueue, InputEventQueuer, }, - key::{self, char_to_symbol, Key}, + key::{char_to_symbol, Key}, panic::panic_handler, print_help::print_help, printf, @@ -101,9 +101,6 @@ fn process_input(continuous_mode: bool, verbose: bool) -> i32 { continue; }; let c = kevt.key.codepoint; - if c == key::Invalid { - continue; - } if verbose { printf!("# decoded from: "); for byte in kevt.seq.chars() { diff --git a/src/input_common.rs b/src/input_common.rs index 704bfbb72..eaa253dfc 100644 --- a/src/input_common.rs +++ b/src/input_common.rs @@ -691,6 +691,7 @@ fn try_readch(&mut self, blocking: bool) -> Option { if key == Some(Key::from_raw(key::Invalid)) { continue; } + assert!(key.map_or(true, |key| key.codepoint != key::Invalid)); let mut consumed = 0; let mut state = zero_mbstate(); let mut i = 0; @@ -767,10 +768,13 @@ fn parse_escape_sequence( return Some( match self.parse_escape_sequence(buffer, have_escape_prefix) { Some(mut nested_sequence) => { + if nested_sequence == invalid { + return Some(Key::from_raw(key::Escape)); + } nested_sequence.modifiers.alt = true; nested_sequence } - None => invalid, + _ => invalid, }, ); } diff --git a/src/key.rs b/src/key.rs index 7b8346ff5..59a8ce56e 100644 --- a/src/key.rs +++ b/src/key.rs @@ -23,7 +23,7 @@ pub(crate) const Insert: char = '\u{F50c}'; pub(crate) const Tab: char = '\u{F50d}'; pub(crate) const Space: char = '\u{F50e}'; -pub const Invalid: char = '\u{F50f}'; +pub(crate) const Invalid: char = '\u{F50f}'; pub(crate) fn function_key(n: u32) -> char { assert!((1..=12).contains(&n)); char::from_u32(u32::from(Invalid) + n).unwrap() From b77fc28692e445422b5ba93ff141e4ff7ba1e006 Mon Sep 17 00:00:00 2001 From: Johannes Altmanninger Date: Wed, 1 Jan 2025 21:32:40 +0100 Subject: [PATCH 24/70] Add menu and printscreen keys These aren't typically used in the terminal but they are present on many keyboards. Also reorganize the named key constants a bit. Between F500 and ENCODE_DIRECT_BASE (F600) we have space for 256 named keys. (cherry picked from commit 109ef888313dbfac846850f6b046fa4053a92219) --- share/completions/bind.fish | 2 +- src/input_common.rs | 2 ++ src/key.rs | 18 +++++++++++------- 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/share/completions/bind.fish b/share/completions/bind.fish index 9f62e36d8..8699a1946 100644 --- a/share/completions/bind.fish +++ b/share/completions/bind.fish @@ -72,7 +72,7 @@ function __fish_bind_complete printf '%sshift-\tShift modifier…\n' $prefix set -l key_names minus comma backspace delete escape \ enter up down left right pageup pagedown home end insert tab \ - space f(seq 12) + space menu printscreen f(seq 12) printf '%s\tNamed key\n' $prefix$key_names end end diff --git a/src/input_common.rs b/src/input_common.rs index eaa253dfc..6a3f71f8f 100644 --- a/src/input_common.rs +++ b/src/input_common.rs @@ -961,6 +961,8 @@ fn parse_csi(&mut self, buffer: &mut Vec) -> Option { b'u' => { // Treat numpad keys the same as their non-numpad counterparts. Could add a numpad modifier here. let key = match params[0][0] { + 57361 => key::PrintScreen, + 57363 => key::Menu, 57399 => '0', 57400 => '1', 57401 => '2', diff --git a/src/key.rs b/src/key.rs index 59a8ce56e..b4d2b66b0 100644 --- a/src/key.rs +++ b/src/key.rs @@ -18,16 +18,18 @@ pub(crate) const Right: char = '\u{F507}'; pub(crate) const PageUp: char = '\u{F508}'; pub(crate) const PageDown: char = '\u{F509}'; -pub(crate) const Home: char = '\u{F50a}'; -pub(crate) const End: char = '\u{F50b}'; -pub(crate) const Insert: char = '\u{F50c}'; -pub(crate) const Tab: char = '\u{F50d}'; -pub(crate) const Space: char = '\u{F50e}'; -pub(crate) const Invalid: char = '\u{F50f}'; +pub(crate) const Home: char = '\u{F50A}'; +pub(crate) const End: char = '\u{F50B}'; +pub(crate) const Insert: char = '\u{F50C}'; +pub(crate) const Tab: char = '\u{F50D}'; +pub(crate) const Space: char = '\u{F50E}'; +pub(crate) const Menu: char = '\u{F50F}'; +pub(crate) const PrintScreen: char = '\u{F510}'; pub(crate) fn function_key(n: u32) -> char { assert!((1..=12).contains(&n)); - char::from_u32(u32::from(Invalid) + n).unwrap() + char::from_u32(u32::from('\u{F5FF}') - n).unwrap() } +pub(crate) const Invalid: char = '\u{F5FF}'; const KEY_NAMES: &[(char, &wstr)] = &[ ('-', L!("minus")), @@ -47,6 +49,8 @@ pub(crate) fn function_key(n: u32) -> char { (Insert, L!("insert")), (Tab, L!("tab")), (Space, L!("space")), + (Menu, L!("menu")), + (PrintScreen, L!("printscreen")), ]; #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] From 7cca98bda2dbb056e918f6bf3d5d2fa36b5d9c17 Mon Sep 17 00:00:00 2001 From: Johannes Altmanninger Date: Mon, 27 Jan 2025 21:46:35 +0100 Subject: [PATCH 25/70] Fix regression decoding function keys Commit 109ef88831 (Add menu and printscreen keys, 2025-01-01) accidentally broke an assumption by inverting f1..f12. Fix that. Fixes #11098 (cherry picked from commit d2b2c5286aa71a387a62f43a6a8767848a425c41) --- src/key.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/key.rs b/src/key.rs index b4d2b66b0..ee77ea264 100644 --- a/src/key.rs +++ b/src/key.rs @@ -27,7 +27,7 @@ pub(crate) const PrintScreen: char = '\u{F510}'; pub(crate) fn function_key(n: u32) -> char { assert!((1..=12).contains(&n)); - char::from_u32(u32::from('\u{F5FF}') - n).unwrap() + char::from_u32(u32::from('\u{F5FF}') - 12 + (n - 1)).unwrap() } pub(crate) const Invalid: char = '\u{F5FF}'; From 02932d6b8c7faa4a1bfbbfb1cbe43dddc61e5d1b Mon Sep 17 00:00:00 2001 From: Johannes Altmanninger Date: Mon, 31 Mar 2025 20:30:09 +0200 Subject: [PATCH 26/70] Extract constant for the number of function keys Switch to fish_wcstoul because we want the constant to be unsigned. It's u32 because most callers of function_key() want that. (cherry picked from commit e9d1cdfe878004f5184db17e1ec329933a8e387c) --- src/key.rs | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/key.rs b/src/key.rs index ee77ea264..8aa4aa1b5 100644 --- a/src/key.rs +++ b/src/key.rs @@ -5,7 +5,7 @@ fallback::fish_wcwidth, reader::TERMINAL_MODE_ON_STARTUP, wchar::{decode_byte_from_char, prelude::*}, - wutil::{fish_is_pua, fish_wcstoi}, + wutil::{fish_is_pua, fish_wcstoul}, }; pub(crate) const Backspace: char = '\u{F500}'; // below ENCODE_DIRECT_BASE @@ -25,9 +25,10 @@ pub(crate) const Space: char = '\u{F50E}'; pub(crate) const Menu: char = '\u{F50F}'; pub(crate) const PrintScreen: char = '\u{F510}'; +pub(crate) const MAX_FUNCTION_KEY: u32 = 12; pub(crate) fn function_key(n: u32) -> char { - assert!((1..=12).contains(&n)); - char::from_u32(u32::from('\u{F5FF}') - 12 + (n - 1)).unwrap() + assert!((1..=MAX_FUNCTION_KEY).contains(&n)); + char::from_u32(u32::from('\u{F5FF}') - MAX_FUNCTION_KEY + (n - 1)).unwrap() } pub(crate) const Invalid: char = '\u{F5FF}'; @@ -299,11 +300,14 @@ pub(crate) fn parse_keys(value: &wstr) -> Result, WString> { })? } else if codepoint.is_none() && key_name.starts_with('f') && key_name.len() <= 3 { let num = key_name.strip_prefix('f').unwrap(); - let codepoint = match fish_wcstoi(num) { - Ok(n) if (1..=12).contains(&n) => function_key(u32::try_from(n).unwrap()), + let codepoint = match fish_wcstoul(num) { + Ok(n) if (1..=u64::from(MAX_FUNCTION_KEY)).contains(&n) => { + function_key(u32::try_from(n).unwrap()) + } _ => { return Err(wgettext_fmt!( - "only f1 through f12 are supported, not 'f%s'", + "only f1 through f%d are supported, not 'f%s'", + MAX_FUNCTION_KEY, num, )); } @@ -394,7 +398,7 @@ fn from(key: Key) -> Self { .iter() .find_map(|&(codepoint, name)| (codepoint == key.codepoint).then(|| name.to_owned())) .or_else(|| { - (function_key(1)..=function_key(12)) + (function_key(1)..=function_key(MAX_FUNCTION_KEY)) .contains(&key.codepoint) .then(|| { sprintf!( From a09c78491f3792399dc756316c46c16f4fd1c91b Mon Sep 17 00:00:00 2001 From: Johannes Altmanninger Date: Sun, 30 Mar 2025 17:06:23 +0200 Subject: [PATCH 27/70] Extract function for creating key event with modifiers (cherry picked from commit fabbbba037265de1abcb4e8893df77b4dcb7f1e9) --- src/input_common.rs | 60 ++++++++++++++++++++++----------------------- src/key.rs | 37 +++++++++------------------- 2 files changed, 41 insertions(+), 56 deletions(-) diff --git a/src/input_common.rs b/src/input_common.rs index 6a3f71f8f..fa5db0e34 100644 --- a/src/input_common.rs +++ b/src/input_common.rs @@ -1039,36 +1039,36 @@ fn parse_ss3(&mut self, buffer: &mut Vec) -> Option { let modifiers = parse_mask(raw_mask.saturating_sub(1)); #[rustfmt::skip] let key = match code { - b' ' => Key{modifiers, codepoint: key::Space}, - b'A' => Key{modifiers, codepoint: key::Up}, - b'B' => Key{modifiers, codepoint: key::Down}, - b'C' => Key{modifiers, codepoint: key::Right}, - b'D' => Key{modifiers, codepoint: key::Left}, - b'F' => Key{modifiers, codepoint: key::End}, - b'H' => Key{modifiers, codepoint: key::Home}, - b'I' => Key{modifiers, codepoint: key::Tab}, - b'M' => Key{modifiers, codepoint: key::Enter}, - b'P' => Key{modifiers, codepoint: function_key(1)}, - b'Q' => Key{modifiers, codepoint: function_key(2)}, - b'R' => Key{modifiers, codepoint: function_key(3)}, - b'S' => Key{modifiers, codepoint: function_key(4)}, - b'X' => Key{modifiers, codepoint: '='}, - b'j' => Key{modifiers, codepoint: '*'}, - b'k' => Key{modifiers, codepoint: '+'}, - b'l' => Key{modifiers, codepoint: ','}, - b'm' => Key{modifiers, codepoint: '-'}, - b'n' => Key{modifiers, codepoint: '.'}, - b'o' => Key{modifiers, codepoint: '/'}, - b'p' => Key{modifiers, codepoint: '0'}, - b'q' => Key{modifiers, codepoint: '1'}, - b'r' => Key{modifiers, codepoint: '2'}, - b's' => Key{modifiers, codepoint: '3'}, - b't' => Key{modifiers, codepoint: '4'}, - b'u' => Key{modifiers, codepoint: '5'}, - b'v' => Key{modifiers, codepoint: '6'}, - b'w' => Key{modifiers, codepoint: '7'}, - b'x' => Key{modifiers, codepoint: '8'}, - b'y' => Key{modifiers, codepoint: '9'}, + b' ' => Key::new(modifiers, key::Space), + b'A' => Key::new(modifiers, key::Up), + b'B' => Key::new(modifiers, key::Down), + b'C' => Key::new(modifiers, key::Right), + b'D' => Key::new(modifiers, key::Left), + b'F' => Key::new(modifiers, key::End), + b'H' => Key::new(modifiers, key::Home), + b'I' => Key::new(modifiers, key::Tab), + b'M' => Key::new(modifiers, key::Enter), + b'P' => Key::new(modifiers, function_key(1)), + b'Q' => Key::new(modifiers, function_key(2)), + b'R' => Key::new(modifiers, function_key(3)), + b'S' => Key::new(modifiers, function_key(4)), + b'X' => Key::new(modifiers, '='), + b'j' => Key::new(modifiers, '*'), + b'k' => Key::new(modifiers, '+'), + b'l' => Key::new(modifiers, ','), + b'm' => Key::new(modifiers, '-'), + b'n' => Key::new(modifiers, '.'), + b'o' => Key::new(modifiers, '/'), + b'p' => Key::new(modifiers, '0'), + b'q' => Key::new(modifiers, '1'), + b'r' => Key::new(modifiers, '2'), + b's' => Key::new(modifiers, '3'), + b't' => Key::new(modifiers, '4'), + b'u' => Key::new(modifiers, '5'), + b'v' => Key::new(modifiers, '6'), + b'w' => Key::new(modifiers, '7'), + b'x' => Key::new(modifiers, '8'), + b'y' => Key::new(modifiers, '9'), _ => return None, }; Some(key) diff --git a/src/key.rs b/src/key.rs index 8aa4aa1b5..4de577c5a 100644 --- a/src/key.rs +++ b/src/key.rs @@ -91,39 +91,33 @@ pub struct Key { } impl Key { - pub(crate) fn from_raw(codepoint: char) -> Self { + pub(crate) const fn new(modifiers: Modifiers, codepoint: char) -> Self { Self { - modifiers: Modifiers::default(), + modifiers, codepoint, } } + pub(crate) fn from_raw(codepoint: char) -> Self { + Self::new(Modifiers::default(), codepoint) + } } pub(crate) const fn ctrl(codepoint: char) -> Key { let mut modifiers = Modifiers::new(); modifiers.ctrl = true; - Key { - modifiers, - codepoint, - } + Key::new(modifiers, codepoint) } pub(crate) const fn alt(codepoint: char) -> Key { let mut modifiers = Modifiers::new(); modifiers.alt = true; - Key { - modifiers, - codepoint, - } + Key::new(modifiers, codepoint) } pub(crate) const fn shift(codepoint: char) -> Key { let mut modifiers = Modifiers::new(); modifiers.shift = true; - Key { - modifiers, - codepoint, - } + Key::new(modifiers, codepoint) } impl Key { @@ -140,10 +134,7 @@ pub fn from_single_byte(c: u8) -> Self { pub fn canonicalize_control_char(c: u8) -> Option { let codepoint = canonicalize_keyed_control_char(char::from(c)); if u32::from(codepoint) > 255 { - return Some(Key { - modifiers: Modifiers::default(), - codepoint, - }); + return Some(Key::from_raw(codepoint)); } if c < 32 { @@ -294,10 +285,7 @@ pub(crate) fn parse_keys(value: &wstr) -> Result, WString> { .find_map(|(codepoint, name)| (name == key_name).then_some(*codepoint)) .or_else(|| (key_name.len() == 1).then(|| key_name.as_char_slice()[0])); let key = if let Some(codepoint) = codepoint { - canonicalize_key(Key { - modifiers, - codepoint, - })? + canonicalize_key(Key::new(modifiers, codepoint))? } else if codepoint.is_none() && key_name.starts_with('f') && key_name.len() <= 3 { let num = key_name.strip_prefix('f').unwrap(); let codepoint = match fish_wcstoul(num) { @@ -312,10 +300,7 @@ pub(crate) fn parse_keys(value: &wstr) -> Result, WString> { )); } }; - Key { - modifiers, - codepoint, - } + Key::new(modifiers, codepoint) } else { return Err(wgettext_fmt!( "cannot parse key '%s'", From 4f98ef36f68135c77b5b4666ba64085bff735e3c Mon Sep 17 00:00:00 2001 From: Johannes Altmanninger Date: Sun, 30 Mar 2025 08:33:24 +0200 Subject: [PATCH 28/70] Extract KeyEvent type The be used in the grandchild commit. (cherry picked from commit 855a1f702eeb751fa2504eba9a06409819fa4303) --- src/bin/fish_key_reader.rs | 15 ++- src/input.rs | 5 +- src/input_common.rs | 185 ++++++++++++++++++++++--------------- src/tests/input.rs | 8 +- 4 files changed, 130 insertions(+), 83 deletions(-) diff --git a/src/bin/fish_key_reader.rs b/src/bin/fish_key_reader.rs index 7412b27fc..d4b7e44f2 100644 --- a/src/bin/fish_key_reader.rs +++ b/src/bin/fish_key_reader.rs @@ -21,7 +21,7 @@ input::input_terminfo_get_name, input_common::{ terminal_protocol_hacks, terminal_protocols_enable_ifn, CharEvent, InputEventQueue, - InputEventQueuer, + InputEventQueuer, KeyEvent, }, key::{char_to_symbol, Key}, panic::panic_handler, @@ -37,15 +37,20 @@ }; /// Return true if the recent sequence of characters indicates the user wants to exit the program. -fn should_exit(recent_keys: &mut Vec, key: Key) -> bool { - recent_keys.push(key); +fn should_exit(recent_keys: &mut Vec, key_evt: KeyEvent) -> bool { + recent_keys.push(key_evt); for evt in [VINTR, VEOF] { let modes = shell_modes(); let cc = Key::from_single_byte(modes.c_cc[evt]); - if key == cc { - if recent_keys.iter().rev().nth(1) == Some(&cc) { + if key_evt == cc { + if recent_keys + .iter() + .rev() + .nth(1) + .is_some_and(|&prev| prev == cc) + { return true; } eprintf!( diff --git a/src/input.rs b/src/input.rs index ec7b9a91a..cb1d98b65 100644 --- a/src/input.rs +++ b/src/input.rs @@ -4,7 +4,8 @@ use crate::event; use crate::flog::FLOG; use crate::input_common::{ - CharEvent, CharInputStyle, InputData, InputEventQueuer, ReadlineCmd, R_END_INPUT_FUNCTIONS, + CharEvent, CharInputStyle, InputData, InputEventQueuer, KeyEvent, ReadlineCmd, + R_END_INPUT_FUNCTIONS, }; use crate::key::{self, canonicalize_raw_escapes, ctrl, Key, Modifiers}; use crate::proc::job_reap; @@ -429,7 +430,7 @@ fn select_interrupted(&mut self) { if reader_reading_interrupted(self) != 0 { let vintr = shell_modes().c_cc[libc::VINTR]; if vintr != 0 { - self.push_front(CharEvent::from_key(Key::from_single_byte(vintr))); + self.push_front(CharEvent::from_key(KeyEvent::from_single_byte(vintr))); } return; } diff --git a/src/input_common.rs b/src/input_common.rs index fa5db0e34..a6f63c639 100644 --- a/src/input_common.rs +++ b/src/input_common.rs @@ -139,11 +139,53 @@ pub enum ReadlineCmd { ReverseRepeatJump, } +#[derive(Clone, Copy, Debug)] +pub struct KeyEvent { + pub key: Key, +} + +impl KeyEvent { + pub(crate) fn new(modifiers: Modifiers, codepoint: char) -> Self { + Self::from(Key::new(modifiers, codepoint)) + } + pub(crate) fn from_raw(codepoint: char) -> Self { + Self::from(Key::from_raw(codepoint)) + } + pub fn from_single_byte(c: u8) -> Self { + Self::from(Key::from_single_byte(c)) + } +} + +impl From for KeyEvent { + fn from(key: Key) -> Self { + Self { key } + } +} + +impl std::ops::Deref for KeyEvent { + type Target = Key; + fn deref(&self) -> &Self::Target { + &self.key + } +} + +impl std::ops::DerefMut for KeyEvent { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.key + } +} + +impl PartialEq for KeyEvent { + fn eq(&self, key: &Key) -> bool { + &self.key == key + } +} + /// Represents an event on the character input stream. #[derive(Debug, Clone)] pub enum CharEventType { /// A character was entered. - Char(Key), + Char(KeyInputEvent), /// A readline event. Readline(ReadlineCmd), @@ -169,9 +211,9 @@ pub struct ReadlineCmdEvent { } #[derive(Debug, Clone)] -pub struct KeyEvent { +pub struct KeyInputEvent { // The key. - pub key: Key, + pub key: KeyEvent, // The style to use when inserting characters into the command line. pub input_style: CharInputStyle, /// The sequence of characters in the input mapping which generated this event. @@ -183,7 +225,7 @@ pub struct KeyEvent { #[derive(Debug, Clone)] pub enum CharEvent { /// A character was entered. - Key(KeyEvent), + Key(KeyInputEvent), /// A readline event. Readline(ReadlineCmdEvent), @@ -227,7 +269,7 @@ pub fn get_char(&self) -> char { kevt.key.codepoint } - pub fn get_key(&self) -> Option<&KeyEvent> { + pub fn get_key(&self) -> Option<&KeyInputEvent> { match self { CharEvent::Key(kevt) => Some(kevt), _ => None, @@ -249,15 +291,15 @@ pub fn get_command(&self) -> Option<&wstr> { } pub fn from_char(c: char) -> CharEvent { - Self::from_key(Key::from_raw(c)) + Self::from_key(KeyEvent::from_raw(c)) } - pub fn from_key(key: Key) -> CharEvent { + pub fn from_key(key: KeyEvent) -> CharEvent { Self::from_key_seq(key, WString::new()) } - pub fn from_key_seq(key: Key, seq: WString) -> CharEvent { - CharEvent::Key(KeyEvent { + pub fn from_key_seq(key: KeyEvent, seq: WString) -> CharEvent { + CharEvent::Key(KeyInputEvent { key, input_style: CharInputStyle::Normal, seq, @@ -678,7 +720,7 @@ fn try_readch(&mut self, blocking: bool) -> Option { let key_with_escape = if read_byte == 0x1b { self.parse_escape_sequence(&mut buffer, &mut have_escape_prefix) } else { - canonicalize_control_char(read_byte) + canonicalize_control_char(read_byte).map(KeyEvent::from) }; if self.paste_is_buffering() { if read_byte != 0x1b { @@ -688,7 +730,7 @@ fn try_readch(&mut self, blocking: bool) -> Option { } let mut seq = WString::new(); let mut key = key_with_escape; - if key == Some(Key::from_raw(key::Invalid)) { + if key.is_some_and(|key| key == Key::from_raw(key::Invalid)) { continue; } assert!(key.map_or(true, |key| key.codepoint != key::Invalid)); @@ -713,7 +755,7 @@ fn try_readch(&mut self, blocking: bool) -> Option { if have_escape_prefix && i != 0 { have_escape_prefix = false; let c = seq.as_char_slice().last().unwrap(); - key = Some(Key::from(alt(*c))); + key = Some(KeyEvent::from(alt(*c))); } if i + 1 == buffer.len() { break true; @@ -736,7 +778,7 @@ fn try_readch(&mut self, blocking: bool) -> Option { let Some(c) = seq.chars().next() else { continue; }; - Some(CharEvent::from_key_seq(Key::from_raw(c), seq)) + Some(CharEvent::from_key_seq(KeyEvent::from_raw(c), seq)) }; } ReadbResult::NothingToRead => return None, @@ -756,20 +798,20 @@ fn parse_escape_sequence( &mut self, buffer: &mut Vec, have_escape_prefix: &mut bool, - ) -> Option { + ) -> Option { let Some(next) = self.try_readb(buffer) else { if !self.paste_is_buffering() { - return Some(Key::from_raw(key::Escape)); + return Some(KeyEvent::from_raw(key::Escape)); } return None; }; - let invalid = Key::from_raw(key::Invalid); + let invalid = KeyEvent::from_raw(key::Invalid); if buffer.len() == 2 && next == b'\x1b' { return Some( match self.parse_escape_sequence(buffer, have_escape_prefix) { Some(mut nested_sequence) => { - if nested_sequence == invalid { - return Some(Key::from_raw(key::Escape)); + if nested_sequence == invalid.key { + return Some(KeyEvent::from_raw(key::Escape)); } nested_sequence.modifiers.alt = true; nested_sequence @@ -789,7 +831,7 @@ fn parse_escape_sequence( match canonicalize_control_char(next) { Some(mut key) => { key.modifiers.alt = true; - Some(key) + Some(KeyEvent::from(key)) } None => { *have_escape_prefix = true; @@ -798,11 +840,11 @@ fn parse_escape_sequence( } } - fn parse_csi(&mut self, buffer: &mut Vec) -> Option { + fn parse_csi(&mut self, buffer: &mut Vec) -> Option { // The maximum number of CSI parameters is defined by NPAR, nominally 16. let mut params = [[0_u32; 4]; 16]; let Some(mut c) = self.try_readb(buffer) else { - return Some(ctrl('[')); + return Some(KeyEvent::from(ctrl('['))); }; let mut next_char = |zelf: &mut Self| zelf.try_readb(buffer).unwrap_or(0xff); let private_mode; @@ -845,10 +887,7 @@ fn parse_csi(&mut self, buffer: &mut Vec) -> Option { codepoint = shifted_codepoint; } } - Key { - modifiers, - codepoint, - } + KeyEvent::new(modifiers, codepoint) }; let key = match c { @@ -858,9 +897,9 @@ fn parse_csi(&mut self, buffer: &mut Vec) -> Option { return None; } match params[0][0] { - 23 | 24 => shift( + 23 | 24 => KeyEvent::from(shift( char::from_u32(u32::from(function_key(11)) + params[0][0] - 23).unwrap(), // rxvt style - ), + )), _ => return None, } } @@ -931,23 +970,23 @@ fn parse_csi(&mut self, buffer: &mut Vec) -> Option { char::from_u32(u32::from(function_key(11)) + params[0][0] - 23).unwrap(), None, ), - 25 | 26 => { - shift(char::from_u32(u32::from(function_key(3)) + params[0][0] - 25).unwrap()) - } // rxvt style + 25 | 26 => KeyEvent::from(shift( + char::from_u32(u32::from(function_key(3)) + params[0][0] - 25).unwrap(), + )), // rxvt style 27 => { let key = canonicalize_keyed_control_char(char::from_u32(params[2][0]).unwrap()); masked_key(key, None) } - 28 | 29 => { - shift(char::from_u32(u32::from(function_key(5)) + params[0][0] - 28).unwrap()) - } // rxvt style - 31 | 32 => { - shift(char::from_u32(u32::from(function_key(7)) + params[0][0] - 31).unwrap()) - } // rxvt style - 33 | 34 => { - shift(char::from_u32(u32::from(function_key(9)) + params[0][0] - 33).unwrap()) - } // rxvt style + 28 | 29 => KeyEvent::from(shift( + char::from_u32(u32::from(function_key(5)) + params[0][0] - 28).unwrap(), + )), // rxvt style + 31 | 32 => KeyEvent::from(shift( + char::from_u32(u32::from(function_key(7)) + params[0][0] - 31).unwrap(), + )), // rxvt style + 33 | 34 => KeyEvent::from(shift( + char::from_u32(u32::from(function_key(9)) + params[0][0] - 33).unwrap(), + )), // rxvt style 200 => { self.paste_start_buffering(); return None; @@ -999,7 +1038,7 @@ fn parse_csi(&mut self, buffer: &mut Vec) -> Option { )), ) } - b'Z' => shift(key::Tab), + b'Z' => KeyEvent::from(shift(key::Tab)), b'I' => { self.push_front(CharEvent::from_readline(ReadlineCmd::FocusIn)); return None; @@ -1027,10 +1066,10 @@ fn disable_mouse_tracking(&mut self) { self.push_front(CharEvent::from_readline(ReadlineCmd::DisableMouseTracking)); } - fn parse_ss3(&mut self, buffer: &mut Vec) -> Option { + fn parse_ss3(&mut self, buffer: &mut Vec) -> Option { let mut raw_mask = 0; let Some(mut code) = self.try_readb(buffer) else { - return Some(alt('O')); + return Some(KeyEvent::from(alt('O'))); }; while (b'0'..=b'9').contains(&code) { raw_mask = raw_mask * 10 + u32::from(code - b'0'); @@ -1039,36 +1078,36 @@ fn parse_ss3(&mut self, buffer: &mut Vec) -> Option { let modifiers = parse_mask(raw_mask.saturating_sub(1)); #[rustfmt::skip] let key = match code { - b' ' => Key::new(modifiers, key::Space), - b'A' => Key::new(modifiers, key::Up), - b'B' => Key::new(modifiers, key::Down), - b'C' => Key::new(modifiers, key::Right), - b'D' => Key::new(modifiers, key::Left), - b'F' => Key::new(modifiers, key::End), - b'H' => Key::new(modifiers, key::Home), - b'I' => Key::new(modifiers, key::Tab), - b'M' => Key::new(modifiers, key::Enter), - b'P' => Key::new(modifiers, function_key(1)), - b'Q' => Key::new(modifiers, function_key(2)), - b'R' => Key::new(modifiers, function_key(3)), - b'S' => Key::new(modifiers, function_key(4)), - b'X' => Key::new(modifiers, '='), - b'j' => Key::new(modifiers, '*'), - b'k' => Key::new(modifiers, '+'), - b'l' => Key::new(modifiers, ','), - b'm' => Key::new(modifiers, '-'), - b'n' => Key::new(modifiers, '.'), - b'o' => Key::new(modifiers, '/'), - b'p' => Key::new(modifiers, '0'), - b'q' => Key::new(modifiers, '1'), - b'r' => Key::new(modifiers, '2'), - b's' => Key::new(modifiers, '3'), - b't' => Key::new(modifiers, '4'), - b'u' => Key::new(modifiers, '5'), - b'v' => Key::new(modifiers, '6'), - b'w' => Key::new(modifiers, '7'), - b'x' => Key::new(modifiers, '8'), - b'y' => Key::new(modifiers, '9'), + b' ' => KeyEvent::new(modifiers, key::Space), + b'A' => KeyEvent::new(modifiers, key::Up), + b'B' => KeyEvent::new(modifiers, key::Down), + b'C' => KeyEvent::new(modifiers, key::Right), + b'D' => KeyEvent::new(modifiers, key::Left), + b'F' => KeyEvent::new(modifiers, key::End), + b'H' => KeyEvent::new(modifiers, key::Home), + b'I' => KeyEvent::new(modifiers, key::Tab), + b'M' => KeyEvent::new(modifiers, key::Enter), + b'P' => KeyEvent::new(modifiers, function_key(1)), + b'Q' => KeyEvent::new(modifiers, function_key(2)), + b'R' => KeyEvent::new(modifiers, function_key(3)), + b'S' => KeyEvent::new(modifiers, function_key(4)), + b'X' => KeyEvent::new(modifiers, '='), + b'j' => KeyEvent::new(modifiers, '*'), + b'k' => KeyEvent::new(modifiers, '+'), + b'l' => KeyEvent::new(modifiers, ','), + b'm' => KeyEvent::new(modifiers, '-'), + b'n' => KeyEvent::new(modifiers, '.'), + b'o' => KeyEvent::new(modifiers, '/'), + b'p' => KeyEvent::new(modifiers, '0'), + b'q' => KeyEvent::new(modifiers, '1'), + b'r' => KeyEvent::new(modifiers, '2'), + b's' => KeyEvent::new(modifiers, '3'), + b't' => KeyEvent::new(modifiers, '4'), + b'u' => KeyEvent::new(modifiers, '5'), + b'v' => KeyEvent::new(modifiers, '6'), + b'w' => KeyEvent::new(modifiers, '7'), + b'x' => KeyEvent::new(modifiers, '8'), + b'y' => KeyEvent::new(modifiers, '9'), _ => return None, }; Some(key) @@ -1355,7 +1394,7 @@ fn select_interrupted(&mut self) { if reader_test_and_clear_interrupted() != 0 { let vintr = shell_modes().c_cc[libc::VINTR]; if vintr != 0 { - self.push_front(CharEvent::from_key(Key::from_single_byte(vintr))); + self.push_front(CharEvent::from_key(KeyEvent::from_single_byte(vintr))); } } } diff --git a/src/tests/input.rs b/src/tests/input.rs index a4caa4265..27860274c 100644 --- a/src/tests/input.rs +++ b/src/tests/input.rs @@ -1,6 +1,6 @@ use crate::env::EnvStack; use crate::input::{EventQueuePeeker, InputMappingSet, KeyNameStyle, DEFAULT_BIND_MODE}; -use crate::input_common::{CharEvent, InputData, InputEventQueuer}; +use crate::input_common::{CharEvent, InputData, InputEventQueuer, KeyEvent}; use crate::key::Key; use crate::wchar::prelude::*; use std::rc::Rc; @@ -52,8 +52,10 @@ fn test_input() { ); // Push the desired binding to the queue. - for c in desired_binding { - input.input_data.queue_char(CharEvent::from_key(c)); + for key in desired_binding { + input + .input_data + .queue_char(CharEvent::from_key(KeyEvent::from(key))); } let mut peeker = EventQueuePeeker::new(&mut input); From 08d796890a5c91172434610845ae450947cdb039 Mon Sep 17 00:00:00 2001 From: Johannes Altmanninger Date: Mon, 31 Mar 2025 14:19:05 +0200 Subject: [PATCH 29/70] Stop accepting "bind shift-A" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This notation doesn't make sense, use either A or shift-a. We accept it for ASCII letters only -- things like "bind shift-!" or "bind shift-Γ„" do not work as of today, we don't tolerate extra shift modifiers yet. So let's remove it for consistency. Note that the next commit will allow the shift-A notation again, but it will not match shift-a events. (cherry picked from commit 7f25d865a90c9f6fec0fa9d9e3b8c97b6c40c925) --- src/key.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/key.rs b/src/key.rs index 4de577c5a..3cd746373 100644 --- a/src/key.rs +++ b/src/key.rs @@ -204,7 +204,7 @@ pub(crate) fn canonicalize_key(mut key: Key) -> Result { } } if key.modifiers.shift { - if key.codepoint.is_ascii_alphabetic() { + if key.codepoint.is_ascii_lowercase() { // Shift + ASCII letters is just the uppercase letter. key.modifiers.shift = false; key.codepoint = key.codepoint.to_ascii_uppercase(); From 6b17ec7dae787d8b22f87b67155a2eadc693a1b8 Mon Sep 17 00:00:00 2001 From: Johannes Altmanninger Date: Sun, 30 Mar 2025 08:33:24 +0200 Subject: [PATCH 30/70] Allow explicit shift modifier for non-ASCII letters, fix capslock behavior MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We canonicalize "ctrl-shift-i" to "ctrl-I". Both when deciphering this notation (as given to builtin bind), and when receiving it as a key event ("\e[105;73;6u") This has problems: A. Our bind notation canonicalization only works for 26 English letters. For example, "ctrl-shift-Γ€" is not supported -- only "ctrl-Γ„" is. We could try to fix that but this depends on the keyboard layout. For example "bind alt-shift-=" and "bind alt-+" are equivalent on a "us" layout but not on a "de" layout. B. While capslock is on, the key event won't include a shifted key ("73" here). This is due a quirk in the kitty keyboard protocol[^1]. This means that fish_key_reader's canonicalization doesn't work (unless we call toupper() ourselves). I think we want to support both notations. It's recommended to match all of these (in this order) when pressing "ctrl-shift-i". 1. bind ctrl-shift-i do-something 2. bind ctrl-shift-I do-something 3. bind ctrl-I do-something 4. bind ctrl-i do-something Support 1 and 3 for now, allowing both bindings to coexist. No priorities for now. This solves problem A, and -- if we take care to use the explicit shift notation -- problem B. For keys that are not affected by capslock, problem B does not apply. In this case, recommend the shifted notation ("alt-+" instead of "alt-shift-=") since that seems more intuitive. Though if we prioritized "alt-shift-=" over "alt-+" as per the recommendation, that's an argument against the shifted key. Example output for some key events: $ fish_key_reader -cV # decoded from: \e\[61:43\;4u bind alt-+ 'do something' # recommended notation bind alt-shift-= 'do something' # decoded from: \e\[61:43\;68u bind alt-+ 'do something' # recommended notation bind alt-shift-= 'do something' # decoded from: \e\[105:73\;6u bind ctrl-I 'do something' bind ctrl-shift-i 'do something' # recommended notation # decoded from: \e\[105\;70u bind ctrl-shift-i 'do something' Due to the capslock quirk, the last one has only one matching representation since there is no shifted key. We could decide to match ctrl-shift-i events (that don't have a shifted key) to ctrl-I bindings (for ASCII letters), as before this patch. But that case is very rare, it should only happen when capslock is on, so it's probably not even a breaking change. The other way round is supported -- we do match ctrl-I events (typically with shifted key) to ctrl-shift-i bindings (but only for ASCII letters). This is mainly for backwards compatibility. Also note that, bindings without other modifiers currently need to use the shifted key (like "Γ„", not "shift-Γ€"), since we still get a legacy encoding, until we request "Report all keys as escape codes". [^1]: (cherry picked from commit 50a6e486a56865a7b9ff632ce3cfae500cd0c8ee) --- .../functions/fish_default_key_bindings.fish | 2 +- src/bin/fish_key_reader.rs | 30 +++- src/input_common.rs | 140 ++++++++++++++++-- src/key.rs | 24 ++- tests/checks/bind.fish | 15 ++ 5 files changed, 181 insertions(+), 30 deletions(-) diff --git a/share/functions/fish_default_key_bindings.fish b/share/functions/fish_default_key_bindings.fish index 327c597c5..e00e38be6 100644 --- a/share/functions/fish_default_key_bindings.fish +++ b/share/functions/fish_default_key_bindings.fish @@ -51,7 +51,7 @@ function fish_default_key_bindings -d "emacs-like key binds" bind --preset $argv ctrl-/ undo bind --preset $argv ctrl-_ undo # XTerm idiosyncracy, can get rid of this once we go full CSI u bind --preset $argv ctrl-z undo - bind --preset $argv ctrl-Z redo + bind --preset $argv ctrl-shift-z redo bind --preset $argv alt-/ redo bind --preset $argv alt-t transpose-words bind --preset $argv alt-u upcase-word diff --git a/src/bin/fish_key_reader.rs b/src/bin/fish_key_reader.rs index d4b7e44f2..405a7b33c 100644 --- a/src/bin/fish_key_reader.rs +++ b/src/bin/fish_key_reader.rs @@ -23,7 +23,7 @@ terminal_protocol_hacks, terminal_protocols_enable_ifn, CharEvent, InputEventQueue, InputEventQueuer, KeyEvent, }, - key::{char_to_symbol, Key}, + key::{char_to_symbol, Key, Modifiers}, panic::panic_handler, print_help::print_help, printf, @@ -113,7 +113,33 @@ fn process_input(continuous_mode: bool, verbose: bool) -> i32 { } printf!("\n"); } - printf!("bind %s 'do something'\n", kevt.key); + let print_bind_example = |key: &Key, recommended: bool| { + printf!( + "bind %s 'do something'%s\n", + key, + if recommended { + " # recommended notation" + } else { + "" + } + ); + }; + let have_shifted_key = kevt.key.shifted_codepoint != '\0'; + // If we have shift + some other modifier, the lowercase version is the canonical one. + let prefer_explicit_shift = kevt.key.modifiers.shift + && kevt.key.modifiers != Modifiers::SHIFT + && kevt + .key + .shifted_codepoint + .to_lowercase() + .eq(Some(kevt.key.codepoint).into_iter()); + if have_shifted_key { + let mut shifted_key = kevt.key.key; + shifted_key.modifiers.shift = false; + shifted_key.codepoint = kevt.key.shifted_codepoint; + print_bind_example(&shifted_key, !prefer_explicit_shift); + } + print_bind_example(&kevt.key, have_shifted_key && prefer_explicit_shift); if let Some(name) = sequence_name(&mut recent_chars1, c) { printf!("bind -k %ls 'do something'\n", name); } diff --git a/src/input_common.rs b/src/input_common.rs index a6f63c639..b44e3bdcb 100644 --- a/src/input_common.rs +++ b/src/input_common.rs @@ -142,12 +142,23 @@ pub enum ReadlineCmd { #[derive(Clone, Copy, Debug)] pub struct KeyEvent { pub key: Key, + pub shifted_codepoint: char, } impl KeyEvent { pub(crate) fn new(modifiers: Modifiers, codepoint: char) -> Self { Self::from(Key::new(modifiers, codepoint)) } + pub(crate) fn with_shifted_codepoint( + modifiers: Modifiers, + codepoint: char, + shifted_codepoint: Option, + ) -> Self { + Self { + key: Key::new(modifiers, codepoint), + shifted_codepoint: shifted_codepoint.unwrap_or_default(), + } + } pub(crate) fn from_raw(codepoint: char) -> Self { Self::from(Key::from_raw(codepoint)) } @@ -158,7 +169,10 @@ pub fn from_single_byte(c: u8) -> Self { impl From for KeyEvent { fn from(key: Key) -> Self { - Self { key } + Self { + key, + shifted_codepoint: '\0', + } } } @@ -175,12 +189,89 @@ fn deref_mut(&mut self) -> &mut Self::Target { } } +fn apply_shift(mut key: Key, do_ascii: bool, shifted_codepoint: char) -> Option { + if !key.modifiers.shift { + return Some(key); + } + if shifted_codepoint != '\0' { + key.codepoint = shifted_codepoint; + } else if do_ascii && key.codepoint.is_ascii_lowercase() { + // For backwards compatibility, we convert the "bind shift-a" notation to "bind A". + // This enables us to match "A" events which are the legacy encoding for keys that + // generate text -- until we request kitty's "Report all keys as escape codes". + // We do not currently convert non-ASCII key notation such as "bind shift-Γ€". + key.codepoint = key.codepoint.to_ascii_uppercase(); + } else { + return None; + }; + key.modifiers.shift = false; + Some(key) +} + impl PartialEq for KeyEvent { fn eq(&self, key: &Key) -> bool { - &self.key == key + if &self.key == key { + return true; + } + + let Some(shifted_evt) = apply_shift(self.key, false, self.shifted_codepoint) else { + return false; + }; + let Some(shifted_key) = apply_shift(*key, true, '\0') else { + return false; + }; + shifted_evt == shifted_key } } +#[test] +fn test_key_event_eq() { + let none = Modifiers::default(); + let shift = Modifiers::SHIFT; + let ctrl = Modifiers::CTRL; + let ctrl_shift = Modifiers { + ctrl: true, + shift: true, + ..Default::default() + }; + + assert_eq!(KeyEvent::new(none, 'a'), Key::new(none, 'a')); + assert_ne!(KeyEvent::new(none, 'a'), Key::new(none, 'A')); + assert_eq!(KeyEvent::new(shift, 'a'), Key::new(shift, 'a')); + assert_ne!(KeyEvent::new(shift, 'a'), Key::new(none, 'A')); + assert_ne!(KeyEvent::new(shift, 'Γ€'), Key::new(none, 'Γ„')); + // For historical reasons we canonicalize notation for ASCII keys like "shift-a" to "A", + // but not "shift-a" events - those should send a shifted key. + assert_eq!(KeyEvent::new(none, 'A'), Key::new(shift, 'a')); + assert_ne!(KeyEvent::new(none, 'A'), Key::new(shift, 'A')); + assert_eq!(KeyEvent::new(none, 'Γ„'), Key::new(none, 'Γ„')); + assert_ne!(KeyEvent::new(none, 'Γ„'), Key::new(shift, 'Γ€')); + + // FYI: for codepoints that are not letters with uppercase/lowercase versions, we use + // the shifted key in the canonical notation, because the unshifted one may depend on the + // keyboard layout. + let ctrl_shift_equals = KeyEvent::with_shifted_codepoint(ctrl_shift, '=', Some('+')); + assert_eq!(ctrl_shift_equals, Key::new(ctrl_shift, '=')); + assert_eq!(ctrl_shift_equals, Key::new(ctrl, '+')); // canonical notation + assert_ne!(ctrl_shift_equals, Key::new(ctrl_shift, '+')); + assert_ne!(ctrl_shift_equals, Key::new(ctrl, '=')); + + // A event like capslock-shift-Γ€ may or may not include a shifted codepoint. + // + // Without a shifted codepoint, we cannot easily match ctrl-Γ„. + let caps_ctrl_shift_Γ€ = KeyEvent::new(ctrl_shift, 'Γ€'); + assert_eq!(caps_ctrl_shift_Γ€, Key::new(ctrl_shift, 'Γ€')); // canonical notation + assert_ne!(caps_ctrl_shift_Γ€, Key::new(ctrl, 'Γ€')); + assert_ne!(caps_ctrl_shift_Γ€, Key::new(ctrl, 'Γ„')); // can't match without shifted key + assert_ne!(caps_ctrl_shift_Γ€, Key::new(ctrl_shift, 'Γ„')); + // With a shifted codepoint, we can match the alternative notation too. + let caps_ctrl_shift_Γ€ = KeyEvent::with_shifted_codepoint(ctrl_shift, 'Γ€', Some('Γ„')); + assert_eq!(caps_ctrl_shift_Γ€, Key::new(ctrl_shift, 'Γ€')); // canonical notation + assert_ne!(caps_ctrl_shift_Γ€, Key::new(ctrl, 'Γ€')); + assert_eq!(caps_ctrl_shift_Γ€, Key::new(ctrl, 'Γ„')); // matched via shifted key + assert_ne!(caps_ctrl_shift_Γ€, Key::new(ctrl_shift, 'Γ„')); +} + /// Represents an event on the character input stream. #[derive(Debug, Clone)] pub enum CharEventType { @@ -599,13 +690,15 @@ pub(crate) fn terminal_protocols_disable_ifn() { TERMINAL_PROTOCOLS.store(false, Ordering::Release); } -fn parse_mask(mask: u32) -> Modifiers { - Modifiers { +fn parse_mask(mask: u32) -> (Modifiers, bool) { + let modifiers = Modifiers { ctrl: (mask & 4) != 0, alt: (mask & 2) != 0, shift: (mask & 1) != 0, sup: (mask & 8) != 0, - } + }; + let caps_lock = (mask & 64) != 0; + (modifiers, caps_lock) } // A data type used by the input machinery. @@ -878,16 +971,35 @@ fn parse_csi(&mut self, buffer: &mut Vec) -> Option { return None; } - let masked_key = |mut codepoint, shifted_codepoint| { + let masked_key = |codepoint: char, shifted_codepoint: Option| { let mask = params[1][0].saturating_sub(1); - let mut modifiers = parse_mask(mask); - if let Some(shifted_codepoint) = shifted_codepoint { - if shifted_codepoint != '\0' && modifiers.shift { - modifiers.shift = false; - codepoint = shifted_codepoint; - } + let (mut modifiers, caps_lock) = parse_mask(mask); + + // An event like "capslock-shift-=" should have a shifted codepoint ("+") to enable + // fish to match "bind +". + // + // With letters that are affected by capslock, capslock and shift cancel each + // other out ("capslock-shift-Γ€"), unless there is another modifier to imply that + // capslock should be ignored. + // + // So if shift is the only modifier, we should consume it, but not if the event is + // something like "capslock-shift-delete" because delete is not affected by capslock. + // + // Normally, we could consume shift by translating to the shifted key. + // While capslock is on however, we don't get a shifted key, see + // https://github.com/kovidgoyal/kitty/issues/8493. + // + // Do it by trying to find out ourselves whether the key is affected by capslock. + // + // Alternatively, we could relax our exact matching semantics, and make "bind Γ€" + // match the "shift-Γ€" event, as suggested in the kitty issue. + if caps_lock + && modifiers == Modifiers::SHIFT + && !codepoint.to_uppercase().eq(Some(codepoint).into_iter()) + { + modifiers.shift = false; } - KeyEvent::new(modifiers, codepoint) + KeyEvent::with_shifted_codepoint(modifiers, codepoint, shifted_codepoint) }; let key = match c { @@ -1075,7 +1187,7 @@ fn parse_ss3(&mut self, buffer: &mut Vec) -> Option { raw_mask = raw_mask * 10 + u32::from(code - b'0'); code = self.try_readb(buffer).unwrap_or(0xff); } - let modifiers = parse_mask(raw_mask.saturating_sub(1)); + let (modifiers, _caps_lock) = parse_mask(raw_mask.saturating_sub(1)); #[rustfmt::skip] let key = match code { b' ' => KeyEvent::new(modifiers, key::Space), diff --git a/src/key.rs b/src/key.rs index 3cd746373..9ae9a09ca 100644 --- a/src/key.rs +++ b/src/key.rs @@ -71,11 +71,22 @@ const fn new() -> Self { sup: false, } } + #[cfg(test)] + pub(crate) const CTRL: Self = { + let mut m = Self::new(); + m.ctrl = true; + m + }; pub(crate) const ALT: Self = { let mut m = Self::new(); m.alt = true; m }; + pub const SHIFT: Self = { + let mut m = Self::new(); + m.shift = true; + m + }; pub(crate) fn is_some(&self) -> bool { *self != Self::new() } @@ -203,19 +214,6 @@ pub(crate) fn canonicalize_key(mut key: Key) -> Result { key.modifiers.ctrl = true; } } - if key.modifiers.shift { - if key.codepoint.is_ascii_lowercase() { - // Shift + ASCII letters is just the uppercase letter. - key.modifiers.shift = false; - key.codepoint = key.codepoint.to_ascii_uppercase(); - } else if !fish_is_pua(key.codepoint) { - // Shift + any other printable character is not allowed. - return Err(wgettext_fmt!( - "Shift modifier is only supported on special keys and lowercase ASCII, not '%s'", - key, - )); - } - } Ok(key) } diff --git a/tests/checks/bind.fish b/tests/checks/bind.fish index baf7dc683..f5a263e37 100644 --- a/tests/checks/bind.fish +++ b/tests/checks/bind.fish @@ -151,4 +151,19 @@ bind \n 2>&1 bind _\cx_\ci_\ei_\\_\'_ 'echo foo' # CHECKERR: bind: cannot parse key '_\cx_\t_\ei_\\_'_' +bind A +# CHECKERR: bind: No binding found for key 'A' + +bind shift-a +# CHECKERR: bind: No binding found for key 'shift-a' + +bind shift-A +# CHECKERR: bind: No binding found for key 'shift-A' + +bind ctrl-shift-a +# CHECKERR: bind: No binding found for key 'ctrl-shift-a' + +bind ctrl-shift-Γ€ +# CHECKERR: bind: No binding found for key 'ctrl-shift-Γ€' + exit 0 From b9d9e7edc65af16a5867bad3c0063cefb56ba530 Mon Sep 17 00:00:00 2001 From: Johannes Altmanninger Date: Sat, 24 May 2025 10:42:06 +0200 Subject: [PATCH 31/70] Match bindings with explicit shift first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new key notation canonicalizes aggressively, e.g. these two bindings clash: bind ctrl-shift-a something bind shift-ctrl-a something else This means that key events generally match at most one active binding that uses the new syntax. The exception -- two coexisting new-syntax binds that match the same key event -- was added by commit 50a6e486a56 (Allow explicit shift modifier for non-ASCII letters, fix capslock behavior, 2025-03-30): bind ctrl-A 'echo A' bind ctrl-shift-a 'echo shift-a' The precedence was determined by definition order. This doesn't seem very useful. A following patch wants to resolve #11520 by matching "ctrl-Ρ†" events against "ctrl-w" bindings. It would be surprising if a "ctrl-w" binding shadowed a "ctrl-Ρ†" one based on something as subtle as definition order. Additionally, definition order semantics (which is an unintended cause of the implementation) is not really obvious. Reverse definition order would make more sense. Remove the ambiguity by always giving precedence to bindings that use explicit shift. Unrelated to this, as established in 50a6e486a56, explicit shift is still recommended for bicameral letters but not typically for others -- e.g. alt-+ is typically preferred over alt-shift-= because the former also works on a German keyboard. See #11520 (cherry picked from commit 08c8afcb1294475bebf324f48b48fc7ffd9dfdd2) --- src/bin/fish_key_reader.rs | 9 +-- src/input.rs | 127 ++++++++++++++++++++++++------------- src/input_common.rs | 90 +++++++++++++++----------- 3 files changed, 141 insertions(+), 85 deletions(-) diff --git a/src/bin/fish_key_reader.rs b/src/bin/fish_key_reader.rs index 405a7b33c..c875e33a3 100644 --- a/src/bin/fish_key_reader.rs +++ b/src/bin/fish_key_reader.rs @@ -20,8 +20,8 @@ eprintf, fprintf, input::input_terminfo_get_name, input_common::{ - terminal_protocol_hacks, terminal_protocols_enable_ifn, CharEvent, InputEventQueue, - InputEventQueuer, KeyEvent, + match_key_event_to_key, terminal_protocol_hacks, terminal_protocols_enable_ifn, CharEvent, + InputEventQueue, InputEventQueuer, KeyEvent, }, key::{char_to_symbol, Key, Modifiers}, panic::panic_handler, @@ -44,12 +44,13 @@ fn should_exit(recent_keys: &mut Vec, key_evt: KeyEvent) -> bool { let modes = shell_modes(); let cc = Key::from_single_byte(modes.c_cc[evt]); - if key_evt == cc { + if match_key_event_to_key(&key_evt, &cc).is_some() { if recent_keys .iter() .rev() .nth(1) - .is_some_and(|&prev| prev == cc) + .and_then(|&prev| match_key_event_to_key(&prev, &cc)) + .is_some() { return true; } diff --git a/src/input.rs b/src/input.rs index cb1d98b65..25ad13ca9 100644 --- a/src/input.rs +++ b/src/input.rs @@ -4,8 +4,8 @@ use crate::event; use crate::flog::FLOG; use crate::input_common::{ - CharEvent, CharInputStyle, InputData, InputEventQueuer, KeyEvent, ReadlineCmd, - R_END_INPUT_FUNCTIONS, + match_key_event_to_key, CharEvent, CharInputStyle, InputData, InputEventQueuer, KeyEvent, + KeyMatchQuality, ReadlineCmd, R_END_INPUT_FUNCTIONS, }; use crate::key::{self, canonicalize_raw_escapes, ctrl, Key, Modifiers}; use crate::proc::job_reap; @@ -17,6 +17,7 @@ use crate::wchar::prelude::*; use once_cell::sync::{Lazy, OnceCell}; use std::ffi::CString; +use std::mem; use std::sync::{ atomic::{AtomicU32, Ordering}, Mutex, MutexGuard, @@ -510,14 +511,19 @@ fn next(&mut self) -> CharEvent { /// Check if the next event is the given character. This advances the index on success only. /// If `escaped` is set, then return false if this (or any other) character had a timeout. - fn next_is_char(&mut self, style: &KeyNameStyle, key: Key, escaped: bool) -> bool { + fn next_is_char( + &mut self, + style: &KeyNameStyle, + key: Key, + escaped: bool, + ) -> Option { assert!( self.idx <= self.peeked.len(), "Index must not be larger than dequeued event count" ); // See if we had a timeout already. if escaped && self.had_timeout { - return false; + return None; } // Grab a new event if we have exhausted what we have already peeked. // Use either readch or readch_timed, per our param. @@ -528,7 +534,7 @@ fn next_is_char(&mut self, style: &KeyNameStyle, key: Key, escaped: bool) -> boo Some(evt) => evt, None => { self.had_timeout = true; - return false; + return None; } } } else { @@ -537,7 +543,7 @@ fn next_is_char(&mut self, style: &KeyNameStyle, key: Key, escaped: bool) -> boo Some(evt) => evt, None => { self.had_timeout = true; - return false; + return None; } } }; @@ -547,9 +553,7 @@ fn next_is_char(&mut self, style: &KeyNameStyle, key: Key, escaped: bool) -> boo // Now we have peeked far enough; check the event. // If it matches the char, then increment the index. let evt = &self.peeked[self.idx]; - let Some(kevt) = evt.get_key() else { - return false; - }; + let kevt = evt.get_key()?; if kevt.seq == L!("\x1b") && key.modifiers == Modifiers::ALT { self.idx += 1; self.subidx = 0; @@ -557,13 +561,13 @@ fn next_is_char(&mut self, style: &KeyNameStyle, key: Key, escaped: bool) -> boo return self.next_is_char(style, Key::from_raw(key.codepoint), true); } if *style == KeyNameStyle::Plain { - if kevt.key == key { + let result = match_key_event_to_key(&kevt.key, &key); + if let Some(key_match) = &result { assert!(self.subidx == 0); self.idx += 1; - FLOG!(reader, "matched full key", key); - return true; + FLOG!(reader, "matched full key", key, "kind", key_match); } - return false; + return result; } let actual_seq = kevt.seq.as_char_slice(); if !actual_seq.is_empty() { @@ -583,7 +587,11 @@ fn next_is_char(&mut self, style: &KeyNameStyle, key: Key, escaped: bool) -> boo actual_seq.len() ) ); - return true; + return Some(if matches!(style, KeyNameStyle::Terminfo(_)) { + KeyMatchQuality::Exact + } else { + KeyMatchQuality::Legacy + }); } if key.modifiers == Modifiers::ALT && seq_char == '\x1b' { if self.subidx + 1 == actual_seq.len() { @@ -603,11 +611,15 @@ fn next_is_char(&mut self, style: &KeyNameStyle, key: Key, escaped: bool) -> boo self.subidx = 0; } FLOG!(reader, format!("matched {key} against raw escape sequence")); - return true; + return Some(if matches!(style, KeyNameStyle::Terminfo(_)) { + KeyMatchQuality::Exact + } else { + KeyMatchQuality::Legacy + }); } } } - false + None } /// Consume all events up to the current index. @@ -634,7 +646,12 @@ pub fn restart(&mut self) { } /// Return true if this `peeker` matches a given sequence of char events given by `str`. - fn try_peek_sequence(&mut self, style: &KeyNameStyle, seq: &[Key]) -> bool { + fn try_peek_sequence( + &mut self, + style: &KeyNameStyle, + seq: &[Key], + quality: &mut Vec, + ) -> bool { assert!( !seq.is_empty(), "Empty sequence passed to try_peek_sequence" @@ -644,9 +661,10 @@ fn try_peek_sequence(&mut self, style: &KeyNameStyle, seq: &[Key]) -> bool { // If we just read an escape, we need to add a timeout for the next char, // to distinguish between the actual escape key and an "alt"-modifier. let escaped = *style != KeyNameStyle::Plain && prev == Key::from_raw(key::Escape); - if !self.next_is_char(style, *key, escaped) { + let Some(spec) = self.next_is_char(style, *key, escaped) else { return false; - } + }; + quality.push(spec); prev = *key; } if self.subidx != 0 { @@ -663,16 +681,24 @@ fn try_peek_sequence(&mut self, style: &KeyNameStyle, seq: &[Key]) -> bool { /// user's mapping list, then the preset list. /// Return none if nothing matches, or if we may have matched a longer sequence but it was /// interrupted by a readline event. - pub fn find_mapping( + pub fn find_mapping<'a>( &mut self, vars: &dyn Environment, - ip: &InputMappingSet, + ip: &'a InputMappingSet, ) -> Option { - let mut generic: Option<&InputMapping> = None; let bind_mode = input_get_bind_mode(vars); - let mut escape: Option<&InputMapping> = None; + + struct MatchedMapping<'a> { + mapping: &'a InputMapping, + quality: Vec, + idx: usize, + subidx: usize, + } + + let mut deferred: Option> = None; let ml = ip.mapping_list.iter().chain(ip.preset_mapping_list.iter()); + let mut quality = vec![]; for m in ml { if m.mode != bind_mode { continue; @@ -680,24 +706,41 @@ pub fn find_mapping( // Defer generic mappings until the end. if m.is_generic() { - if generic.is_none() { - generic = Some(m); + if deferred.is_none() { + deferred = Some(MatchedMapping { + mapping: m, + quality: vec![], + idx: self.idx, + subidx: self.subidx, + }); } continue; } // FLOG!(reader, "trying mapping", format!("{:?}", m)); - if self.try_peek_sequence(&m.key_name_style, &m.seq) { - // A binding for just escape should also be deferred - // so escape sequences take precedence. - if m.seq == vec![Key::from_raw(key::Escape)] { - if escape.is_none() { - escape = Some(m); - } - } else { + if self.try_peek_sequence(&m.key_name_style, &m.seq, &mut quality) { + // // A binding for just escape should also be deferred + // // so escape sequences take precedence. + let is_escape = m.seq == vec![Key::from_raw(key::Escape)]; + let is_perfect_match = quality + .iter() + .all(|key_match| *key_match == KeyMatchQuality::Exact); + if !is_escape && is_perfect_match { return Some(m.clone()); } + if deferred + .as_ref() + .is_none_or(|matched| !is_escape && quality >= matched.quality) + { + deferred = Some(MatchedMapping { + mapping: m, + quality: mem::take(&mut quality), + idx: self.idx, + subidx: self.subidx, + }); + } } + quality.clear(); self.restart(); } if self.char_sequence_interrupted() { @@ -706,17 +749,13 @@ pub fn find_mapping( return None; } - if escape.is_some() { - // We need to reconsume the escape. - self.next(); - return escape.cloned(); - } - - if generic.is_some() { - generic.cloned() - } else { - None - } + deferred + .map(|matched| { + self.idx = matched.idx; + self.subidx = matched.subidx; + matched.mapping + }) + .cloned() } } diff --git a/src/input_common.rs b/src/input_common.rs index b44e3bdcb..5c8c64f58 100644 --- a/src/input_common.rs +++ b/src/input_common.rs @@ -6,7 +6,7 @@ }; use crate::env::{EnvStack, Environment}; use crate::fd_readable_set::FdReadableSet; -use crate::flog::FLOG; +use crate::flog::{FloggableDebug, FLOG}; use crate::fork_exec::flog_safe::FLOG_SAFE; use crate::future_feature_flags::{feature_test, FeatureFlag}; use crate::global_safety::RelaxedAtomicBool; @@ -208,24 +208,33 @@ fn apply_shift(mut key: Key, do_ascii: bool, shifted_codepoint: char) -> Option< Some(key) } -impl PartialEq for KeyEvent { - fn eq(&self, key: &Key) -> bool { - if &self.key == key { - return true; - } +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub enum KeyMatchQuality { + Legacy, + ModuloShift, + Exact, +} - let Some(shifted_evt) = apply_shift(self.key, false, self.shifted_codepoint) else { - return false; - }; - let Some(shifted_key) = apply_shift(*key, true, '\0') else { - return false; - }; - shifted_evt == shifted_key +impl FloggableDebug for KeyMatchQuality {} + +pub fn match_key_event_to_key(event: &KeyEvent, key: &Key) -> Option { + if &event.key == key { + return Some(KeyMatchQuality::Exact); } + + let shifted_evt = apply_shift(event.key, false, event.shifted_codepoint)?; + let shifted_key = apply_shift(*key, true, '\0')?; + (shifted_evt == shifted_key).then_some(KeyMatchQuality::ModuloShift) } #[test] -fn test_key_event_eq() { +fn test_match_key_event_to_key() { + macro_rules! validate { + ($evt:expr, $key:expr, $expected:expr) => { + assert_eq!(match_key_event_to_key(&$evt, &$key), $expected); + }; + } + let none = Modifiers::default(); let shift = Modifiers::SHIFT; let ctrl = Modifiers::CTRL; @@ -235,41 +244,48 @@ fn test_key_event_eq() { ..Default::default() }; - assert_eq!(KeyEvent::new(none, 'a'), Key::new(none, 'a')); - assert_ne!(KeyEvent::new(none, 'a'), Key::new(none, 'A')); - assert_eq!(KeyEvent::new(shift, 'a'), Key::new(shift, 'a')); - assert_ne!(KeyEvent::new(shift, 'a'), Key::new(none, 'A')); - assert_ne!(KeyEvent::new(shift, 'Γ€'), Key::new(none, 'Γ„')); + let exact = KeyMatchQuality::Exact; + let modulo_shift = KeyMatchQuality::ModuloShift; + + validate!(KeyEvent::new(none, 'a'), Key::new(none, 'a'), Some(exact)); + validate!(KeyEvent::new(none, 'a'), Key::new(none, 'A'), None); + validate!(KeyEvent::new(shift, 'a'), Key::new(shift, 'a'), Some(exact)); + validate!(KeyEvent::new(shift, 'a'), Key::new(none, 'A'), None); + validate!(KeyEvent::new(shift, 'Γ€'), Key::new(none, 'Γ„'), None); // For historical reasons we canonicalize notation for ASCII keys like "shift-a" to "A", // but not "shift-a" events - those should send a shifted key. - assert_eq!(KeyEvent::new(none, 'A'), Key::new(shift, 'a')); - assert_ne!(KeyEvent::new(none, 'A'), Key::new(shift, 'A')); - assert_eq!(KeyEvent::new(none, 'Γ„'), Key::new(none, 'Γ„')); - assert_ne!(KeyEvent::new(none, 'Γ„'), Key::new(shift, 'Γ€')); + validate!( + KeyEvent::new(none, 'A'), + Key::new(shift, 'a'), + Some(modulo_shift) + ); + validate!(KeyEvent::new(none, 'A'), Key::new(shift, 'A'), None); + validate!(KeyEvent::new(none, 'Γ„'), Key::new(none, 'Γ„'), Some(exact)); + validate!(KeyEvent::new(none, 'Γ„'), Key::new(shift, 'Γ€'), None); // FYI: for codepoints that are not letters with uppercase/lowercase versions, we use // the shifted key in the canonical notation, because the unshifted one may depend on the // keyboard layout. let ctrl_shift_equals = KeyEvent::with_shifted_codepoint(ctrl_shift, '=', Some('+')); - assert_eq!(ctrl_shift_equals, Key::new(ctrl_shift, '=')); - assert_eq!(ctrl_shift_equals, Key::new(ctrl, '+')); // canonical notation - assert_ne!(ctrl_shift_equals, Key::new(ctrl_shift, '+')); - assert_ne!(ctrl_shift_equals, Key::new(ctrl, '=')); + validate!(ctrl_shift_equals, Key::new(ctrl_shift, '='), Some(exact)); + validate!(ctrl_shift_equals, Key::new(ctrl, '+'), Some(modulo_shift)); // canonical notation + validate!(ctrl_shift_equals, Key::new(ctrl_shift, '+'), None); + validate!(ctrl_shift_equals, Key::new(ctrl, '='), None); // A event like capslock-shift-Γ€ may or may not include a shifted codepoint. // // Without a shifted codepoint, we cannot easily match ctrl-Γ„. let caps_ctrl_shift_Γ€ = KeyEvent::new(ctrl_shift, 'Γ€'); - assert_eq!(caps_ctrl_shift_Γ€, Key::new(ctrl_shift, 'Γ€')); // canonical notation - assert_ne!(caps_ctrl_shift_Γ€, Key::new(ctrl, 'Γ€')); - assert_ne!(caps_ctrl_shift_Γ€, Key::new(ctrl, 'Γ„')); // can't match without shifted key - assert_ne!(caps_ctrl_shift_Γ€, Key::new(ctrl_shift, 'Γ„')); + validate!(caps_ctrl_shift_Γ€, Key::new(ctrl_shift, 'Γ€'), Some(exact)); // canonical notation + validate!(caps_ctrl_shift_Γ€, Key::new(ctrl, 'Γ€'), None); + validate!(caps_ctrl_shift_Γ€, Key::new(ctrl, 'Γ„'), None); // can't match without shifted key + validate!(caps_ctrl_shift_Γ€, Key::new(ctrl_shift, 'Γ„'), None); // With a shifted codepoint, we can match the alternative notation too. let caps_ctrl_shift_Γ€ = KeyEvent::with_shifted_codepoint(ctrl_shift, 'Γ€', Some('Γ„')); - assert_eq!(caps_ctrl_shift_Γ€, Key::new(ctrl_shift, 'Γ€')); // canonical notation - assert_ne!(caps_ctrl_shift_Γ€, Key::new(ctrl, 'Γ€')); - assert_eq!(caps_ctrl_shift_Γ€, Key::new(ctrl, 'Γ„')); // matched via shifted key - assert_ne!(caps_ctrl_shift_Γ€, Key::new(ctrl_shift, 'Γ„')); + validate!(caps_ctrl_shift_Γ€, Key::new(ctrl_shift, 'Γ€'), Some(exact)); // canonical notation + validate!(caps_ctrl_shift_Γ€, Key::new(ctrl, 'Γ€'), None); + validate!(caps_ctrl_shift_Γ€, Key::new(ctrl, 'Γ„'), Some(modulo_shift)); // matched via shifted key + validate!(caps_ctrl_shift_Γ€, Key::new(ctrl_shift, 'Γ„'), None); } /// Represents an event on the character input stream. @@ -823,7 +839,7 @@ fn try_readch(&mut self, blocking: bool) -> Option { } let mut seq = WString::new(); let mut key = key_with_escape; - if key.is_some_and(|key| key == Key::from_raw(key::Invalid)) { + if key.is_some_and(|key| key.key == Key::from_raw(key::Invalid)) { continue; } assert!(key.map_or(true, |key| key.codepoint != key::Invalid)); @@ -903,7 +919,7 @@ fn parse_escape_sequence( return Some( match self.parse_escape_sequence(buffer, have_escape_prefix) { Some(mut nested_sequence) => { - if nested_sequence == invalid.key { + if nested_sequence.key == invalid.key { return Some(KeyEvent::from_raw(key::Escape)); } nested_sequence.modifiers.alt = true; From 68d2cafa6e6c8d1efaa8eb0336db67caf7be9439 Mon Sep 17 00:00:00 2001 From: Johannes Altmanninger Date: Sat, 17 May 2025 07:30:33 +0200 Subject: [PATCH 32/70] input: remove unnecessary check in bracketed paste code path When "self.paste_is_buffering()" is true, "parse_escape_sequence()" explicitly returns "None" instead of "Some(Escape)". This is irrelevant because this return value is never read, as long as "self.paste_is_buffering()" remains true until "parse_escape_sequence()" returns, because the caller will return early in that case. Paste buffering only ends if we actually read a complete escape sequence (for ending bracketed paste). Remove this extra branch. (cherry picked from commit e5fdd77b097c371a83f3641601541627347afda8) --- src/input_common.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/input_common.rs b/src/input_common.rs index 5c8c64f58..52eee9f39 100644 --- a/src/input_common.rs +++ b/src/input_common.rs @@ -909,10 +909,7 @@ fn parse_escape_sequence( have_escape_prefix: &mut bool, ) -> Option { let Some(next) = self.try_readb(buffer) else { - if !self.paste_is_buffering() { - return Some(KeyEvent::from_raw(key::Escape)); - } - return None; + return Some(KeyEvent::from_raw(key::Escape)); }; let invalid = KeyEvent::from_raw(key::Invalid); if buffer.len() == 2 && next == b'\x1b' { From 3f1add9e217ab4461b76d8988b8628251cd01758 Mon Sep 17 00:00:00 2001 From: Johannes Altmanninger Date: Sun, 26 Jan 2025 14:52:42 +0100 Subject: [PATCH 33/70] Sanitize some inputs in CSI parser This was copied from C++ code but we have overflow checks, which forces us to actually handle errors. While at it, add some basic error logging. Fixes #11092 (cherry picked from commit 4c28a7771e40a9f5936195a0b841609be2c8136e) --- src/input_common.rs | 35 +++++++++++++++++++++++++++++++---- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/src/input_common.rs b/src/input_common.rs index 52eee9f39..486730f5f 100644 --- a/src/input_common.rs +++ b/src/input_common.rs @@ -11,8 +11,8 @@ use crate::future_feature_flags::{feature_test, FeatureFlag}; use crate::global_safety::RelaxedAtomicBool; use crate::key::{ - self, alt, canonicalize_control_char, canonicalize_keyed_control_char, ctrl, function_key, - shift, Key, Modifiers, + self, alt, canonicalize_control_char, canonicalize_keyed_control_char, char_to_symbol, ctrl, + function_key, shift, Key, Modifiers, }; use crate::reader::{reader_current_data, reader_test_and_clear_interrupted}; use crate::threads::{iothread_port, is_main_thread}; @@ -966,9 +966,13 @@ fn parse_csi(&mut self, buffer: &mut Vec) -> Option { while count < 16 && c >= 0x30 && c <= 0x3f { if c.is_ascii_digit() { // Return None on invalid ascii numeric CSI parameter exceeding u32 bounds - params[count][subcount] = params[count][subcount] + match params[count][subcount] .checked_mul(10) - .and_then(|result| result.checked_add(u32::from(c - b'0')))?; + .and_then(|result| result.checked_add(u32::from(c - b'0'))) + { + Some(c) => params[count][subcount] = c, + None => return invalid_sequence(buffer), + }; } else if c == b':' && subcount < 3 { subcount += 1; } else if c == b';' { @@ -1493,6 +1497,29 @@ pub(crate) fn decode_input_byte( Complete } +fn invalid_sequence(buffer: &[u8]) -> Option { + FLOG!( + reader, + "Error: invalid escape sequence: ", + DisplayBytes(buffer) + ); + None +} + +struct DisplayBytes<'a>(&'a [u8]); + +impl<'a> std::fmt::Display for DisplayBytes<'a> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + for (i, &c) in self.0.iter().enumerate() { + if i != 0 { + write!(f, " ")?; + } + write!(f, "{}", char_to_symbol(char::from(c)))?; + } + Ok(()) + } +} + /// A simple, concrete implementation of InputEventQueuer. pub struct InputEventQueue { data: InputData, From 65fc2b539c4e4901447e831df5288ca0a3d677b2 Mon Sep 17 00:00:00 2001 From: Johannes Altmanninger Date: Thu, 22 May 2025 08:49:29 +0200 Subject: [PATCH 34/70] Fix some invalid assertions parsing keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For example the terminal sending Β« CSI 55296 ; 5 u Β» would crash fish. (cherry picked from commit c7391d10262925392da3756166e8ae82ad3be3a1) --- src/input_common.rs | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/src/input_common.rs b/src/input_common.rs index 486730f5f..dcdbb5ffa 100644 --- a/src/input_common.rs +++ b/src/input_common.rs @@ -1103,9 +1103,10 @@ fn parse_csi(&mut self, buffer: &mut Vec) -> Option { char::from_u32(u32::from(function_key(3)) + params[0][0] - 25).unwrap(), )), // rxvt style 27 => { - let key = - canonicalize_keyed_control_char(char::from_u32(params[2][0]).unwrap()); - masked_key(key, None) + let Some(key) = char::from_u32(params[2][0]) else { + return invalid_sequence(buffer); + }; + masked_key(canonicalize_keyed_control_char(key), None) } 28 | 29 => KeyEvent::from(shift( char::from_u32(u32::from(function_key(5)) + params[0][0] - 28).unwrap(), @@ -1158,14 +1159,17 @@ fn parse_csi(&mut self, buffer: &mut Vec) -> Option { 57424 => key::End, 57425 => key::Insert, 57426 => key::Delete, - cp => canonicalize_keyed_control_char(char::from_u32(cp).unwrap()), + cp => { + let Some(key) = char::from_u32(cp) else { + return invalid_sequence(buffer); + }; + canonicalize_keyed_control_char(key) + } }; - masked_key( - key, - Some(canonicalize_keyed_control_char( - char::from_u32(params[0][1]).unwrap(), - )), - ) + let Some(shifted_key) = char::from_u32(params[0][1]) else { + return invalid_sequence(buffer); + }; + masked_key(key, Some(canonicalize_keyed_control_char(shifted_key))) } b'Z' => KeyEvent::from(shift(key::Tab)), b'I' => { From 59b9f578023c916557f4834b3670fbee4d0242f1 Mon Sep 17 00:00:00 2001 From: Johannes Altmanninger Date: Mon, 26 May 2025 10:52:29 +0200 Subject: [PATCH 35/70] fish_key_reader: unopinionated description for bind notation variants As explained in the parent commit, "alt-+" is usually preferred over "alt-shift-=" but both have their moments. We communicate this via a comment saying "# recommended notation". This is not always true and not super helpful, especially as we add a third variant for #11520 (physical key), which is the recommended one for users who switch between English and Cyrillic layouts. Only explain what each variant does. Based on this the user may figure out which one to use. (cherry picked from commit 4cbd1b83f1bf76d76680168d2bc714fb6564319d) --- src/bin/fish_key_reader.rs | 33 +++++++++++---------------------- 1 file changed, 11 insertions(+), 22 deletions(-) diff --git a/src/bin/fish_key_reader.rs b/src/bin/fish_key_reader.rs index c875e33a3..234621678 100644 --- a/src/bin/fish_key_reader.rs +++ b/src/bin/fish_key_reader.rs @@ -23,7 +23,7 @@ match_key_event_to_key, terminal_protocol_hacks, terminal_protocols_enable_ifn, CharEvent, InputEventQueue, InputEventQueuer, KeyEvent, }, - key::{char_to_symbol, Key, Modifiers}, + key::{char_to_symbol, Key}, panic::panic_handler, print_help::print_help, printf, @@ -114,33 +114,22 @@ fn process_input(continuous_mode: bool, verbose: bool) -> i32 { } printf!("\n"); } - let print_bind_example = |key: &Key, recommended: bool| { - printf!( - "bind %s 'do something'%s\n", - key, - if recommended { - " # recommended notation" - } else { - "" - } - ); - }; let have_shifted_key = kevt.key.shifted_codepoint != '\0'; - // If we have shift + some other modifier, the lowercase version is the canonical one. - let prefer_explicit_shift = kevt.key.modifiers.shift - && kevt.key.modifiers != Modifiers::SHIFT - && kevt - .key - .shifted_codepoint - .to_lowercase() - .eq(Some(kevt.key.codepoint).into_iter()); + let mut keys = vec![(kevt.key.key, "")]; if have_shifted_key { let mut shifted_key = kevt.key.key; shifted_key.modifiers.shift = false; shifted_key.codepoint = kevt.key.shifted_codepoint; - print_bind_example(&shifted_key, !prefer_explicit_shift); + keys.push((shifted_key, "shifted key")); + } + for (key, explanation) in keys { + printf!( + "bind %s 'do something'%s%s\n", + key, + if explanation.is_empty() { "" } else { " # " }, + explanation, + ); } - print_bind_example(&kevt.key, have_shifted_key && prefer_explicit_shift); if let Some(name) = sequence_name(&mut recent_chars1, c) { printf!("bind -k %ls 'do something'\n", name); } From e0cabacdaa283f893cb070f4ea948ce0a0c2ec90 Mon Sep 17 00:00:00 2001 From: Johannes Altmanninger Date: Thu, 22 May 2025 08:45:33 +0200 Subject: [PATCH 36/70] kitty keyboard protocol: fall back to base layout key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On terminals that do not implement the kitty keyboard protocol "ctrl-Ρ†" on a Russian keyboard layout generally sends the same byte as "ctrl-w". This is because historically there was no standard way to encode "ctrl-Ρ†", and the "Ρ†" letter happens to be in the same position as "w" on the PC-101 keyboard layout. Users have gotten used to this, probably because many of them are switching between a Russian (or Greek etc.) and an English layout. Vim/Emacs allow opting in to this behavior by setting the "input method" (which probably means "keyboard layout"). Match key events that have the base layout key set against bindings for that key. Closes #11520 --- Alternatively, we could add the relevant preset bindings (for "ctrl-Ρ†" etc.) but 1. this will be wrong if there is a disagreement on the placement of "Ρ†" between two layouts 2. there are a lot of them 3. it won't work for user bindings (for better or worse) (cherry picked from commit 7a79728df3a5061f1ed967ad00dce58808db4686) --- src/bin/fish_key_reader.rs | 5 ++ src/input_common.rs | 122 +++++++++++++++++++++++++------------ 2 files changed, 88 insertions(+), 39 deletions(-) diff --git a/src/bin/fish_key_reader.rs b/src/bin/fish_key_reader.rs index 234621678..85655bf58 100644 --- a/src/bin/fish_key_reader.rs +++ b/src/bin/fish_key_reader.rs @@ -122,6 +122,11 @@ fn process_input(continuous_mode: bool, verbose: bool) -> i32 { shifted_key.codepoint = kevt.key.shifted_codepoint; keys.push((shifted_key, "shifted key")); } + if kevt.key.base_layout_codepoint != '\0' { + let mut base_layout_key = kevt.key.key; + base_layout_key.codepoint = kevt.key.base_layout_codepoint; + keys.push((base_layout_key, "physical key")); + } for (key, explanation) in keys { printf!( "bind %s 'do something'%s%s\n", diff --git a/src/input_common.rs b/src/input_common.rs index dcdbb5ffa..865c39904 100644 --- a/src/input_common.rs +++ b/src/input_common.rs @@ -143,20 +143,23 @@ pub enum ReadlineCmd { pub struct KeyEvent { pub key: Key, pub shifted_codepoint: char, + pub base_layout_codepoint: char, } impl KeyEvent { pub(crate) fn new(modifiers: Modifiers, codepoint: char) -> Self { Self::from(Key::new(modifiers, codepoint)) } - pub(crate) fn with_shifted_codepoint( + pub(crate) fn new_with( modifiers: Modifiers, codepoint: char, - shifted_codepoint: Option, + shifted_key: Option, + base_layout_key: Option, ) -> Self { Self { key: Key::new(modifiers, codepoint), - shifted_codepoint: shifted_codepoint.unwrap_or_default(), + shifted_codepoint: shifted_key.unwrap_or_default(), + base_layout_codepoint: base_layout_key.unwrap_or_default(), } } pub(crate) fn from_raw(codepoint: char) -> Self { @@ -169,10 +172,7 @@ pub fn from_single_byte(c: u8) -> Self { impl From for KeyEvent { fn from(key: Key) -> Self { - Self { - key, - shifted_codepoint: '\0', - } + Self::new_with(key.modifiers, key.codepoint, None, None) } } @@ -211,6 +211,8 @@ fn apply_shift(mut key: Key, do_ascii: bool, shifted_codepoint: char) -> Option< #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] pub enum KeyMatchQuality { Legacy, + BaseLayoutModuloShift, + BaseLayout, ModuloShift, Exact, } @@ -222,9 +224,25 @@ pub fn match_key_event_to_key(event: &KeyEvent, key: &Key) -> Option) -> Option { return None; } - let masked_key = |codepoint: char, shifted_codepoint: Option| { + let kitty_key = |key: char, shifted_key: Option, base_layout_key: Option| { let mask = params[1][0].saturating_sub(1); let (mut modifiers, caps_lock) = parse_mask(mask); @@ -1012,12 +1051,13 @@ fn parse_csi(&mut self, buffer: &mut Vec) -> Option { // match the "shift-Γ€" event, as suggested in the kitty issue. if caps_lock && modifiers == Modifiers::SHIFT - && !codepoint.to_uppercase().eq(Some(codepoint).into_iter()) + && !key.to_uppercase().eq(Some(key).into_iter()) { modifiers.shift = false; } - KeyEvent::with_shifted_codepoint(modifiers, codepoint, shifted_codepoint) + KeyEvent::new_with(modifiers, key, shifted_key, base_layout_key) }; + let masked_key = |key: char| kitty_key(key, None, None); let key = match c { b'$' => { @@ -1032,13 +1072,13 @@ fn parse_csi(&mut self, buffer: &mut Vec) -> Option { _ => return None, } } - b'A' => masked_key(key::Up, None), - b'B' => masked_key(key::Down, None), - b'C' => masked_key(key::Right, None), - b'D' => masked_key(key::Left, None), - b'E' => masked_key('5', None), // Numeric keypad - b'F' => masked_key(key::End, None), // PC/xterm style - b'H' => masked_key(key::Home, None), // PC/xterm style + b'A' => masked_key(key::Up), + b'B' => masked_key(key::Down), + b'C' => masked_key(key::Right), + b'D' => masked_key(key::Left), + b'E' => masked_key('5'), // Numeric keypad + b'F' => masked_key(key::End), // PC/xterm style + b'H' => masked_key(key::Home), // PC/xterm style b'M' | b'm' => { self.disable_mouse_tracking(); let sgr = private_mode == Some(b'<'); @@ -1074,30 +1114,27 @@ fn parse_csi(&mut self, buffer: &mut Vec) -> Option { } return None; } - b'P' => masked_key(function_key(1), None), - b'Q' => masked_key(function_key(2), None), - b'R' => masked_key(function_key(3), None), - b'S' => masked_key(function_key(4), None), + b'P' => masked_key(function_key(1)), + b'Q' => masked_key(function_key(2)), + b'R' => masked_key(function_key(3)), + b'S' => masked_key(function_key(4)), b'~' => match params[0][0] { - 1 => masked_key(key::Home, None), // VT220/tmux style - 2 => masked_key(key::Insert, None), - 3 => masked_key(key::Delete, None), - 4 => masked_key(key::End, None), // VT220/tmux style - 5 => masked_key(key::PageUp, None), - 6 => masked_key(key::PageDown, None), - 7 => masked_key(key::Home, None), // rxvt style - 8 => masked_key(key::End, None), // rxvt style + 1 => masked_key(key::Home), // VT220/tmux style + 2 => masked_key(key::Insert), + 3 => masked_key(key::Delete), + 4 => masked_key(key::End), // VT220/tmux style + 5 => masked_key(key::PageUp), + 6 => masked_key(key::PageDown), + 7 => masked_key(key::Home), // rxvt style + 8 => masked_key(key::End), // rxvt style 11..=15 => masked_key( char::from_u32(u32::from(function_key(1)) + params[0][0] - 11).unwrap(), - None, ), 17..=21 => masked_key( char::from_u32(u32::from(function_key(6)) + params[0][0] - 17).unwrap(), - None, ), 23 | 24 => masked_key( char::from_u32(u32::from(function_key(11)) + params[0][0] - 23).unwrap(), - None, ), 25 | 26 => KeyEvent::from(shift( char::from_u32(u32::from(function_key(3)) + params[0][0] - 25).unwrap(), @@ -1106,7 +1143,7 @@ fn parse_csi(&mut self, buffer: &mut Vec) -> Option { let Some(key) = char::from_u32(params[2][0]) else { return invalid_sequence(buffer); }; - masked_key(canonicalize_keyed_control_char(key), None) + masked_key(canonicalize_keyed_control_char(key)) } 28 | 29 => KeyEvent::from(shift( char::from_u32(u32::from(function_key(5)) + params[0][0] - 28).unwrap(), @@ -1169,7 +1206,14 @@ fn parse_csi(&mut self, buffer: &mut Vec) -> Option { let Some(shifted_key) = char::from_u32(params[0][1]) else { return invalid_sequence(buffer); }; - masked_key(key, Some(canonicalize_keyed_control_char(shifted_key))) + let Some(base_layout_key) = char::from_u32(params[0][2]) else { + return invalid_sequence(buffer); + }; + kitty_key( + key, + Some(canonicalize_keyed_control_char(shifted_key)), + Some(base_layout_key), + ) } b'Z' => KeyEvent::from(shift(key::Tab)), b'I' => { From c052beb4dd3644c607181ec3d3af4a6b0be3e243 Mon Sep 17 00:00:00 2001 From: Johannes Altmanninger Date: Tue, 10 Jun 2025 17:56:21 +0200 Subject: [PATCH 37/70] Fix build on Rust 1.70 --- src/input.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/input.rs b/src/input.rs index 25ad13ca9..20d959a3c 100644 --- a/src/input.rs +++ b/src/input.rs @@ -3,6 +3,8 @@ use crate::env::{Environment, CURSES_INITIALIZED}; use crate::event; use crate::flog::FLOG; +#[allow(unused_imports)] +use crate::future::IsSomeAnd; use crate::input_common::{ match_key_event_to_key, CharEvent, CharInputStyle, InputData, InputEventQueuer, KeyEvent, KeyMatchQuality, ReadlineCmd, R_END_INPUT_FUNCTIONS, From 62ac23453e39eff8d0cdfbed79c456d43fcdb123 Mon Sep 17 00:00:00 2001 From: Erick Howard <78889625+needlesslygrim@users.noreply.github.com> Date: Thu, 2 Jan 2025 02:17:48 +0800 Subject: [PATCH 38/70] Code cleanup in `src/bin/fish.rs` to make it more idiomatic (#10975) Code cleanup in `src/bin/fish.rs` to make it more idiomatic (cherry picked from commit 967c4b22725abb172bdecf09eac8cdbfddc4f968) --- src/bin/fish.rs | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/bin/fish.rs b/src/bin/fish.rs index 2a06f3a2c..7f5e36684 100644 --- a/src/bin/fish.rs +++ b/src/bin/fish.rs @@ -817,10 +817,9 @@ fn throwing_main() -> i32 { save_term_foreground_process_group(); } - let mut paths: Option = None; // If we're not executing, there's no need to find the config. - if !opts.no_exec { - paths = Some(determine_config_directory_paths(OsString::from_vec( + let paths: Option = if !opts.no_exec { + let paths = Some(determine_config_directory_paths(OsString::from_vec( wcs2string(&args[0]), ))); env_init( @@ -828,7 +827,10 @@ fn throwing_main() -> i32 { /* do uvars */ !opts.no_config, /* default paths */ opts.no_config, ); - } + paths + } else { + None + }; // Set features early in case other initialization depends on them. // Start with the ones set in the environment, then those set on the command line (so the @@ -845,7 +847,7 @@ fn throwing_main() -> i32 { // Construct the root parser! let env = Rc::new(EnvStack::globals().create_child(true /* dispatches_var_changes */)); - let parser: &Parser = &Parser::new(env, CancelBehavior::Clear); + let parser = &Parser::new(env, CancelBehavior::Clear); parser.set_syncs_uvars(!opts.no_config); if !opts.no_exec && !opts.no_config { @@ -1008,7 +1010,6 @@ fn fish_xdm_login_hack_hack_hack_hack(cmds: &mut [OsString], args: &[WString]) - return false; } - let mut result = false; let cmd = &cmds[0]; if cmd == "exec \"${@}\"" || cmd == "exec \"$@\"" { // We're going to construct a new command that starts with exec, and then has the @@ -1020,7 +1021,8 @@ fn fish_xdm_login_hack_hack_hack_hack(cmds: &mut [OsString], args: &[WString]) - } cmds[0] = new_cmd; - result = true; + true + } else { + false } - result } From 63a08e53e5508fea33c6e21971f150491fc0aec8 Mon Sep 17 00:00:00 2001 From: Integral Date: Sun, 22 Dec 2024 02:34:27 +0800 Subject: [PATCH 39/70] Replace some PathBuf with Path avoid unnecessary heap allocation (#10929) (cherry picked from commit b19a467ea6012ef7ee6fb0ed43a448a6989bb24b) --- build.rs | 16 ++++++++-------- src/bin/fish.rs | 6 +++--- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/build.rs b/build.rs index 6d6bdff4d..04ce54f67 100644 --- a/build.rs +++ b/build.rs @@ -227,7 +227,7 @@ fn has_small_stack(_: &Target) -> Result> { } fn setup_paths() { - fn get_path(name: &str, default: &str, onvar: PathBuf) -> PathBuf { + fn get_path(name: &str, default: &str, onvar: &Path) -> PathBuf { let mut var = PathBuf::from(env::var(name).unwrap_or(default.to_string())); if var.is_relative() { var = onvar.join(var); @@ -250,7 +250,7 @@ fn get_path(name: &str, default: &str, onvar: PathBuf) -> PathBuf { rsconf::rebuild_if_env_changed("PREFIX"); rsconf::set_env_value("PREFIX", prefix.to_str().unwrap()); - let datadir = get_path("DATADIR", "share/", prefix.clone()); + let datadir = get_path("DATADIR", "share/", &prefix); rsconf::set_env_value("DATADIR", datadir.to_str().unwrap()); rsconf::rebuild_if_env_changed("DATADIR"); @@ -261,7 +261,7 @@ fn get_path(name: &str, default: &str, onvar: PathBuf) -> PathBuf { }; rsconf::set_env_value("DATADIR_SUBDIR", datadir_subdir); - let bindir = get_path("BINDIR", "bin/", prefix.clone()); + let bindir = get_path("BINDIR", "bin/", &prefix); rsconf::set_env_value("BINDIR", bindir.to_str().unwrap()); rsconf::rebuild_if_env_changed("BINDIR"); @@ -270,16 +270,16 @@ fn get_path(name: &str, default: &str, onvar: PathBuf) -> PathBuf { // If we get our prefix from $HOME, we should use the system's /etc/ // ~/.local/share/etc/ makes no sense if prefix_from_home { "/etc/" } else { "etc/" }, - datadir.clone(), + &datadir, ); rsconf::set_env_value("SYSCONFDIR", sysconfdir.to_str().unwrap()); rsconf::rebuild_if_env_changed("SYSCONFDIR"); - let localedir = get_path("LOCALEDIR", "locale/", datadir.clone()); + let localedir = get_path("LOCALEDIR", "locale/", &datadir); rsconf::set_env_value("LOCALEDIR", localedir.to_str().unwrap()); rsconf::rebuild_if_env_changed("LOCALEDIR"); - let docdir = get_path("DOCDIR", "doc/fish", datadir.clone()); + let docdir = get_path("DOCDIR", "doc/fish", &datadir); rsconf::set_env_value("DOCDIR", docdir.to_str().unwrap()); rsconf::rebuild_if_env_changed("DOCDIR"); } @@ -292,7 +292,7 @@ fn get_version(src_dir: &Path) -> String { return var; } - let path = PathBuf::from(src_dir).join("version"); + let path = src_dir.join("version"); if let Ok(strver) = read_to_string(path) { return strver.to_string(); } @@ -321,7 +321,7 @@ fn get_version(src_dir: &Path) -> String { // or because it refused (safe.directory applies to `git describe`!) // So we read the SHA ourselves. fn get_git_hash() -> Result> { - let gitdir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(".git"); + let gitdir = Path::new(env!("CARGO_MANIFEST_DIR")).join(".git"); // .git/HEAD contains ref: refs/heads/branch let headpath = gitdir.join("HEAD"); diff --git a/src/bin/fish.rs b/src/bin/fish.rs index 7f5e36684..9fee345cf 100644 --- a/src/bin/fish.rs +++ b/src/bin/fish.rs @@ -279,7 +279,7 @@ fn determine_config_directory_paths(argv0: impl AsRef) -> ConfigPaths { // Detect if we're running right out of the CMAKE build directory if exec_path.starts_with(env!("CARGO_MANIFEST_DIR")) { - let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); FLOG!( config, "Running out of target directory, using paths relative to CARGO_MANIFEST_DIR:\n", @@ -352,7 +352,7 @@ fn determine_config_directory_paths(argv0: impl AsRef) -> ConfigPaths { PathBuf::from(home).join(DATA_DIR).join(DATA_DIR_SUBDIR) } else { - PathBuf::from(DATA_DIR).join(DATA_DIR_SUBDIR) + Path::new(DATA_DIR).join(DATA_DIR_SUBDIR) }; let bin = if cfg!(feature = "installable") { exec_path.parent().map(|x| x.to_path_buf()) @@ -363,7 +363,7 @@ fn determine_config_directory_paths(argv0: impl AsRef) -> ConfigPaths { FLOG!(config, "Using compiled in paths:"); paths = ConfigPaths { data, - sysconf: PathBuf::from(SYSCONF_DIR).join("fish"), + sysconf: Path::new(SYSCONF_DIR).join("fish"), doc: DOC_DIR.into(), bin, } From 052fc18db92a0616efcdb9db8805c22bd72f844f Mon Sep 17 00:00:00 2001 From: Johannes Altmanninger Date: Fri, 13 Jun 2025 11:19:02 +0200 Subject: [PATCH 40/70] Extract config path module. NFC This is the "code movement" part of bf65b9e3 ("Change `gettext` paths to be relocatable (#11195)"). While at it, fix some warnings. --- src/bin/fish.rs | 164 +++++----------------------------------- src/env/config_paths.rs | 147 +++++++++++++++++++++++++++++++++++ src/env/mod.rs | 2 + 3 files changed, 168 insertions(+), 145 deletions(-) create mode 100644 src/env/config_paths.rs diff --git a/src/bin/fish.rs b/src/bin/fish.rs index 9fee345cf..2f3fc928b 100644 --- a/src/bin/fish.rs +++ b/src/bin/fish.rs @@ -21,6 +21,8 @@ #![allow(unstable_name_collisions)] #![allow(clippy::uninlined_format_args)] +#[cfg(feature = "installable")] +use fish::common::get_executable_path; #[allow(unused_imports)] use fish::future::IsSomeAnd; use fish::{ @@ -29,12 +31,12 @@ BUILTIN_ERR_MISSING, BUILTIN_ERR_UNKNOWN, STATUS_CMD_OK, STATUS_CMD_UNKNOWN, }, common::{ - escape, get_executable_path, save_term_foreground_process_group, scoped_push_replacer, - str2wcstring, wcs2osstring, wcs2string, PACKAGE_NAME, PROFILING_ACTIVE, PROGRAM_NAME, + escape, save_term_foreground_process_group, scoped_push_replacer, str2wcstring, wcs2string, + PACKAGE_NAME, PROFILING_ACTIVE, PROGRAM_NAME, }, env::{ environment::{env_init, EnvStack, Environment}, - ConfigPaths, EnvMode, Statuses, + ConfigPaths, EnvMode, Statuses, CONFIG_PATHS, }, eprintf, event::{self, Event}, @@ -65,22 +67,16 @@ use std::fs::File; use std::mem::MaybeUninit; use std::os::unix::prelude::*; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use std::rc::Rc; use std::sync::atomic::Ordering; use std::sync::Arc; use std::{env, ops::ControlFlow}; -const DOC_DIR: &str = env!("DOCDIR"); -const DATA_DIR: &str = env!("DATADIR"); -const DATA_DIR_SUBDIR: &str = env!("DATADIR_SUBDIR"); -const SYSCONF_DIR: &str = env!("SYSCONFDIR"); -const BIN_DIR: &str = env!("BINDIR"); - #[cfg(feature = "installable")] // Disable for clippy because otherwise it would require sphinx #[cfg(not(clippy))] -fn install(confirm: bool, dir: PathBuf) -> bool { +fn install(confirm: bool, dir: &PathBuf) -> bool { use rust_embed::RustEmbed; #[derive(RustEmbed)] @@ -192,7 +188,8 @@ fn install(confirm: bool, dir: PathBuf) -> bool { } #[cfg(any(clippy, not(feature = "installable")))] -fn install(_confirm: bool, _dir: PathBuf) -> bool { +#[allow(dead_code)] +fn install(_confirm: bool, _dir: &PathBuf) -> bool { eprintln!("Fish was built without support for self-installation"); return false; } @@ -264,128 +261,6 @@ fn print_rusage_self() { eprintln!(" signals: {signals}"); } -fn determine_config_directory_paths(argv0: impl AsRef) -> ConfigPaths { - // PORTING: why is this not just an associated method on ConfigPaths? - - let mut paths = ConfigPaths::default(); - let mut done = false; - let exec_path = get_executable_path(argv0.as_ref()); - if let Ok(exec_path) = exec_path.canonicalize() { - FLOG!( - config, - format!("exec_path: {:?}, argv[0]: {:?}", exec_path, argv0.as_ref()) - ); - // TODO: we should determine program_name from argv0 somewhere in this file - - // Detect if we're running right out of the CMAKE build directory - if exec_path.starts_with(env!("CARGO_MANIFEST_DIR")) { - let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); - FLOG!( - config, - "Running out of target directory, using paths relative to CARGO_MANIFEST_DIR:\n", - manifest_dir.display() - ); - done = true; - paths = ConfigPaths { - data: manifest_dir.join("share"), - sysconf: manifest_dir.join("etc"), - doc: manifest_dir.join("user_doc/html"), - bin: Some(exec_path.parent().unwrap().to_owned()), - } - } - - if !done { - // The next check is that we are in a relocatable directory tree - if exec_path.ends_with("bin/fish") { - let base_path = exec_path.parent().unwrap().parent().unwrap(); - paths = ConfigPaths { - // One obvious path is ~/.local (with fish in ~/.local/bin/). - // If we picked ~/.local/share/fish as our data path, - // we would install there and erase history. - // So let's isolate us a bit more. - #[cfg(feature = "installable")] - data: base_path.join("share/fish/install"), - #[cfg(not(feature = "installable"))] - data: base_path.join("share/fish"), - sysconf: base_path.join("etc/fish"), - doc: base_path.join("share/doc/fish"), - bin: Some(base_path.join("bin")), - } - } else if exec_path.ends_with("fish") { - FLOG!( - config, - "'fish' not in a 'bin/', trying paths relative to source tree" - ); - let base_path = exec_path.parent().unwrap(); - paths = ConfigPaths { - #[cfg(feature = "installable")] - data: base_path.join("share/install"), - #[cfg(not(feature = "installable"))] - data: base_path.join("share"), - sysconf: base_path.join("etc"), - doc: base_path.join("user_doc/html"), - bin: Some(base_path.to_path_buf()), - } - } - - if paths.data.exists() && paths.sysconf.exists() { - // The docs dir may not exist; in that case fall back to the compiled in path. - if !paths.doc.exists() { - paths.doc = PathBuf::from(DOC_DIR); - } - done = true; - } - } - } - - if !done { - // Fall back to what got compiled in. - let data = if cfg!(feature = "installable") { - let Some(home) = fish::env::get_home() else { - FLOG!( - error, - "Cannot find home directory and will refuse to read configuration.\n", - "Consider installing into a directory tree with `fish --install=PATH`." - ); - return paths; - }; - - PathBuf::from(home).join(DATA_DIR).join(DATA_DIR_SUBDIR) - } else { - Path::new(DATA_DIR).join(DATA_DIR_SUBDIR) - }; - let bin = if cfg!(feature = "installable") { - exec_path.parent().map(|x| x.to_path_buf()) - } else { - Some(PathBuf::from(BIN_DIR)) - }; - - FLOG!(config, "Using compiled in paths:"); - paths = ConfigPaths { - data, - sysconf: Path::new(SYSCONF_DIR).join("fish"), - doc: DOC_DIR.into(), - bin, - } - } - - FLOGF!( - config, - "determine_config_directory_paths() results:\npaths.data: %ls\npaths.sysconf: \ - %ls\npaths.doc: %ls\npaths.bin: %ls", - paths.data.display().to_string(), - paths.sysconf.display().to_string(), - paths.doc.display().to_string(), - paths - .bin - .clone() - .map(|x| x.display().to_string()) - .unwrap_or("|not found|".to_string()), - ); - - paths -} - // Source the file config.fish in the given directory. // Returns true if successful, false if not. fn source_config_in_directory(parser: &Parser, dir: &wstr) -> bool { @@ -418,6 +293,7 @@ fn source_config_in_directory(parser: &Parser, dir: &wstr) -> bool { #[cfg(feature = "installable")] fn check_version_file(paths: &ConfigPaths, datapath: &wstr) -> Option { + use crate::common::wcs2osstring; // (false-positive, is_none_or is a backport, this builds with 1.70) #[allow(clippy::incompatible_msrv)] if paths @@ -463,7 +339,7 @@ fn read_init(parser: &Parser, paths: &ConfigPaths) { ); } - install(true, PathBuf::from(wcs2osstring(&datapath))); + install(true, &PathBuf::from(wcs2osstring(&datapath))); // We try to go on if installation failed (or was rejected) here // If the assets are missing, we will trigger a later error, // if they are outdated, things will probably (tm) work somewhat. @@ -592,8 +468,9 @@ fn fish_parse_opt(args: &mut [WString], opts: &mut FishCmdOpts) -> ControlFlow ControlFlow opts.is_login = true, @@ -817,13 +692,12 @@ fn throwing_main() -> i32 { save_term_foreground_process_group(); } + let mut paths: Option<&ConfigPaths> = None; // If we're not executing, there's no need to find the config. - let paths: Option = if !opts.no_exec { - let paths = Some(determine_config_directory_paths(OsString::from_vec( - wcs2string(&args[0]), - ))); + if !opts.no_exec { + paths = Some(&*CONFIG_PATHS); env_init( - paths.as_ref(), + paths, /* do uvars */ !opts.no_config, /* default paths */ opts.no_config, ); diff --git a/src/env/config_paths.rs b/src/env/config_paths.rs new file mode 100644 index 000000000..c05f766f2 --- /dev/null +++ b/src/env/config_paths.rs @@ -0,0 +1,147 @@ +use super::ConfigPaths; +use crate::env; +use crate::{common::get_executable_path, FLOG, FLOGF}; +use once_cell::sync::Lazy; +use std::path::{Path, PathBuf}; + +const DOC_DIR: &str = env!("DOCDIR"); +const DATA_DIR: &str = env!("DATADIR"); +const DATA_DIR_SUBDIR: &str = env!("DATADIR_SUBDIR"); +const SYSCONF_DIR: &str = env!("SYSCONFDIR"); +const BIN_DIR: &str = env!("BINDIR"); + +pub static CONFIG_PATHS: Lazy = Lazy::new(|| { + // Read the current executable and follow all symlinks to it. + // OpenBSD has issues with `std::env::current_exe`, see gh-9086 and + // https://github.com/rust-lang/rust/issues/60560 + let argv0 = PathBuf::from(std::env::args().next().unwrap()); + let argv0 = if argv0.exists() { + argv0 + } else { + std::env::current_exe().unwrap_or(argv0) + }; + let argv0 = argv0.canonicalize().unwrap_or(argv0); + determine_config_directory_paths(argv0) +}); + +fn determine_config_directory_paths(argv0: impl AsRef) -> ConfigPaths { + // PORTING: why is this not just an associated method on ConfigPaths? + + let mut paths = ConfigPaths::default(); + let mut done = false; + let exec_path = get_executable_path(argv0.as_ref()); + if let Ok(exec_path) = exec_path.canonicalize() { + FLOG!( + config, + format!("exec_path: {:?}, argv[0]: {:?}", exec_path, argv0.as_ref()) + ); + // TODO: we should determine program_name from argv0 somewhere in this file + + // Detect if we're running right out of the CMAKE build directory + if exec_path.starts_with(env!("CARGO_MANIFEST_DIR")) { + let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); + FLOG!( + config, + "Running out of target directory, using paths relative to CARGO_MANIFEST_DIR:\n", + manifest_dir.display() + ); + done = true; + paths = ConfigPaths { + data: manifest_dir.join("share"), + sysconf: manifest_dir.join("etc"), + doc: manifest_dir.join("user_doc/html"), + bin: Some(exec_path.parent().unwrap().to_owned()), + } + } + + if !done { + // The next check is that we are in a relocatable directory tree + if exec_path.ends_with("bin/fish") { + let base_path = exec_path.parent().unwrap().parent().unwrap(); + paths = ConfigPaths { + // One obvious path is ~/.local (with fish in ~/.local/bin/). + // If we picked ~/.local/share/fish as our data path, + // we would install there and erase history. + // So let's isolate us a bit more. + #[cfg(feature = "installable")] + data: base_path.join("share/fish/install"), + #[cfg(not(feature = "installable"))] + data: base_path.join("share/fish"), + sysconf: base_path.join("etc/fish"), + doc: base_path.join("share/doc/fish"), + bin: Some(base_path.join("bin")), + } + } else if exec_path.ends_with("fish") { + FLOG!( + config, + "'fish' not in a 'bin/', trying paths relative to source tree" + ); + let base_path = exec_path.parent().unwrap(); + paths = ConfigPaths { + #[cfg(feature = "installable")] + data: base_path.join("share/install"), + #[cfg(not(feature = "installable"))] + data: base_path.join("share"), + sysconf: base_path.join("etc"), + doc: base_path.join("user_doc/html"), + bin: Some(base_path.to_path_buf()), + } + } + + if paths.data.exists() && paths.sysconf.exists() { + // The docs dir may not exist; in that case fall back to the compiled in path. + if !paths.doc.exists() { + paths.doc = PathBuf::from(DOC_DIR); + } + done = true; + } + } + } + + if !done { + // Fall back to what got compiled in. + let data = if cfg!(feature = "installable") { + let Some(home) = env::get_home() else { + FLOG!( + error, + "Cannot find home directory and will refuse to read configuration.\n", + "Consider installing into a directory tree with `fish --install=PATH`." + ); + return paths; + }; + + PathBuf::from(home).join(DATA_DIR).join(DATA_DIR_SUBDIR) + } else { + Path::new(DATA_DIR).join(DATA_DIR_SUBDIR) + }; + let bin = if cfg!(feature = "installable") { + exec_path.parent().map(|x| x.to_path_buf()) + } else { + Some(PathBuf::from(BIN_DIR)) + }; + + FLOG!(config, "Using compiled in paths:"); + paths = ConfigPaths { + data, + sysconf: Path::new(SYSCONF_DIR).join("fish"), + doc: DOC_DIR.into(), + bin, + } + } + + FLOGF!( + config, + "determine_config_directory_paths() results:\npaths.data: %ls\npaths.sysconf: \ + %ls\npaths.doc: %ls\npaths.bin: %ls", + paths.data.display().to_string(), + paths.sysconf.display().to_string(), + paths.doc.display().to_string(), + paths + .bin + .clone() + .map(|x| x.display().to_string()) + .unwrap_or("|not found|".to_string()), + ); + + paths +} diff --git a/src/env/mod.rs b/src/env/mod.rs index 3f7f7213a..eea119918 100644 --- a/src/env/mod.rs +++ b/src/env/mod.rs @@ -1,6 +1,8 @@ +mod config_paths; pub mod environment; mod environment_impl; pub mod var; +pub use config_paths::CONFIG_PATHS; use crate::common::ToCString; pub use environment::*; From 6fd0025f3891d78aa67a640c775bb48f0fa572d7 Mon Sep 17 00:00:00 2001 From: Johannes Altmanninger Date: Fri, 13 Jun 2025 11:40:48 +0200 Subject: [PATCH 41/70] Make LOCALEDIR relocatable as well As explained in c3740b85be4 (config_paths: fix compiled-in locale dir, 2025-06-12), fish is "relocatable", i.e. "mv /usr/ /usr2/" will leave "/usr2/bin/fish" fully functional. There is one exception: for LOCALEDIR we always use the path determined at compile time. This seems wrong; let's use the same relocatable-logic as for other paths. Inspired by bf65b9e3a74 (Change `gettext` paths to be relocatable (#11195), 2025-03-30). --- src/env/config_paths.rs | 38 +++++++++++++++++++++++++++++--------- src/env/var.rs | 9 +++++---- src/wutil/gettext.rs | 11 +++++++++-- 3 files changed, 43 insertions(+), 15 deletions(-) diff --git a/src/env/config_paths.rs b/src/env/config_paths.rs index c05f766f2..47851b262 100644 --- a/src/env/config_paths.rs +++ b/src/env/config_paths.rs @@ -9,6 +9,7 @@ const DATA_DIR_SUBDIR: &str = env!("DATADIR_SUBDIR"); const SYSCONF_DIR: &str = env!("SYSCONFDIR"); const BIN_DIR: &str = env!("BINDIR"); +const LOCALE_DIR: &str = env!("LOCALEDIR"); pub static CONFIG_PATHS: Lazy = Lazy::new(|| { // Read the current executable and follow all symlinks to it. @@ -51,6 +52,7 @@ fn determine_config_directory_paths(argv0: impl AsRef) -> ConfigPaths { sysconf: manifest_dir.join("etc"), doc: manifest_dir.join("user_doc/html"), bin: Some(exec_path.parent().unwrap().to_owned()), + locale: Some(manifest_dir.join("share/locale")), } } @@ -58,18 +60,21 @@ fn determine_config_directory_paths(argv0: impl AsRef) -> ConfigPaths { // The next check is that we are in a relocatable directory tree if exec_path.ends_with("bin/fish") { let base_path = exec_path.parent().unwrap().parent().unwrap(); + #[cfg(feature = "installable")] + let data = base_path.join("share/fish/install"); + #[cfg(not(feature = "installable"))] + let data = base_path.join("share/fish"); + let locale = Some(data.join("locale")); paths = ConfigPaths { // One obvious path is ~/.local (with fish in ~/.local/bin/). // If we picked ~/.local/share/fish as our data path, // we would install there and erase history. // So let's isolate us a bit more. - #[cfg(feature = "installable")] - data: base_path.join("share/fish/install"), - #[cfg(not(feature = "installable"))] - data: base_path.join("share/fish"), + data, sysconf: base_path.join("etc/fish"), doc: base_path.join("share/doc/fish"), bin: Some(base_path.join("bin")), + locale, } } else if exec_path.ends_with("fish") { FLOG!( @@ -77,14 +82,18 @@ fn determine_config_directory_paths(argv0: impl AsRef) -> ConfigPaths { "'fish' not in a 'bin/', trying paths relative to source tree" ); let base_path = exec_path.parent().unwrap(); + #[cfg(feature = "installable")] + let data = base_path.join("share/install"); + #[cfg(not(feature = "installable"))] + let data = base_path.join("share"); + let locale = Some(data.join("locale")); + paths = ConfigPaths { - #[cfg(feature = "installable")] - data: base_path.join("share/install"), - #[cfg(not(feature = "installable"))] - data: base_path.join("share"), + data, sysconf: base_path.join("etc"), doc: base_path.join("user_doc/html"), bin: Some(base_path.to_path_buf()), + locale, } } @@ -119,6 +128,11 @@ fn determine_config_directory_paths(argv0: impl AsRef) -> ConfigPaths { } else { Some(PathBuf::from(BIN_DIR)) }; + let locale = if cfg!(feature = "installable") { + None + } else { + Some(PathBuf::from(LOCALE_DIR)) + }; FLOG!(config, "Using compiled in paths:"); paths = ConfigPaths { @@ -126,13 +140,14 @@ fn determine_config_directory_paths(argv0: impl AsRef) -> ConfigPaths { sysconf: Path::new(SYSCONF_DIR).join("fish"), doc: DOC_DIR.into(), bin, + locale, } } FLOGF!( config, "determine_config_directory_paths() results:\npaths.data: %ls\npaths.sysconf: \ - %ls\npaths.doc: %ls\npaths.bin: %ls", + %ls\npaths.doc: %ls\npaths.bin: %ls\npaths.locale: %ls", paths.data.display().to_string(), paths.sysconf.display().to_string(), paths.doc.display().to_string(), @@ -141,6 +156,11 @@ fn determine_config_directory_paths(argv0: impl AsRef) -> ConfigPaths { .clone() .map(|x| x.display().to_string()) .unwrap_or("|not found|".to_string()), + paths + .locale + .clone() + .map(|x| x.display().to_string()) + .unwrap_or("|not found|".to_string()), ); paths diff --git a/src/env/var.rs b/src/env/var.rs index 9e8f10e62..1eefb9905 100644 --- a/src/env/var.rs +++ b/src/env/var.rs @@ -50,10 +50,11 @@ fn from(val: EnvMode) -> Self { /// env_init. #[derive(Default)] pub struct ConfigPaths { - pub data: PathBuf, // e.g., /usr/local/share - pub sysconf: PathBuf, // e.g., /usr/local/etc - pub doc: PathBuf, // e.g., /usr/local/share/doc/fish - pub bin: Option, // e.g., /usr/local/bin + pub data: PathBuf, // e.g., /usr/local/share + pub sysconf: PathBuf, // e.g., /usr/local/etc + pub doc: PathBuf, // e.g., /usr/local/share/doc/fish + pub bin: Option, // e.g., /usr/local/bin + pub locale: Option, // e.g., /usr/local/share/locale } /// A collection of status and pipestatus. diff --git a/src/wutil/gettext.rs b/src/wutil/gettext.rs index ab968ed34..4f934c282 100644 --- a/src/wutil/gettext.rs +++ b/src/wutil/gettext.rs @@ -1,11 +1,15 @@ use std::collections::HashMap; use std::ffi::CString; +use std::os::unix::ffi::OsStrExt; use std::sync::Mutex; -use crate::common::{charptr2wcstring, truncate_at_nul, wcs2zstring, PACKAGE_NAME}; #[cfg(test)] use crate::tests::prelude::*; use crate::wchar::prelude::*; +use crate::{ + common::{charptr2wcstring, truncate_at_nul, wcs2zstring, PACKAGE_NAME}, + env::CONFIG_PATHS, +}; use errno::{errno, set_errno}; use once_cell::sync::{Lazy, OnceCell}; @@ -47,8 +51,11 @@ pub fn fish_textdomain(_domainname: &CStr) -> *mut c_char { // Really init wgettext. fn wgettext_really_init() { + let Some(ref localepath) = CONFIG_PATHS.locale else { + return; + }; let package_name = CString::new(PACKAGE_NAME).unwrap(); - let localedir = CString::new(env!("LOCALEDIR")).unwrap(); + let localedir = CString::new(localepath.as_os_str().as_bytes()).unwrap(); fish_bindtextdomain(&package_name, &localedir); fish_textdomain(&package_name); } From 4be17bfefbdb1ef6ff6a9ea5d9c751d824142c04 Mon Sep 17 00:00:00 2001 From: Johannes Altmanninger Date: Fri, 13 Jun 2025 12:17:04 +0200 Subject: [PATCH 42/70] fixup! Extract config path module. NFC Fix cargo (non-cmake) build. --- src/bin/fish.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/bin/fish.rs b/src/bin/fish.rs index 2f3fc928b..4e9f8bd97 100644 --- a/src/bin/fish.rs +++ b/src/bin/fish.rs @@ -22,7 +22,7 @@ #![allow(clippy::uninlined_format_args)] #[cfg(feature = "installable")] -use fish::common::get_executable_path; +use fish::common::{get_executable_path, wcs2osstring}; #[allow(unused_imports)] use fish::future::IsSomeAnd; use fish::{ @@ -293,7 +293,6 @@ fn source_config_in_directory(parser: &Parser, dir: &wstr) -> bool { #[cfg(feature = "installable")] fn check_version_file(paths: &ConfigPaths, datapath: &wstr) -> Option { - use crate::common::wcs2osstring; // (false-positive, is_none_or is a backport, this builds with 1.70) #[allow(clippy::incompatible_msrv)] if paths From f3ebc68d5d03ff7c5f187b58f408529192637777 Mon Sep 17 00:00:00 2001 From: Peter Ammon Date: Thu, 19 Jun 2025 15:07:58 -0700 Subject: [PATCH 43/70] Correct statvfs call to statfs This was missed in the Rust port - C++ had statfs for MNT_LOCAL and not statvfs. The effect of this is that fish never thought its filesystem was local on macOS or BSDs (Linux was OK). This caused history race tests to fail, and also could in rare cases result in history items being dropped with multiple concurrent sessions. This fixes the history race tests under macOS and FreeBSD - we weren't locking because we thought the history was a remote file. Cherry-picked from ba00d721f495a6b132b4565e846c820ebf3d4cfa --- src/path.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/path.rs b/src/path.rs index f4b80cbfd..4737c41f0 100644 --- a/src/path.rs +++ b/src/path.rs @@ -713,14 +713,14 @@ fn path_remoteness(path: &wstr) -> DirRemoteness { // In practice the only system to define it is NetBSD. let local_flag = ST_LOCAL() | MNT_LOCAL(); if local_flag != 0 { - let mut buf: libc::statvfs = unsafe { std::mem::zeroed() }; - if unsafe { libc::statvfs(narrow.as_ptr(), &mut buf) } < 0 { + let mut buf: libc::statfs = unsafe { std::mem::zeroed() }; + if unsafe { libc::statfs(narrow.as_ptr(), &mut buf) } < 0 { return DirRemoteness::unknown; } // statfs::f_flag is hard-coded as 64-bits on 32/64-bit FreeBSD but it's a (4-byte) // long on 32-bit NetBSD.. and always 4-bytes on macOS (even on 64-bit builds). #[allow(clippy::useless_conversion)] - return if u64::from(buf.f_flag) & local_flag != 0 { + return if u64::from(buf.f_flags) & local_flag != 0 { DirRemoteness::local } else { DirRemoteness::remote From 6e9e33d81d2d99817130743af327f9d1d2a56b02 Mon Sep 17 00:00:00 2001 From: Johannes Altmanninger Date: Sat, 21 Jun 2025 17:23:49 +0200 Subject: [PATCH 44/70] __fish_complete_list: strip "--foo=" prefix from replacing completions Given a command line like foo --foo=bar=baz=qux\=argument (the behavior is the same if '=' is substituted with ':'). fish completes arguments starting from the last unescaped separator, i.e. foo --foo=bar=baz=qux\=argument ^ __fish_complete_list provides completions like printf %s\n (commandline -t)(printf %s\n choice1 choice2 ...) This means that completions include the "--foo=bar=baz=" prefix. This is wrong. This wasn't a problem until commit f9febba (Fix replacing completions with a -foo prefix, 2024-12-14), because prior to that, replacing completions would replace the entire token. This made it too hard to writ ecompletions like complete -c foo -s s -l long -xa "hello-world goodbye-friend" that would work with "foo --long fri" as well as "foo --long=frie". Replacing the entire token would only work if the completion included that prefix, but the above command is supposed to just work. So f9febba made us replace only the part after the separator. Unfortunately that caused the earlier problem. Work around this. The change is not pretty, but it's a compromise until we have a better way of telling which character fish considers to be the separator. Fixes #11508 (cherry picked from commit 320ebb6859dfb0235cffb0f0a0fa27722afdd7b9) --- share/completions/clj.fish | 8 ++++---- share/completions/equery.fish | 2 +- share/completions/gpasswd.fish | 4 ++-- share/completions/guild.fish | 2 +- share/completions/guile.fish | 2 +- share/completions/hashcat.fish | 4 ++-- share/completions/john.fish | 8 ++++---- share/completions/losetup.fish | 2 +- share/completions/lpadmin.fish | 8 ++++---- share/completions/lsblk.fish | 2 +- share/completions/lsof.fish | 6 +++--- share/completions/ncat.fish | 2 +- share/completions/nmap.fish | 4 ++-- share/completions/ps.fish | 14 +++++++------- share/completions/setxkbmap.fish | 2 +- share/completions/ssh.fish | 2 +- share/completions/su.fish | 2 +- share/completions/systemd-cryptenroll.fish | 2 +- share/completions/usermod.fish | 2 +- share/completions/xbps-query.fish | 2 +- share/functions/__fish_complete_list.fish | 4 +++- share/functions/__fish_complete_pgrep.fish | 10 +++++----- share/functions/__fish_complete_ssh.fish | 2 +- tests/checks/complete.fish | 12 ++++++++++++ 24 files changed, 61 insertions(+), 47 deletions(-) diff --git a/share/completions/clj.fish b/share/completions/clj.fish index 90a0dbf44..cc491b172 100644 --- a/share/completions/clj.fish +++ b/share/completions/clj.fish @@ -49,10 +49,10 @@ function __fish_clj_tools -V bb_helper bb -e "$bb_helper" tools end -complete -c clj -s X -x -r -k -a "(__fish_complete_list : __fish_clj_aliases)" -d "Use concatenated aliases to modify classpath or supply exec fn/args" -complete -c clj -s A -x -r -k -a "(__fish_complete_list : __fish_clj_aliases)" -d "Use concatenated aliases to modify classpath" -complete -c clj -s M -x -r -k -a "(__fish_complete_list : __fish_clj_aliases)" -d "Use concatenated aliases to modify classpath or supply main opts" -complete -c clj -s T -x -r -k -a "(__fish_complete_list : __fish_clj_tools)" -d "Invoke tool by name or via aliases ala -X" +complete -c clj -s X -x -r -k -a "(__fish_stripprefix='^-\w*X' __fish_complete_list : __fish_clj_aliases)" -d "Use concatenated aliases to modify classpath or supply exec fn/args" +complete -c clj -s A -x -r -k -a "(__fish_stripprefix='^-\w*A' __fish_complete_list : __fish_clj_aliases)" -d "Use concatenated aliases to modify classpath" +complete -c clj -s M -x -r -k -a "(__fish_stripprefix='^-\w*M' __fish_complete_list : __fish_clj_aliases)" -d "Use concatenated aliases to modify classpath or supply main opts" +complete -c clj -s T -x -r -k -a "(__fish_stripprefix='^-\w*T' __fish_complete_list : __fish_clj_tools)" -d "Invoke tool by name or via aliases ala -X" complete -c clj -f -o Sdeps -r -d "Deps data to use as the last deps file to be merged" complete -c clj -f -o Spath -d "Compute classpath and echo to stdout only" diff --git a/share/completions/equery.fish b/share/completions/equery.fish index 184d84564..e2cfb5b08 100644 --- a/share/completions/equery.fish +++ b/share/completions/equery.fish @@ -85,7 +85,7 @@ complete -c equery -n '__fish_seen_subcommand_from f files' -s s -l timestamp -d complete -c equery -n '__fish_seen_subcommand_from f files' -s t -l type -d "Include file type in output" complete -c equery -n '__fish_seen_subcommand_from f files' -l tree -d "Display results in a tree" complete -c equery -n '__fish_seen_subcommand_from f files' -s f -l filter -d "Filter output by file type" \ - -xa "(__fish_complete_list , __fish_equery_files_filter_args)" + -xa "(__fish_stripprefix='^(--filter=|-\w*f)' __fish_complete_list , __fish_equery_files_filter_args)" # has + hasuse complete -c equery -n '__fish_seen_subcommand_from a has h hasuse' -s I -l exclude-installed -d "Exclude installed pkgs from search path" diff --git a/share/completions/gpasswd.fish b/share/completions/gpasswd.fish index 96b32b652..cea9f6a18 100644 --- a/share/completions/gpasswd.fish +++ b/share/completions/gpasswd.fish @@ -4,5 +4,5 @@ complete -c gpasswd -s d -l delete -d 'Remove user from group' -xa '(__fish_comp complete -c gpasswd -s h -l help -d 'Print help' complete -c gpasswd -s r -l remove-password -d 'Remove the GROUP\'s password' complete -c gpasswd -s R -l restrict -d 'Restrict access to GROUP to its members' -complete -c gpasswd -s M -l members -d 'Set the list of members of GROUP' -xa '(__fish_complete_list , __fish_complete_users)' -complete -c gpasswd -s A -l administrators -d 'set the list of administrators for GROUP' -xa '(__fish_complete_list , __fish_complete_users)' +complete -c gpasswd -s M -l members -d 'Set the list of members of GROUP' -xa "(__fish_stripprefix='^(--members=|-\w*M)' __fish_complete_list , __fish_complete_users)" +complete -c gpasswd -s A -l administrators -d 'set the list of administrators for GROUP' -xa "(__fish_stripprefix='^(--administrators=|-\w*A)' __fish_complete_list , __fish_complete_users)" diff --git a/share/completions/guild.fish b/share/completions/guild.fish index 019704d33..351a8e190 100644 --- a/share/completions/guild.fish +++ b/share/completions/guild.fish @@ -36,7 +36,7 @@ complete -c $command -s x -x \ -n $compile_condition complete -c $command -s W -l warning \ - -a '(__fish_complete_list , __fish_guild__complete_warnings)' \ + -a "(__fish_stripprefix='^(--warning=|-\w*W)' __fish_complete_list , __fish_guild__complete_warnings)" \ -d 'Specify the warning level for a compilation' \ -n $compile_condition diff --git a/share/completions/guile.fish b/share/completions/guile.fish index 2165a6a14..1bc14ecd7 100644 --- a/share/completions/guile.fish +++ b/share/completions/guile.fish @@ -85,7 +85,7 @@ complete -c $command -o ds \ -d 'Treat the last -s option as if it occurred at this point' complete -c $command -l use-srfi \ - -a '(__fish_complete_list , __fish_guile__complete_srfis)' \ + -a "(__fish_stripprefix='^--use-srfi=' __fish_complete_list , __fish_guile__complete_srfis)" \ -d 'Specify the SRFI modules to load' for standard in 6 7 diff --git a/share/completions/hashcat.fish b/share/completions/hashcat.fish index 0c28ca709..2b79604ba 100644 --- a/share/completions/hashcat.fish +++ b/share/completions/hashcat.fish @@ -58,7 +58,7 @@ complete -c hashcat -l restore -d "Restore session from --session" complete -c hashcat -l restore-disable -d "Do not write restore file" complete -c hashcat -l restore-file-path -rF -d "Specific path to restore file" complete -c hashcat -s o -l outfile -rF -d "Define outfile for recovered hash" -complete -c hashcat -l outfile-format -xa "(__fish_complete_list , __fish_hashcat_outfile_formats)" -d "Outfile formats to use" +complete -c hashcat -l outfile-format -xa "(__fish_stripprefix='^--outfile-format=' __fish_complete_list , __fish_hashcat_outfile_formats)" -d "Outfile formats to use" complete -c hashcat -l outfile-autohex-disable -d "Disable the use of \$HEX[] in output plains" complete -c hashcat -l outfile-check-timer -x -d "Sets seconds between outfile checks" complete -c hashcat -l wordlist-autohex-disable -d "Disable the conversion of \$HEX[] from the wordlist" @@ -106,7 +106,7 @@ complete -c hashcat -l backend-ignore-metal -d "Do not try to open Metal interfa complete -c hashcat -l backend-ignore-opencl -d "Do not try to open OpenCL interface on startup" complete -c hashcat -s I -l backend-info -d "Show info about detected backend API devices" complete -c hashcat -s d -l backend-devices -x -d "Backend devices to use" -complete -c hashcat -s D -l opencl-device-types -xa "(__fish_complete_list , __fish_hashcat_device_types)" -d "OpenCL device-types to use" +complete -c hashcat -s D -l opencl-device-types -xa "(__fish_stripprefix='^(--opencl-device-types=|-\w*D)' __fish_complete_list , __fish_hashcat_device_types)" -d "OpenCL device-types to use" complete -c hashcat -s O -l optimized-kernel-enable -d "Enable optimized kernels (limits password length)" complete -c hashcat -s M -l multiply-accel-disable -d "Disable multiply kernel-accel with processor count" complete -c hashcat -s w -l workload-profile -d "Enable a specific workload profile" -xa " diff --git a/share/completions/john.fish b/share/completions/john.fish index 4b8626368..7a8d77ae0 100644 --- a/share/completions/john.fish +++ b/share/completions/john.fish @@ -9,7 +9,7 @@ function __fish_john_formats --description "Print JohnTheRipper hash formats" end complete -c john -l help -d "print usage summary" -complete -c john -l single -fa "(__fish_complete_list , __fish_john_rules)" -d "single crack mode" +complete -c john -l single -fa "(__fish_stripprefix='^--single=' __fish_complete_list , __fish_john_rules)" -d "single crack mode" complete -c john -l single-seed -rf -d "add static seed word(s) for all salts in single mode" complete -c john -l single-wordlist -rF -d "short wordlist with static seed words/morphemes" complete -c john -l single-user-seed -rF -d "wordlist with seeds per username" @@ -35,8 +35,8 @@ complete -c john -l prince-case-permute -d "permute case of first letter" complete -c john -l prince-mmap -d "memory-map infile" complete -c john -l prince-keyspace -d "just show total keyspace that would be produced" complete -c john -l encoding -l input-encoding -fa "$__fish_john_encodings" -d "input encoding" -complete -c john -l rules -fa "(__fish_complete_list , __fish_john_rules)" -d "enable word mangling rules" -complete -c john -l rules-stack -fa "(__fish_complete_list , __fish_john_rules)" -d "stacked rules" +complete -c john -l rules -fa "(__fish_stripprefix='^--rules=' __fish_complete_list , __fish_john_rules)" -d "enable word mangling rules" +complete -c john -l rules-stack -fa "(__fish_stripprefix='^--rules-stack=' __fish_complete_list , __fish_john_rules)" -d "stacked rules" complete -c john -l rules-skip-nop -d "skip any NOP rules" complete -c john -l incremental -fa "(john --list=inc-modes 2>/dev/null)" -d "incremental mode" complete -c john -l incremental-charcount -rf -d "override CharCount for incremental mode" @@ -97,4 +97,4 @@ complete -c john -l internal-codepage -fa "$__fish_john_encodings" -d "codepage complete -c john -l target-encoding -fa "$__fish_john_encodings" -d "output encoding" complete -c john -l tune -fa "auto report N" -d "tuning options" complete -c john -l force-tty -d "set up terminal for reading keystrokes" -complete -c john -l format -fa "(__fish_complete_list , __fish_john_formats)" -d "force hash type" +complete -c john -l format -fa "(__fish_stripprefix='^--format=' __fish_complete_list , __fish_john_formats)" -d "force hash type" diff --git a/share/completions/losetup.fish b/share/completions/losetup.fish index 0a57c7370..046f9f910 100644 --- a/share/completions/losetup.fish +++ b/share/completions/losetup.fish @@ -41,7 +41,7 @@ complete -c losetup -s v -l verbose -d "Verbose mode" complete -c losetup -s J -l json -d "Use JSON --list output format" complete -c losetup -s l -l list -d "List info about all or specified" complete -c losetup -s n -l noheadings -d "Don't print headings for --list output" -complete -c losetup -s O -l output -x -a "(__fish_complete_list , __fish_print_losetup_list_output)" -d "Specify columns to output for --list" +complete -c losetup -s O -l output -x -a "(__fish_stripprefix='^(--output=|-\w*O)' __fish_complete_list , __fish_print_losetup_list_output)" -d "Specify columns to output for --list" complete -c losetup -l output-all -d "Output all columns" complete -c losetup -l raw -d "Use raw --list output format" complete -c losetup -s h -l help -d "Display help" diff --git a/share/completions/lpadmin.fish b/share/completions/lpadmin.fish index 844b958c9..3858b194a 100644 --- a/share/completions/lpadmin.fish +++ b/share/completions/lpadmin.fish @@ -25,7 +25,7 @@ complete -c lpadmin -s o -xa printer-is-shared=true -d 'Sets dest to shared/publ complete -c lpadmin -s o -xa printer-is-shared=false -d 'Sets dest to shared/published or unshared/unpublished' complete -c lpadmin -s o -d 'Set IPP operation policy associated with dest' -xa "printer-policy=(test -r /etc/cups/cupsd.conf; and string replace -r --filter '' '$1' < /etc/cups/cupsd.conf)" -complete -c lpadmin -s u -xa 'allow:all allow:none (__fish_complete_list , __fish_complete_users allow:)' -d 'Sets user-level access control on a destination' -complete -c lpadmin -s u -xa '(__fish_complete_list , __fish_complete_groups allow: @)' -d 'Sets user-level access control on a destination' -complete -c lpadmin -s u -xa 'deny:all deny:none (__fish_complete_list , __fish_complete_users deny:)' -d 'Sets user-level access control on a destination' -complete -c lpadmin -s u -xa '(__fish_complete_list , __fish_complete_groups deny: @)' -d 'Sets user-level access control on a destination' +complete -c lpadmin -s u -xa "allow:all allow:none (__fish_stripprefix='^-\w*u' __fish_complete_list , __fish_complete_users allow:)" -d 'Sets user-level access control on a destination' +complete -c lpadmin -s u -xa "(__fish_stripprefix='^-\w*u' __fish_complete_list , __fish_complete_groups allow: @)" -d 'Sets user-level access control on a destination' +complete -c lpadmin -s u -xa "deny:all deny:none (__fish_stripprefix='^-\w*u' __fish_complete_list , __fish_complete_users deny:)" -d 'Sets user-level access control on a destination' +complete -c lpadmin -s u -xa "(__fish_stripprefix='^-\w*u' __fish_complete_list , __fish_complete_groups deny: @)" -d 'Sets user-level access control on a destination' diff --git a/share/completions/lsblk.fish b/share/completions/lsblk.fish index 21fab0c0a..3d8284f04 100644 --- a/share/completions/lsblk.fish +++ b/share/completions/lsblk.fish @@ -12,7 +12,7 @@ complete -c lsblk -s h -l help -d "usage information (this)" complete -c lsblk -s i -l ascii -d "use ascii characters only" complete -c lsblk -s m -l perms -d "output info about permissions" complete -c lsblk -s n -l noheadings -d "don't print headings" -complete -c lsblk -s o -l output -d "output columns" -xa '( __fish_complete_list , __fish_print_lsblk_columns )' +complete -c lsblk -s o -l output -d "output columns" -xa "(__fish_stripprefix='^(--output=|-\w*o)' __fish_complete_list , __fish_print_lsblk_columns)" complete -c lsblk -s P -l pairs -d "use key='value' output format" complete -c lsblk -s r -l raw -d "use raw output format" complete -c lsblk -s t -l topology -d "output info about topology" diff --git a/share/completions/lsof.fish b/share/completions/lsof.fish index 4695e9a77..37452305f 100644 --- a/share/completions/lsof.fish +++ b/share/completions/lsof.fish @@ -11,9 +11,9 @@ i\t"ignore the device cache file" r\t"read the device cache file" u\t"read and update the device cache file"' -complete -c lsof -s g -d 'select by group (^ - negates)' -xa '(__fish_complete_list , __fish_complete_groups)' +complete -c lsof -s g -d 'select by group (^ - negates)' -xa "(__fish_stripprefix='^-\w*g' __fish_complete_list , __fish_complete_groups)" complete -c lsof -s l -d 'Convert UIDs to login names' -complete -c lsof -s p -d 'Select or exclude processes by pid' -xa '(__fish_complete_list , __fish_complete_pids)' +complete -c lsof -s p -d 'Select or exclude processes by pid' -xa "(__fish_stripprefix='^-\w*p' __fish_complete_list , __fish_complete_pids)" complete -c lsof -s R -d 'Print PPID' complete -c lsof -s t -d 'Produce terse output (pids only, no header)' -complete -c lsof -s u -d 'select by user (^ - negates)' -xa '(__fish_complete_list , __fish_complete_users)' +complete -c lsof -s u -d 'select by user (^ - negates)' -xa "(__fish_stripprefix='^-\w*u' __fish_complete_list , __fish_complete_users)" diff --git a/share/completions/ncat.fish b/share/completions/ncat.fish index 7a34087dd..8a23d2120 100644 --- a/share/completions/ncat.fish +++ b/share/completions/ncat.fish @@ -35,7 +35,7 @@ function __fish_complete_openssl_ciphers printf "%s\tCipher String\n" $cs end end -complete -c ncat -l ssl-ciphers -x -a "(__fish_complete_list : __fish_complete_openssl_ciphers)" -d "Specify SSL ciphersuites" +complete -c ncat -l ssl-ciphers -x -a "(__fish_stripprefix='^--ssl-ciphers=' __fish_complete_list : __fish_complete_openssl_ciphers)" -d "Specify SSL ciphersuites" complete -c ncat -l ssl-servername -x -a "(__fish_print_hostnames)" -d "Request distinct server name" complete -c ncat -l ssl-alpn -x -d "Specify ALPN protocol list" diff --git a/share/completions/nmap.fish b/share/completions/nmap.fish index 213a9f4d9..0f4265b6f 100644 --- a/share/completions/nmap.fish +++ b/share/completions/nmap.fish @@ -92,11 +92,11 @@ function __fish_complete_nmap_script end echo -e $__fish_nmap_script_completion_cache end -complete -c nmap -l script -r -a "(__fish_complete_list , __fish_complete_nmap_script)" +complete -c nmap -l script -r -a "(__fish_stripprefix='^--script=' __fish_complete_list , __fish_complete_nmap_script)" complete -c nmap -l script -r -d 'Runs a script scan' complete -c nmap -l script-args -d 'provide arguments to NSE scripts' complete -c nmap -l script-args-file -r -d 'load arguments to NSE scripts from a file' -complete -c nmap -l script-help -r -a "(__fish_complete_list , __fish_complete_nmap_script)" +complete -c nmap -l script-help -r -a "(__fish_stripprefix='^--script-help=' __fish_complete_list , __fish_complete_nmap_script)" complete -c nmap -l script-help -r -d "Shows help about scripts" complete -c nmap -l script-trace complete -c nmap -l script-updatedb diff --git a/share/completions/ps.fish b/share/completions/ps.fish index 37ee5637d..1eecc563a 100644 --- a/share/completions/ps.fish +++ b/share/completions/ps.fish @@ -10,7 +10,7 @@ if test "$gnu_linux" -eq 1 # Some short options are GNU-only complete -c ps -s a -d "Select all processes except session leaders and terminal-less" complete -c ps -s A -d "Select all" - complete -c ps -s C -d "Select by command" -ra '(__fish_complete_list , __fish_complete_proc)' + complete -c ps -s C -d "Select by command" -ra "(__fish_stripprefix='^-\w*C' __fish_complete_list , __fish_complete_proc)" complete -c ps -s c -d 'Show different scheduler information for the -l option' complete -c ps -s d -d "Select all processes except session leaders" complete -c ps -s e -d "Select all" @@ -24,9 +24,9 @@ if test "$gnu_linux" -eq 1 complete -c ps -s m -d 'Show threads after processes' complete -c ps -s N -d "Invert selection" complete -c ps -s n -d "Set namelist file" -r - complete -c ps -s s -l sid -d "Select by session ID" -x -a "(__fish_complete_list , __fish_complete_pids)" + complete -c ps -s s -l sid -d "Select by session ID" -x -a "(__fish_stripprefix='^(--sid=|-\w*s)' __fish_complete_list , __fish_complete_pids)" complete -c ps -s T -d "Show threads. With SPID" - complete -c ps -s u -l user -d "Select by user" -x -a "(__fish_complete_list , __fish_complete_users)" + complete -c ps -s u -l user -d "Select by user" -x -a "(__fish_stripprefix='^(--script=|-\w*u)' __fish_complete_list , __fish_complete_users)" complete -c ps -s V -l version -d "Display version and exit" complete -c ps -s y -d "Do not show flags" @@ -39,7 +39,7 @@ if test "$gnu_linux" -eq 1 complete -c ps -l info -d "Display debug info" complete -c ps -l lines -l rows -d "Set screen height" -r complete -c ps -l no-headers -d 'Print no headers' - complete -c ps -l ppid -d "Select by parent PID" -x -a "(__fish_complete_list , __fish_complete_pids)" + complete -c ps -l ppid -d "Select by parent PID" -x -a "(__fish_stripprefix='^--ppid=' __fish_complete_list , __fish_complete_pids)" complete -c ps -l sort -d 'Specify sort order' -r else # Assume BSD options otherwise @@ -81,6 +81,6 @@ end complete -c ps -s o -lformat$bsd_null -d "User defined format" -x complete -c ps -s Z -lcontext$bsd_null -d "Include security info" complete -c ps -s t -ltty$bsd_null -d "Select by tty" -r -complete -c ps -s G -lgroup$bsd_null -d "Select by group" -x -a "(__fish_complete_list , __fish_complete_groups)" -complete -c ps -s U -luser$bsd_null -d "Select by user" -x -a "(__fish_complete_list , __fish_complete_users)" -complete -c ps -s p -lpid$bsd_null -d "Select by PID" -x -a "(__fish_complete_list , __fish_complete_pids)" +complete -c ps -s G -lgroup$bsd_null -d "Select by group" -x -a "(__fish_stripprefix='^(--group=|-\w*G)' __fish_complete_list , __fish_complete_groups)" +complete -c ps -s U -luser$bsd_null -d "Select by user" -x -a "(__fish_stripprefix='^(--user=|-\w*U)' __fish_complete_list , __fish_complete_users)" +complete -c ps -s p -lpid$bsd_null -d "Select by PID" -x -a "(__fish_stripprefix='^(--pid=|-\w*p)' __fish_complete_list , __fish_complete_pids)" diff --git a/share/completions/setxkbmap.fish b/share/completions/setxkbmap.fish index 96aeed77d..79e0b325f 100644 --- a/share/completions/setxkbmap.fish +++ b/share/completions/setxkbmap.fish @@ -15,7 +15,7 @@ complete -c setxkbmap -o keycodes -d 'Specifies keycodes component name' -xa "(s complete -c setxkbmap -o keymap -d 'Specifies name of keymap to load' -xa "(sed -r $filter /usr/share/X11/xkb/keymap.dir)" complete -c setxkbmap -o layout -d 'Specifies layout used to choose component names' -xa "(__fish_complete_setxkbmap layout)" complete -c setxkbmap -o model -d 'Specifies model used to choose component names' -xa "(__fish_complete_setxkbmap model)" -complete -c setxkbmap -o option -d 'Adds an option used to choose component names' -xa "(__fish_complete_list , '__fish_complete_setxkbmap option')" +complete -c setxkbmap -o option -d 'Adds an option used to choose component names' -xa "(__fish_stripprefix='^--option=' __fish_complete_list , '__fish_complete_setxkbmap option')" complete -c setxkbmap -o print -d 'Print a complete xkb_keymap description and exit' complete -c setxkbmap -o query -d 'Print the current layout settings and exit' complete -c setxkbmap -o rules -d 'Name of rules file to use' -x diff --git a/share/completions/ssh.fish b/share/completions/ssh.fish index 9caa53f28..d5707e44d 100644 --- a/share/completions/ssh.fish +++ b/share/completions/ssh.fish @@ -25,7 +25,7 @@ complete -c ssh -s k -d "Disables forwarding of GSSAPI credentials" complete -c ssh -s L -d "Specify local port forwarding" -x complete -c ssh -s l -x -a "(__fish_complete_users)" -d User complete -c ssh -s M -d "Places the ssh client into master mode" -complete -c ssh -s m -d "MAC algorithm" -xa "(__fish_complete_list , __fish_ssh_macs)" +complete -c ssh -s m -d "MAC algorithm" -xa "(__fish_stripprefix='^-\w*m' __fish_complete_list , __fish_ssh_macs)" complete -c ssh -s N -d "Do not execute remote command" complete -c ssh -s n -d "Prevent reading from stdin" complete -c ssh -s O -d "Control an active connection multiplexing master process" -x diff --git a/share/completions/su.fish b/share/completions/su.fish index ec2a3cd62..fbc902bd7 100644 --- a/share/completions/su.fish +++ b/share/completions/su.fish @@ -12,6 +12,6 @@ complete -c su -s G -l supp-group -x -a "(__fish_complete_groups)" -d "Specify a complete -c su -s m -s p -l preserve_environment -d "Preserve environment" complete -c su -s P -l pty -d "Create pseudo-terminal for the session" complete -c su -s s -l shell -x -a "(cat /etc/shells)" -d "Run the specified shell" -complete -c su -s w -l whitelist-environment -x -a "(__fish_complete_list , __fish_complete_su_env_whitelist)" -d "Don't reset these environment variables" +complete -c su -s w -l whitelist-environment -x -a "(__fish_stripprefix='^(--whitelist-environment=|-\w*w)' __fish_complete_list , __fish_complete_su_env_whitelist)" -d "Don't reset these environment variables" complete -c su -s h -l help -d "Display help and exit" complete -c su -s V -l version -d "Display version and exit" diff --git a/share/completions/systemd-cryptenroll.fish b/share/completions/systemd-cryptenroll.fish index 076d4c6d0..d143fac48 100644 --- a/share/completions/systemd-cryptenroll.fish +++ b/share/completions/systemd-cryptenroll.fish @@ -40,6 +40,6 @@ complete -c systemd-cryptenroll -l fido2-with-user-presence -xa "yes no" -d "Req complete -c systemd-cryptenroll -l fido2-with-user-verification -xa "yes no" -d "Require user verification when unlocking the volume" complete -c systemd-cryptenroll -l tpm2-device -kxa "(__fish_cryptenroll_tpm2_devices)" -d "Enroll a TPM2 security chip" complete -c systemd-cryptenroll -l tpm2-pcrs -x -d "Bind the enrollment of TPM2 device to speficied PCRs" -complete -c systemd-cryptenroll -l wipe-slot -kxa "(__fish_complete_list , __fish_cryptenroll_complete_wipe)" -d "Wipes one or more LUKS2 key slots" +complete -c systemd-cryptenroll -l wipe-slot -kxa "(__fish_stripprefix='^--wipe-slot=' __fish_complete_list , __fish_cryptenroll_complete_wipe)" -d "Wipes one or more LUKS2 key slots" complete -c systemd-cryptenroll -l help -s h -d "Print a short help" complete -c systemd-cryptenroll -l version -d "Print a short version string" diff --git a/share/completions/usermod.fish b/share/completions/usermod.fish index f457e9b6a..b0cce3a8c 100644 --- a/share/completions/usermod.fish +++ b/share/completions/usermod.fish @@ -5,7 +5,7 @@ complete -c usermod -s d -l home -d "Change user's login directory" -r complete -c usermod -s e -l expiredate -d "Date (YYYY-MM-DD) on which the user account will be disabled" -x complete -c usermod -s f -l inactive -d "Number of days after a password expires until the account is locked" -xa "(seq 0 365)" complete -c usermod -s g -l gid -d "Group name or number of the user's new initial login group" -xa "(__fish_complete_groups)" -complete -c usermod -s G -l groups -d "List of groups which the user is also a member of" -xa "(__fish_complete_list , __fish_complete_groups)" +complete -c usermod -s G -l groups -d "List of groups which the user is also a member of" -xa "(__fish_stripprefix='^(--groups=|-\w*G)' __fish_complete_list , __fish_complete_groups)" complete -c usermod -s l -l login -d "Change user's name" -x complete -c usermod -s L -l lock -d "Lock user's password" -f complete -c usermod -s m -l move-home -d "Move the content of the user's home directory to the new location" -f diff --git a/share/completions/xbps-query.fish b/share/completions/xbps-query.fish index ef614e68d..c69495210 100644 --- a/share/completions/xbps-query.fish +++ b/share/completions/xbps-query.fish @@ -50,7 +50,7 @@ complete -c $progname -s d -d 'Enable extra debugging shown to stderr' complete -c $progname -s h -d 'Show the help message' complete -c $progname -s i -d 'Ignore repositories defined in configuration files' complete -c $progname -s M -d 'For remote repositories, the data is fetched and stored in memory only' -complete -c $progname -s p -d 'Match one or more package properties' -xa "(__fish_complete_list , __fish_print_xbps_pkg_props)" +complete -c $progname -s p -d 'Match one or more package properties' -xa "(__fish_stripprefix='^-\w*p' __fish_complete_list , __fish_print_xbps_pkg_props)" complete -c $progname -s R -d 'Enable repository mode' complete -c $progname -l repository -d 'Append the specified repository to the top of the list' complete -c $progname -l regex -d 'Use Extended Regular Expressions' diff --git a/share/functions/__fish_complete_list.fish b/share/functions/__fish_complete_list.fish index 2d55c535e..758489572 100644 --- a/share/functions/__fish_complete_list.fish +++ b/share/functions/__fish_complete_list.fish @@ -14,7 +14,9 @@ where: set -q prefix[1] or set -l prefix "" set -l pat "$(commandline -t)" - #set -l pat $argv[5] + if set -q __fish_stripprefix[1] + set pat "$(string replace -r -- "$__fish_stripprefix" "" $pat)" + end switch $pat case "*$div*" for i in (echo $pat | sed "s/^\(.\+$div\)$iprefix.*\$/\1/")$iprefix(eval $cmd) diff --git a/share/functions/__fish_complete_pgrep.fish b/share/functions/__fish_complete_pgrep.fish index b74b7dc39..5b632a632 100644 --- a/share/functions/__fish_complete_pgrep.fish +++ b/share/functions/__fish_complete_pgrep.fish @@ -1,15 +1,15 @@ function __fish_complete_pgrep -d 'Complete pgrep/pkill' --argument-names cmd complete -c $cmd -xa '(__fish_complete_proc)' complete -c $cmd -s f -d 'Match pattern against full command line' - complete -c $cmd -s g -d 'Only match processes in the process group' -xa '(__fish_complete_list , __fish_complete_groups)' - complete -c $cmd -s G -d "Only match processes whose real group ID is listed. Group 0 is translated into $cmd\'s own process group" -xa '(__fish_complete_list , __fish_complete_groups)' + complete -c $cmd -s g -d 'Only match processes in the process group' -xa "(__fish_stripprefix='^-\w*g' __fish_complete_list , __fish_complete_groups)" + complete -c $cmd -s G -d "Only match processes whose real group ID is listed. Group 0 is translated into $cmd\'s own process group" -xa "(__fish_stripprefix='^-\w*G' __fish_complete_list , __fish_complete_groups)" complete -c $cmd -s n -d 'Select only the newest process' complete -c $cmd -s o -d 'Select only the oldest process' - complete -c $cmd -s P -d 'Only match processes whose parent process ID is listed' -xa '(__fish_complete_list , __fish_complete_pids)' + complete -c $cmd -s P -d 'Only match processes whose parent process ID is listed' -xa "(__fish_stripprefix='^-\w*P' __fish_complete_list , __fish_complete_pids)" complete -c $cmd -s s -d "Only match processes whose process session ID is listed. Session ID 0 is translated into $cmd\'s own session ID." complete -c $cmd -s t -d 'Only match processes whose controlling terminal is listed. The terminal name should be specified without the "/dev/" prefix' -r - complete -c $cmd -s u -d 'Only match processes whose effective user ID is listed' -xa '(__fish_complete_list , __fish_complete_users)' - complete -c $cmd -s U -d 'Only match processes whose real user ID is listed' -xa '(__fish_complete_list , __fish_complete_users)' + complete -c $cmd -s u -d 'Only match processes whose effective user ID is listed' -xa "(__fish_stripprefix='^-\w*u' __fish_complete_list , __fish_complete_users)" + complete -c $cmd -s U -d 'Only match processes whose real user ID is listed' -xa "(__fish_stripprefix='^-\w*U' __fish_complete_list , __fish_complete_users)" complete -c $cmd -s v -d 'Negates the matching' complete -c $cmd -s x -d ' Only match processes whose name (or command line if -f is specified) exactly match the pattern' end diff --git a/share/functions/__fish_complete_ssh.fish b/share/functions/__fish_complete_ssh.fish index a628429a0..69d7a5c8e 100644 --- a/share/functions/__fish_complete_ssh.fish +++ b/share/functions/__fish_complete_ssh.fish @@ -3,7 +3,7 @@ function __fish_complete_ssh -d "common completions for ssh commands" --argument complete -c $command -s 6 -d "IPv6 only" complete -c $command -s A -d "Enables forwarding of the authentication agent" complete -c $command -s C -d "Compress all data" - complete -c $command -s c -d "Encryption algorithm" -xa "(__fish_complete_list , __fish_ssh_ciphers)" + complete -c $command -s c -d "Encryption algorithm" -xa "(__fish_stripprefix='^-\w*c' __fish_complete_list , __fish_ssh_ciphers)" complete -c $command -s F -d "Configuration file" -rF complete -c $command -s i -d "Identity key file" -rF complete -c $command -s J -d 'ProxyJump host' -xa "(__fish_complete_user_at_hosts)" diff --git a/tests/checks/complete.fish b/tests/checks/complete.fish index 9ea39f260..fad13e362 100644 --- a/tests/checks/complete.fish +++ b/tests/checks/complete.fish @@ -627,3 +627,15 @@ complete -C'testcommand ' # CHECK: check{{\t}}Check the frobnicator # CHECK: search{{\t}}Search for frobs # CHECK: show{{\t}}Show all frobs + +complete complete-list -xa '(__fish_complete_list , "seq 2")' +complete -C "complete-list 1," +# CHECK: 1,1 +# CHECK: 1,2 +complete complete-list -s l -l number-list -xa '(__fish_stripprefix="^(--number-list=|-\w*l)" __fish_complete_list , "seq 2")' +complete -C "complete-list --number-list=1," +# CHECK: --number-list=1,1 +# CHECK: --number-list=1,2 +complete -C "complete-list -abcl1," +# CHECK: -abcl1,1 +# CHECK: -abcl1,2 From ec66749369682a72c13cb775d10c61b628a76945 Mon Sep 17 00:00:00 2001 From: Johannes Altmanninger Date: Sat, 21 Jun 2025 18:29:31 +0200 Subject: [PATCH 45/70] __fish_complete_list: only unescape "$(commandline -t)" Commit cd3da62d244 (fix(completion): unescape strings for __fish_complete_list, 2024-09-17) bravely addressed an issue that exists in a lot of completions. It did so only for __fish_complete_list. Fair enough. Unfortunately it unescaped more than just "$(commandline -t)". This causes the problem described at https://github.com/fish-shell/fish-shell/issues/11508#issuecomment-2889088934 where completion descriptions containing a backslash followed by "n" are interpreted as newlines, breaking the completion parser. Fix that. (cherry picked from commit 60881f11959d2af067612e33c09e6db2e65dd38d) --- share/functions/__fish_complete_list.fish | 6 +++--- tests/checks/complete.fish | 7 +++++++ 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/share/functions/__fish_complete_list.fish b/share/functions/__fish_complete_list.fish index 758489572..b2e5c88f3 100644 --- a/share/functions/__fish_complete_list.fish +++ b/share/functions/__fish_complete_list.fish @@ -19,12 +19,12 @@ where: end switch $pat case "*$div*" - for i in (echo $pat | sed "s/^\(.\+$div\)$iprefix.*\$/\1/")$iprefix(eval $cmd) - string unescape -- $i + for i in (string unescape -- $pat | sed "s/^\(.\+$div\)$iprefix.*\$/\1/")$iprefix(eval $cmd) + printf %s\n $i end case '*' for i in $prefix$iprefix(eval $cmd) - string unescape -- $i + printf %s\n $i end end diff --git a/tests/checks/complete.fish b/tests/checks/complete.fish index fad13e362..e68d2d8c0 100644 --- a/tests/checks/complete.fish +++ b/tests/checks/complete.fish @@ -639,3 +639,10 @@ complete -C "complete-list --number-list=1," complete -C "complete-list -abcl1," # CHECK: -abcl1,1 # CHECK: -abcl1,2 + +function esc_in_description + echo completion\t'escaped \n newline' +end +complete complete-list -l desc -xa '(__fish_complete_list , esc_in_description)' +complete -C 'complete-list --desc ' +# CHECK: completion{{\t}}escaped {{\\n}} newline From 335f91babdc476b87ab6c8f63b9f87c517a3952d Mon Sep 17 00:00:00 2001 From: Johannes Altmanninger Date: Wed, 18 Jun 2025 23:02:54 +0200 Subject: [PATCH 46/70] completions/git: fix spurious error when no subcommand is in $PATH Systems like NixOS might not have "git-receive-pack" or any other "git-*" executable in in $PATH -- instead they patch git to use absolute paths. This is weird. But no reason for us to fail. Silence the error. Fixes #11590 (cherry picked from commit 4f46d369c4e9d7ea2f76290c6cb3a0882014eb4a) --- share/completions/git.fish | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/share/completions/git.fish b/share/completions/git.fish index be512f750..e0d20a186 100644 --- a/share/completions/git.fish +++ b/share/completions/git.fish @@ -779,7 +779,8 @@ function __fish_git_custom_commands # if any of these completion results match the name of the builtin git commands, # but it's simpler just to blacklist these names. They're unlikely to change, # and the failure mode is we accidentally complete a plumbing command. - for name in (string replace -r "^.*/git-([^/]*)" '$1' $PATH/git-*) + set -l git_subcommands $PATH/git-* + for name in (string replace -r "^.*/git-([^/]*)" '$1' $git_subcommands) switch $name case cvsserver receive-pack shell upload-archive upload-pack # skip these @@ -2594,7 +2595,8 @@ end # source git-* commands' autocompletion file if exists set -l __fish_git_custom_commands_completion -for file in (path filter -xZ $PATH/git-* | path basename) +set -l git_subcommands $PATH/git-* +for file in (path filter -xZ $git_subcommands | path basename) # Already seen this command earlier in $PATH. contains -- $file $__fish_git_custom_commands_completion and continue From 1ceebdf580496724f75c7aa3c8318af997b3782d Mon Sep 17 00:00:00 2001 From: Johannes Altmanninger Date: Wed, 25 Jun 2025 13:33:27 +0200 Subject: [PATCH 47/70] Fix some CSI commands being sent to old midnight commander Commit 97581ed20ff (Do send bracketed paste inside midnight commander, 2024-10-12) accidentally started sending CSI commands such as "CSI >5;0m", which we intentionally didn't do for some old versions of Midnight Commander, which fail to parse them. Fix that. Fixes #11617 --- src/input_common.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/input_common.rs b/src/input_common.rs index 865c39904..b1766f22f 100644 --- a/src/input_common.rs +++ b/src/input_common.rs @@ -710,7 +710,9 @@ pub(crate) fn terminal_protocols_disable_ifn() { if !TERMINAL_PROTOCOLS.load(Ordering::Acquire) { return; } - let sequences = if !feature_test(FeatureFlag::keyboard_protocols) { + let sequences = if !feature_test(FeatureFlag::keyboard_protocols) + || IN_MIDNIGHT_COMMANDER_PRE_CSI_U.load() + { "\x1b[?2004l" } else if IN_JETBRAINS.load() || IN_ITERM_PRE_CSI_U.load() { concat!("\x1b[?2004l", "\x1b[>4;0m", "\x1b>",) From eecf0814a1d46f4c024d0c029b5590ec921ed117 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=AE=87=E9=80=B8?= Date: Mon, 19 May 2025 20:52:55 +0800 Subject: [PATCH 48/70] Use uninit instead of zeroed (cherry-pikcked only the change to src/path.rs) (cherry picked from commit 7c2c7f5874e077834e5c9c5866b089dcc480af1e) --- src/path.rs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/path.rs b/src/path.rs index 4737c41f0..7886c6399 100644 --- a/src/path.rs +++ b/src/path.rs @@ -15,6 +15,7 @@ use once_cell::sync::Lazy; use std::ffi::OsStr; use std::io::ErrorKind; +use std::mem::MaybeUninit; use std::os::unix::prelude::*; /// Returns the user configuration directory for fish. If the directory or one of its parents @@ -676,10 +677,11 @@ fn path_remoteness(path: &wstr) -> DirRemoteness { let narrow = wcs2zstring(path); #[cfg(target_os = "linux")] { - let mut buf: libc::statfs = unsafe { std::mem::zeroed() }; - if unsafe { libc::statfs(narrow.as_ptr(), &mut buf) } < 0 { + let mut buf = MaybeUninit::uninit(); + if unsafe { libc::statfs(narrow.as_ptr(), buf.as_mut_ptr()) } < 0 { return DirRemoteness::unknown; } + let buf = unsafe { buf.assume_init() }; // Linux has constants for these like NFS_SUPER_MAGIC, SMB_SUPER_MAGIC, CIFS_MAGIC_NUMBER but // these are in varying headers. Simply hard code them. // Note that we treat FUSE filesystems as remote, which means we lock less on such filesystems. @@ -713,10 +715,11 @@ fn path_remoteness(path: &wstr) -> DirRemoteness { // In practice the only system to define it is NetBSD. let local_flag = ST_LOCAL() | MNT_LOCAL(); if local_flag != 0 { - let mut buf: libc::statfs = unsafe { std::mem::zeroed() }; - if unsafe { libc::statfs(narrow.as_ptr(), &mut buf) } < 0 { + let mut buf = MaybeUninit::uninit(); + if unsafe { libc::statfs(narrow.as_ptr(), buf.as_mut_ptr()) } < 0 { return DirRemoteness::unknown; } + let buf = unsafe { buf.assume_init() }; // statfs::f_flag is hard-coded as 64-bits on 32/64-bit FreeBSD but it's a (4-byte) // long on 32-bit NetBSD.. and always 4-bytes on macOS (even on 64-bit builds). #[allow(clippy::useless_conversion)] From 9af33802ec107ecc2ce9fcf8db4f4034d5474e30 Mon Sep 17 00:00:00 2001 From: Johannes Altmanninger Date: Sat, 21 Jun 2025 12:25:02 +0200 Subject: [PATCH 49/70] Use statvfs on NetBSD again to fix build From commit ba00d721f49 (Correct statvfs call to statfs, 2025-06-19): > This was missed in the Rust port To elaborate: - ec176dc07e7 (Port path.h, 2023-04-09) didn't change this (as before, `statvfs` used `ST_LOCAL` and `statfs` used `MNT_LOCAL`) - 6877773fdd4 (Fix build on NetBSD (#10270), 2024-01-28) changed the `statvfs` call to `statfs`, presumably due to the libc-wrapper for `statvfs` being missing on NetBSD. This change happens to work fine on NetBSD because they do [`#define ST_LOCAL MNT_LOCAL`](https://github.com/fish-shell/fish-shell/pull/11486#discussion_r2092408952) But it was wrong on others like macOS and FreeBSD, which was fixed by ba00d721f49 (but that broke the build on NetBSD). - 7228cb15bfa (Include sys/statvfs.h for the definition of ST_LOCAL (Rust port regression), 2025-05-16) fixed a code clone left behind by the above commit (incorrectly assuming that the clone had always existed.) Fix the NetBSD build specifically by using statfs on that platform. Note that this still doesn't make the behavior equivalent to commit LastC++11. That one used ST_LOCAL if defined, and otherwise MNT_LOCAL if defined. If we want perfect equivalence, we could detect both flags in `src/build.rs`. Then we would also build on operating systems that define neither. Not sure. Closes #11596 (cherry picked from commit 6644cc9b0e29841e3d0a85fbc672a95c4a2fd000) --- src/path.rs | 42 ++++++++++++++++++++++++++++++++---------- 1 file changed, 32 insertions(+), 10 deletions(-) diff --git a/src/path.rs b/src/path.rs index 7886c6399..35a742919 100644 --- a/src/path.rs +++ b/src/path.rs @@ -6,8 +6,6 @@ use crate::env::{EnvMode, EnvStack, Environment}; use crate::expand::{expand_tilde, HOME_DIRECTORY}; use crate::flog::{FLOG, FLOGF}; -#[cfg(not(target_os = "linux"))] -use crate::libc::{MNT_LOCAL, ST_LOCAL}; use crate::wchar::prelude::*; use crate::wutil::{normalize_path, path_normalize_for_cd, waccess, wdirname, wstat}; use errno::{errno, set_errno, Errno}; @@ -711,25 +709,49 @@ fn path_remoteness(path: &wstr) -> DirRemoteness { } #[cfg(not(target_os = "linux"))] { - // ST_LOCAL is a flag to statvfs, which is itself standardized. - // In practice the only system to define it is NetBSD. - let local_flag = ST_LOCAL() | MNT_LOCAL(); - if local_flag != 0 { + fn remoteness_via_statfs( + statfn: unsafe extern "C" fn(*const i8, *mut StatFS) -> libc::c_int, + flagsfn: fn(&StatFS) -> Flags, + is_local_flag: u64, + path: &std::ffi::CStr, + ) -> DirRemoteness + where + u64: From, + { + if is_local_flag == 0 { + return DirRemoteness::unknown; + } let mut buf = MaybeUninit::uninit(); - if unsafe { libc::statfs(narrow.as_ptr(), buf.as_mut_ptr()) } < 0 { + if unsafe { (statfn)(path.as_ptr(), buf.as_mut_ptr()) } < 0 { return DirRemoteness::unknown; } let buf = unsafe { buf.assume_init() }; // statfs::f_flag is hard-coded as 64-bits on 32/64-bit FreeBSD but it's a (4-byte) // long on 32-bit NetBSD.. and always 4-bytes on macOS (even on 64-bit builds). #[allow(clippy::useless_conversion)] - return if u64::from(buf.f_flags) & local_flag != 0 { + if u64::from((flagsfn)(&buf)) & is_local_flag != 0 { DirRemoteness::local } else { DirRemoteness::remote - }; + } } - DirRemoteness::unknown + // ST_LOCAL is a flag to statvfs, which is itself standardized. + // In practice the only system to define it is NetBSD. + #[cfg(target_os = "netbsd")] + let remoteness = remoteness_via_statfs( + libc::statvfs, + |stat: &libc::statvfs| stat.f_flag, + crate::libc::ST_LOCAL(), + &narrow, + ); + #[cfg(not(target_os = "netbsd"))] + let remoteness = remoteness_via_statfs( + libc::statfs, + |stat: &libc::statfs| stat.f_flags, + crate::libc::MNT_LOCAL(), + &narrow, + ); + remoteness } } From e204a4c1267df755b33ec796333dfd70f8e7d356 Mon Sep 17 00:00:00 2001 From: Johannes Altmanninger Date: Sat, 21 Jun 2025 14:06:37 +0200 Subject: [PATCH 50/70] Add ctrl-alt-h compatibility binding Historically, ctrl-i sends the same code as tab, ctrl-h sends backspace and ctrl-j and ctrl-m behave like enter. Even for terminals that send unambiguous encodings (via the kitty keyboard protocol), we have kept bindings like ctrl-h, to support existing habits. We forgot that pressing alt-ctrl-h would behave like alt-backspace (and can be easier to reach) so maybe we should add that as well. Don't add ctrl-shift-i because at least on Linux, that's usually intercepted by the terminal emulator. Technically there are some more such as "ctrl-2" (which used to do the same as "ctrl-space") but I don't think anyone uses that over "ctrl-space". Closes #https://github.com/fish-shell/fish-shell/discussions/11548 (cherry picked from commit 4d67ca7c58a9c4889b561ad54c67b15d808e0659) --- share/functions/fish_default_key_bindings.fish | 1 + 1 file changed, 1 insertion(+) diff --git a/share/functions/fish_default_key_bindings.fish b/share/functions/fish_default_key_bindings.fish index e00e38be6..60cefe6f1 100644 --- a/share/functions/fish_default_key_bindings.fish +++ b/share/functions/fish_default_key_bindings.fish @@ -58,6 +58,7 @@ function fish_default_key_bindings -d "emacs-like key binds" bind --preset $argv alt-c capitalize-word bind --preset $argv alt-backspace backward-kill-word + bind --preset $argv ctrl-alt-h backward-kill-word bind --preset $argv ctrl-backspace backward-kill-word bind --preset $argv ctrl-delete kill-word From c7d4acbef8ca13f6bbac20b2105a54076879b55e Mon Sep 17 00:00:00 2001 From: Johannes Altmanninger Date: Sun, 29 Jun 2025 16:01:19 +0200 Subject: [PATCH 51/70] Update changelog --- CHANGELOG.rst | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 30319501a..e9c0ed40f 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -4,6 +4,33 @@ fish 4.0.3 (released ???) This release of fish fixes a number of issues identified in fish 4.0.2: - fish now properly inherits $PATH under Windows WSL2 (:issue:`11354`). +- the :doc:`read ` builtin no longer miscalculates width of multi-byte characters (:issue:`11412`). +- A crash displaying multi-line quoted command substitutions has been fixed (:issue:`11444`). +- Remote filesystems are detected properly again on non-Linux systems. +- builtin :doc:`status ` no longer prints a trailing blank line. +- A workaround has been added to prevent spurious output inside old versions of Midnight Commander. +- :kbd:`ctrl-alt-h` erases the last word again (:issue:`11548`). +- :kbd:`alt-left` :kbd:`alt-right` send unexpected sequences on some terminals; a workaround has been added. (:issue:`11479`). +- fish no longer interprets invalid terminal control sequences as if they were :kbd:`alt-[` or :kbd:`alt-o` key strokes. +- :doc:`bind ` has been taught about the :kbd:`printscreen` and :kbd:`menu` keys. +- Key bindings like ``bind shift-A`` are no longer accepted; use ``bind shift-a`` or ``bind A``. +- Key bindings like ``bind shift-a`` take precedence over ``bind A``, assuming that the key event included the shift modifier. +- Bindings using shift with non-ASCII letters (such as :kbd:`ctrl-shift-Γ€`) are now supported. +- Bindings with modifiers such as ``bind ctrl-w`` work again on non-Latin keyboard layouts such as a Russian one. + This is implemented by allowing key events such as :kbd:`ctrl-Ρ†` to match bindings of the corresponding Latin key, using the kitty keyboard protocol's base layout key (:issue:`11520`). +- For a long time now, fish has been "relocatable" -- it was possible to move the entire ``CMAKE_INSTALL_PREFIX`` and fish would use paths relative to its binary. + Only gettext locale paths were still purely determined at compile time, which has been fixed. +- ``set fish_complete_path ...`` accidentally disabled completion autoloading, which has been corrected. +- ``nmcli`` completions have been fixed to query network information dynamically instead of only when completing the first time. +- Git completions no longer print an error when no `git-*` executable is in :envvar:`PATH`. +- the :doc:`commandline ` builtin fails to print the commandline set by a ``commandline -C`` invocation, which broken some completion scripts. + This has been corrected (:issue:`11423`). +- Custom completions like ``complete foo -l long -xa ...`` that use the output of ``commandline -t``. + on a command-line like ``foo --long=`` have been invalidated by a change in 4.0; the completion scripts have been adjusted accordingly (:issue:`11508`). +- Some completions were misinterpreted, which broke the completion list. This has been fixed. +- The routines to save history and universal variables have seen some robustness improvements. +- Vi mode: The cursor position after pasting via :kbd:`p` has been corrected. +- Vi mode: Trying to replace the last character via :kbd:`r` no longer replaces the last-but-one character (:issue:`11484`), fish 4.0.2 (released April 20, 2025) ==================================== From 3fada8055358124faab4e17f98339902e7dd4a2e Mon Sep 17 00:00:00 2001 From: Johannes Altmanninger Date: Fri, 25 Jul 2025 11:30:24 +0200 Subject: [PATCH 52/70] Fix regression causing \e[ to be interpreted as ctrl-[ Fixes 3201cb9f012 (Stop parsing invalid CSI/SS3 sequences as alt-[/alt-o, 2024-12-30). (cherry picked from commit 43d583d991685698978920ec7deaab55c00a20a8) --- src/input_common.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/input_common.rs b/src/input_common.rs index b1766f22f..ce319348c 100644 --- a/src/input_common.rs +++ b/src/input_common.rs @@ -991,7 +991,7 @@ fn parse_csi(&mut self, buffer: &mut Vec) -> Option { // The maximum number of CSI parameters is defined by NPAR, nominally 16. let mut params = [[0_u32; 4]; 16]; let Some(mut c) = self.try_readb(buffer) else { - return Some(KeyEvent::from(ctrl('['))); + return Some(KeyEvent::from(alt('['))); }; let mut next_char = |zelf: &mut Self| zelf.try_readb(buffer).unwrap_or(0xff); let private_mode; From 0e7c7f1745ab0b873cf02837af8c93e00e803374 Mon Sep 17 00:00:00 2001 From: Johannes Altmanninger Date: Fri, 25 Jul 2025 11:54:42 +0200 Subject: [PATCH 53/70] Remove unused import (cherry picked from commit 07ff4e7df065e97a008b1b2995169bd61219662e) --- src/input_common.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/input_common.rs b/src/input_common.rs index ce319348c..1be6aafb0 100644 --- a/src/input_common.rs +++ b/src/input_common.rs @@ -11,7 +11,7 @@ use crate::future_feature_flags::{feature_test, FeatureFlag}; use crate::global_safety::RelaxedAtomicBool; use crate::key::{ - self, alt, canonicalize_control_char, canonicalize_keyed_control_char, char_to_symbol, ctrl, + self, alt, canonicalize_control_char, canonicalize_keyed_control_char, char_to_symbol, function_key, shift, Key, Modifiers, }; use crate::reader::{reader_current_data, reader_test_and_clear_interrupted}; From d2af306f3d06983b5746a93a5517d0b7db3a70d5 Mon Sep 17 00:00:00 2001 From: Johannes Altmanninger Date: Fri, 25 Jul 2025 18:10:52 +0200 Subject: [PATCH 54/70] Fix some unused-ControlFlow warnings --- src/ast.rs | 7 ++++--- src/reader.rs | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/ast.rs b/src/ast.rs index 04654f908..d07345519 100644 --- a/src/ast.rs +++ b/src/ast.rs @@ -656,7 +656,8 @@ fn index_mut(&mut self, index: usize) -> &mut Self::Output { impl Acceptor for $name { #[allow(unused_variables)] fn accept<'a>(&'a self, visitor: &mut dyn NodeVisitor<'a>, reversed: bool) { - accept_list_visitor!(Self, accept, visit, self, visitor, reversed, $contents); + let _ = + accept_list_visitor!(Self, accept, visit, self, visitor, reversed, $contents); } } impl AcceptorMut for $name { @@ -743,7 +744,7 @@ macro_rules! implement_acceptor_for_branch { impl Acceptor for $name { #[allow(unused_variables)] fn accept<'a>(&'a self, visitor: &mut dyn NodeVisitor<'a>, reversed: bool){ - visitor_accept_field!( + let _ = visitor_accept_field!( Self, accept, visit, @@ -3718,7 +3719,7 @@ fn allocate(&self) -> Box { // Return the resulting Node pointer. It is never null. fn allocate_visit(&mut self) -> Box { let mut result = Box::::default(); - self.visit_mut(&mut *result); + let _ = self.visit_mut(&mut *result); result } diff --git a/src/reader.rs b/src/reader.rs index d62be6d47..68b450871 100644 --- a/src/reader.rs +++ b/src/reader.rs @@ -980,7 +980,7 @@ pub fn reader_execute_readline_cmd(parser: &Parser, ch: CharEvent) { data.rls = Some(ReadlineLoopState::new()); } data.save_screen_state(); - data.handle_char_event(Some(ch)); + let _ = data.handle_char_event(Some(ch)); } } From e274ff41d0338788972252a0d240752d676fb04b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=AE=87=E9=80=B8?= Date: Mon, 19 May 2025 20:52:55 +0800 Subject: [PATCH 55/70] Use uninit instead of zeroed (src/input_common.rs) (cherry picked from commit 7c2c7f5874e077834e5c9c5866b089dcc480af1e) --- src/input_common.rs | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/input_common.rs b/src/input_common.rs index 1be6aafb0..ddfe17a3f 100644 --- a/src/input_common.rs +++ b/src/input_common.rs @@ -21,6 +21,7 @@ use crate::wutil::encoding::{mbrtowc, mbstate_t, zero_mbstate}; use crate::wutil::fish_wcstol; use std::collections::VecDeque; +use std::mem::MaybeUninit; use std::os::fd::RawFd; use std::os::unix::ffi::OsStrExt; use std::ptr; @@ -1316,8 +1317,8 @@ fn readch_timed(&mut self, wait_time_ms: usize) -> Option { // We are not prepared to handle a signal immediately; we only want to know if we get input on // our fd before the timeout. Use pselect to block all signals; we will handle signals // before the next call to readch(). - let mut sigs: libc::sigset_t = unsafe { std::mem::zeroed() }; - unsafe { libc::sigfillset(&mut sigs) }; + let mut sigs = MaybeUninit::uninit(); + unsafe { libc::sigfillset(sigs.as_mut_ptr()) }; // pselect expects timeouts in nanoseconds. const NSEC_PER_MSEC: u64 = 1000 * 1000; @@ -1329,27 +1330,27 @@ fn readch_timed(&mut self, wait_time_ms: usize) -> Option { }; // We have one fd of interest. - let mut fdset: libc::fd_set = unsafe { std::mem::zeroed() }; + let mut fdset = MaybeUninit::uninit(); let in_fd = self.get_in_fd(); unsafe { - libc::FD_ZERO(&mut fdset); - libc::FD_SET(in_fd, &mut fdset); + libc::FD_ZERO(fdset.as_mut_ptr()); + libc::FD_SET(in_fd, fdset.as_mut_ptr()); }; let res = unsafe { libc::pselect( in_fd + 1, - &mut fdset, + fdset.as_mut_ptr(), ptr::null_mut(), ptr::null_mut(), &timeout, - &sigs, + sigs.as_ptr(), ) }; // Prevent signal starvation on WSL causing the `torn_escapes.py` test to fail if is_windows_subsystem_for_linux(WSL::V1) { // Merely querying the current thread's sigmask is sufficient to deliver a pending signal - let _ = unsafe { libc::pthread_sigmask(0, ptr::null(), &mut sigs) }; + let _ = unsafe { libc::pthread_sigmask(0, ptr::null(), sigs.as_mut_ptr()) }; } if res > 0 { return Some(self.readch()); From f23a479b81b6eec95e280e0e5f58caae83b9a0cb Mon Sep 17 00:00:00 2001 From: Johannes Altmanninger Date: Thu, 24 Jul 2025 12:44:41 +0200 Subject: [PATCH 56/70] Reduce MaybeUninit lifetime (cherry picked from commit 137f22022598b4580c9cf5029b1a55b49eabb06a) --- src/input_common.rs | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/input_common.rs b/src/input_common.rs index ddfe17a3f..29a730a1d 100644 --- a/src/input_common.rs +++ b/src/input_common.rs @@ -1318,7 +1318,10 @@ fn readch_timed(&mut self, wait_time_ms: usize) -> Option { // our fd before the timeout. Use pselect to block all signals; we will handle signals // before the next call to readch(). let mut sigs = MaybeUninit::uninit(); - unsafe { libc::sigfillset(sigs.as_mut_ptr()) }; + let mut sigs = unsafe { + libc::sigfillset(sigs.as_mut_ptr()); + sigs.assume_init() + }; // pselect expects timeouts in nanoseconds. const NSEC_PER_MSEC: u64 = 1000 * 1000; @@ -1331,26 +1334,29 @@ fn readch_timed(&mut self, wait_time_ms: usize) -> Option { // We have one fd of interest. let mut fdset = MaybeUninit::uninit(); + let mut fdset = unsafe { + libc::FD_ZERO(fdset.as_mut_ptr()); + fdset.assume_init() + }; let in_fd = self.get_in_fd(); unsafe { - libc::FD_ZERO(fdset.as_mut_ptr()); - libc::FD_SET(in_fd, fdset.as_mut_ptr()); + libc::FD_SET(in_fd, &mut fdset); }; let res = unsafe { libc::pselect( in_fd + 1, - fdset.as_mut_ptr(), + &mut fdset, ptr::null_mut(), ptr::null_mut(), &timeout, - sigs.as_ptr(), + &sigs, ) }; // Prevent signal starvation on WSL causing the `torn_escapes.py` test to fail if is_windows_subsystem_for_linux(WSL::V1) { // Merely querying the current thread's sigmask is sufficient to deliver a pending signal - let _ = unsafe { libc::pthread_sigmask(0, ptr::null(), sigs.as_mut_ptr()) }; + let _ = unsafe { libc::pthread_sigmask(0, ptr::null(), &mut sigs) }; } if res > 0 { return Some(self.readch()); From 3e610369112a11e97dbb5006321b88ef39bd5bf6 Mon Sep 17 00:00:00 2001 From: Johannes Altmanninger Date: Fri, 25 Jul 2025 15:39:03 +0200 Subject: [PATCH 57/70] Revert "Change `readch()` into `try_readch()`" try_readch() was added to help a fuzzing harness, specifically to avoid a call to `unreachable!()` in the NothingToRead case. I don't know much about that but it seems like we should find a better way to tell the fuzzer that this can't happen. Fortunately the next commit will get rid of readb()'s "blocking" argument, along the NothingToRead enum variant. So we'll no longer need this. This reverts commit b92830cb17075c84e9ae17a8aa9efee5e0dfdb40. (cherry picked from commit fb7ee0db74421107c95c0984d7a30c6fb6271225) --- src/input_common.rs | 36 ++++++++++-------------------------- 1 file changed, 10 insertions(+), 26 deletions(-) diff --git a/src/input_common.rs b/src/input_common.rs index 29a730a1d..e68c51bf6 100644 --- a/src/input_common.rs +++ b/src/input_common.rs @@ -813,44 +813,28 @@ fn try_pop(&mut self) -> Option { self.get_input_data_mut().queue.pop_front() } - /// An "infallible" version of [`try_readch`](Self::try_readch) to be used when the input pipe - /// fd is expected to outlive the input reader. Will panic upon EOF. - #[inline(always)] + /// Function used by [`readch`](Self::readch) to read bytes from stdin until enough bytes have been read to + /// convert them to a wchar_t. Conversion is done using mbrtowc. If a character has previously + /// been read and then 'unread' using \c input_common_unreadch, that character is returned. fn readch(&mut self) -> CharEvent { - match self.try_readch(/*blocking*/ true) { - Some(c) => c, - None => unreachable!(), - } - } - - /// Function used by [`input_readch`] to read bytes from stdin until enough bytes have been read to - /// convert them to a wchar_t. Conversion is done using `mbrtowc`. If a character has previously - /// been read and then 'unread', that character is returned. - /// - /// This is guaranteed to keep returning `Some(CharEvent)` so long as the input stream remains - /// open; `None` is only returned upon EOF as the main loop within blocks until input becomes - /// available. - /// - /// This method is used directly by the fuzzing harness to avoid a panic on bounded inputs. - fn try_readch(&mut self, blocking: bool) -> Option { loop { // Do we have something enqueued already? // Note this may be initially true, or it may become true through calls to // iothread_service_main() or env_universal_barrier() below. if let Some(mevt) = self.try_pop() { - return Some(mevt); + return mevt; } // We are going to block; but first allow any override to inject events. self.prepare_to_select(); if let Some(mevt) = self.try_pop() { - return Some(mevt); + return mevt; } - let rr = readb(self.get_in_fd(), blocking); + let rr = readb(self.get_in_fd(), /*blocking=*/ true); match rr { ReadbResult::Eof => { - return Some(CharEvent::Eof); + return CharEvent::Eof; } ReadbResult::Interrupted => { @@ -923,16 +907,16 @@ fn try_readch(&mut self, blocking: bool) -> Option { continue; } return if let Some(key) = key { - Some(CharEvent::from_key_seq(key, seq)) + CharEvent::from_key_seq(key, seq) } else { self.insert_front(seq.chars().skip(1).map(CharEvent::from_char)); let Some(c) = seq.chars().next() else { continue; }; - Some(CharEvent::from_key_seq(KeyEvent::from_raw(c), seq)) + CharEvent::from_key_seq(KeyEvent::from_raw(c), seq) }; } - ReadbResult::NothingToRead => return None, + ReadbResult::NothingToRead => unreachable!(), } } } From 6666c8f1cd2c1b4d7fd1a7d505c6c64f9d7b0c89 Mon Sep 17 00:00:00 2001 From: Johannes Altmanninger Date: Thu, 24 Jul 2025 09:02:43 +0200 Subject: [PATCH 58/70] Block interrupts and uvar events while decoding key readb() has only one caller that passes blocking=false: try_readb(). This function is used while decoding keys; anything but a successful read is treated as "end of input sequence". This means that key input sequences such as \e[1;3D can be torn apart by - signals (EINTR) which is more likely since e1be842 (Work around torn byte sequences in qemu kbd input with 1ms timeout, 2025-03-04). - universal variable notifications (from other fish processes) Fix this by blocking signals and not listening on the uvar fd. We do something similar when matching key sequences against bindings, so extract a function and use it for key decoding too. Ref: https://github.com/fish-shell/fish-shell/issues/11668#issuecomment-3101341081 (cherry picked from commit da9617273975da42a53f3c9ba19eb720946ed7d1) --- src/input_common.rs | 185 ++++++++++++++++++++++---------------------- 1 file changed, 92 insertions(+), 93 deletions(-) diff --git a/src/input_common.rs b/src/input_common.rs index e68c51bf6..f5e5349b9 100644 --- a/src/input_common.rs +++ b/src/input_common.rs @@ -24,8 +24,8 @@ use std::mem::MaybeUninit; use std::os::fd::RawFd; use std::os::unix::ffi::OsStrExt; -use std::ptr; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::time::Duration; // The range of key codes for inputrc-style keyboard functions. pub const R_END_INPUT_FUNCTIONS: usize = (ReadlineCmd::ReverseRepeatJump as usize) + 1; @@ -479,7 +479,7 @@ pub fn from_check_exit() -> CharEvent { /// This calls select() on three fds: input (e.g. stdin), the ioport notifier fd (for main thread /// requests), and the uvar notifier. This returns either the byte which was read, or one of the /// special values below. -enum ReadbResult { +enum InputEventTrigger { // A byte was successfully read. Byte(u8), @@ -494,12 +494,22 @@ enum ReadbResult { // Our ioport reported a change, so service main thread requests. IOPortNotified, - - NothingToRead, } -fn readb(in_fd: RawFd, blocking: bool) -> ReadbResult { +fn readb(in_fd: RawFd) -> Option { assert!(in_fd >= 0, "Invalid in fd"); + let mut arr: [u8; 1] = [0]; + if read_blocked(in_fd, &mut arr) != Ok(1) { + // The terminal has been closed. + return None; + } + let c = arr[0]; + FLOG!(reader, "Read byte", char_to_symbol(char::from(c))); + // The common path is to return a u8. + Some(c) +} + +fn next_input_event(in_fd: RawFd) -> InputEventTrigger { let mut fdset = FdReadableSet::new(); loop { fdset.clear(); @@ -517,57 +527,89 @@ fn readb(in_fd: RawFd, blocking: bool) -> ReadbResult { } // Here's where we call select(). - let select_res = fdset.check_readable(if blocking { - FdReadableSet::kNoTimeout - } else { - 1000 - }); + let select_res = fdset.check_readable(FdReadableSet::kNoTimeout); if select_res < 0 { let err = errno::errno().0; if err == libc::EINTR || err == libc::EAGAIN { // A signal. - return ReadbResult::Interrupted; + return InputEventTrigger::Interrupted; } else { // Some fd was invalid, so probably the tty has been closed. - return ReadbResult::Eof; + return InputEventTrigger::Eof; } } - if blocking { - // select() did not return an error, so we may have a readable fd. - // The priority order is: uvars, stdin, ioport. - // Check to see if we want a universal variable barrier. - if let Some(notifier_fd) = notifier_fd { - if fdset.test(notifier_fd) && notifier.notification_fd_became_readable(notifier_fd) - { - return ReadbResult::UvarNotified; - } + // select() did not return an error, so we may have a readable fd. + // The priority order is: uvars, stdin, ioport. + // Check to see if we want a universal variable barrier. + if let Some(notifier_fd) = notifier_fd { + if fdset.test(notifier_fd) && notifier.notification_fd_became_readable(notifier_fd) { + return InputEventTrigger::UvarNotified; } } // Check stdin. if fdset.test(in_fd) { - let mut arr: [u8; 1] = [0]; - if read_blocked(in_fd, &mut arr) != Ok(1) { - // The terminal has been closed. - return ReadbResult::Eof; - } - FLOG!(reader, "Read byte", arr[0]); - // The common path is to return a u8. - return ReadbResult::Byte(arr[0]); - } - if !blocking { - return ReadbResult::NothingToRead; + return readb(in_fd).map_or(InputEventTrigger::Eof, InputEventTrigger::Byte); } // Check for iothread completions only if there is no data to be read from the stdin. // This gives priority to the foreground. if fdset.test(ioport_fd) { - return ReadbResult::IOPortNotified; + return InputEventTrigger::IOPortNotified; } } } +pub fn check_fd_readable(in_fd: RawFd, timeout: Duration) -> bool { + use std::ptr; + // We are not prepared to handle a signal immediately; we only want to know if we get input on + // our fd before the timeout. Use pselect to block all signals; we will handle signals + // before the next call to readch(). + let mut sigs = MaybeUninit::uninit(); + let mut sigs = unsafe { + libc::sigfillset(sigs.as_mut_ptr()); + sigs.assume_init() + }; + + // pselect expects timeouts in nanoseconds. + const NSEC_PER_MSEC: u64 = 1000 * 1000; + const NSEC_PER_SEC: u64 = NSEC_PER_MSEC * 1000; + let wait_nsec: u64 = (timeout.as_millis() as u64) * NSEC_PER_MSEC; + let timeout = libc::timespec { + tv_sec: (wait_nsec / NSEC_PER_SEC).try_into().unwrap(), + tv_nsec: (wait_nsec % NSEC_PER_SEC).try_into().unwrap(), + }; + + // We have one fd of interest. + let mut fdset = MaybeUninit::uninit(); + let mut fdset = unsafe { + libc::FD_ZERO(fdset.as_mut_ptr()); + fdset.assume_init() + }; + unsafe { + libc::FD_SET(in_fd, &mut fdset); + } + + let res = unsafe { + libc::pselect( + in_fd + 1, + &mut fdset, + ptr::null_mut(), + ptr::null_mut(), + &timeout, + &sigs, + ) + }; + + // Prevent signal starvation on WSL causing the `torn_escapes.py` test to fail + if is_windows_subsystem_for_linux(WSL::V1) { + // Merely querying the current thread's sigmask is sufficient to deliver a pending signal + let _ = unsafe { libc::pthread_sigmask(0, ptr::null(), &mut sigs) }; + } + res > 0 +} + // Update the wait_on_escape_ms value in response to the fish_escape_delay_ms user variable being // set. pub fn update_wait_on_escape_ms(vars: &EnvStack) { @@ -831,25 +873,24 @@ fn readch(&mut self) -> CharEvent { return mevt; } - let rr = readb(self.get_in_fd(), /*blocking=*/ true); - match rr { - ReadbResult::Eof => { + match next_input_event(self.get_in_fd()) { + InputEventTrigger::Eof => { return CharEvent::Eof; } - ReadbResult::Interrupted => { + InputEventTrigger::Interrupted => { self.select_interrupted(); } - ReadbResult::UvarNotified => { + InputEventTrigger::UvarNotified => { self.uvar_change_notified(); } - ReadbResult::IOPortNotified => { + InputEventTrigger::IOPortNotified => { self.ioport_notified(); } - ReadbResult::Byte(read_byte) => { + InputEventTrigger::Byte(read_byte) => { let mut have_escape_prefix = false; let mut buffer = vec![read_byte]; let key_with_escape = if read_byte == 0x1b { @@ -874,8 +915,8 @@ fn readch(&mut self) -> CharEvent { let mut i = 0; let ok = loop { if i == buffer.len() { - buffer.push(match readb(self.get_in_fd(), /*blocking=*/ true) { - ReadbResult::Byte(b) => b, + buffer.push(match next_input_event(self.get_in_fd()) { + InputEventTrigger::Byte(b) => b, _ => 0, }); } @@ -916,15 +957,16 @@ fn readch(&mut self) -> CharEvent { CharEvent::from_key_seq(KeyEvent::from_raw(c), seq) }; } - ReadbResult::NothingToRead => unreachable!(), } } } fn try_readb(&mut self, buffer: &mut Vec) -> Option { - let ReadbResult::Byte(next) = readb(self.get_in_fd(), /*blocking=*/ false) else { + let fd = self.get_in_fd(); + if !check_fd_readable(fd, Duration::from_millis(1)) { return None; - }; + } + let next = readb(fd)?; buffer.push(next); Some(next) } @@ -1298,54 +1340,11 @@ fn readch_timed(&mut self, wait_time_ms: usize) -> Option { } terminal_protocols_enable_ifn(); - // We are not prepared to handle a signal immediately; we only want to know if we get input on - // our fd before the timeout. Use pselect to block all signals; we will handle signals - // before the next call to readch(). - let mut sigs = MaybeUninit::uninit(); - let mut sigs = unsafe { - libc::sigfillset(sigs.as_mut_ptr()); - sigs.assume_init() - }; - - // pselect expects timeouts in nanoseconds. - const NSEC_PER_MSEC: u64 = 1000 * 1000; - const NSEC_PER_SEC: u64 = NSEC_PER_MSEC * 1000; - let wait_nsec: u64 = (wait_time_ms as u64) * NSEC_PER_MSEC; - let timeout = libc::timespec { - tv_sec: (wait_nsec / NSEC_PER_SEC).try_into().unwrap(), - tv_nsec: (wait_nsec % NSEC_PER_SEC).try_into().unwrap(), - }; - - // We have one fd of interest. - let mut fdset = MaybeUninit::uninit(); - let mut fdset = unsafe { - libc::FD_ZERO(fdset.as_mut_ptr()); - fdset.assume_init() - }; - let in_fd = self.get_in_fd(); - unsafe { - libc::FD_SET(in_fd, &mut fdset); - }; - let res = unsafe { - libc::pselect( - in_fd + 1, - &mut fdset, - ptr::null_mut(), - ptr::null_mut(), - &timeout, - &sigs, - ) - }; - - // Prevent signal starvation on WSL causing the `torn_escapes.py` test to fail - if is_windows_subsystem_for_linux(WSL::V1) { - // Merely querying the current thread's sigmask is sufficient to deliver a pending signal - let _ = unsafe { libc::pthread_sigmask(0, ptr::null(), &mut sigs) }; - } - if res > 0 { - return Some(self.readch()); - } - None + check_fd_readable( + self.get_in_fd(), + Duration::from_millis(u64::try_from(wait_time_ms).unwrap()), + ) + .then(|| self.readch()) } /// Return the fd from which to read. From e593da1c2e767a53bf8c20d110062d478e8cf0c5 Mon Sep 17 00:00:00 2001 From: Johannes Altmanninger Date: Thu, 24 Jul 2025 13:10:05 +0200 Subject: [PATCH 59/70] Increase timeout when reading escape sequences inside paste/kitty kbd Historically, fish has treated input bytes [0x1b, 'b'] as alt-b (rather than "escape,b") if the second byte arrives within 30ms of the first. Since we made builtin bind match key events instead of raw byte sequences, we have another place where we do similar disambiguation: when we read keys such as alt-left ("\e[1;3D"), we only consider bytes to be part of this sequence if stdin is immediately readable (actually "readable after a 1ms timeout" since e1be842 (Work around torn byte sequences in qemu kbd input with 1ms timeout, 2025-03-04)). This is technically wrong but has worked in practice (for Kakoune etc.). Issue #11668 reports two issues on some Windows terminals feeding a remote fish shell: - the "bracketed paste finished" sequence may be split into multiple packets, which causes a delay of > 1ms between individual bytes being readable. - AutoHotKey scripts simulating seven "left" keys result in sequence tearing as well. Try to fix the paste case by increasing the timeout when parsing escape sequences. Also increase the timeout for terminals that support the kitty keyboard protocol. The user should only notice this new delay after pressing one of escape,O, escape,P, escape,[, or escape,] **while the kitty keyboard protocol is disabled** (e.g. while an external command is running). In this case, the fish_escape_delay_ms is also virtually increased; hopefully this edge case is not ever relevant. Part of #11668 (cherry picked from commit 30ff3710a069a03b21025cf896f0bbb31d9fe8bd) --- src/input_common.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/input_common.rs b/src/input_common.rs index f5e5349b9..469f3784f 100644 --- a/src/input_common.rs +++ b/src/input_common.rs @@ -963,7 +963,14 @@ fn readch(&mut self) -> CharEvent { fn try_readb(&mut self, buffer: &mut Vec) -> Option { let fd = self.get_in_fd(); - if !check_fd_readable(fd, Duration::from_millis(1)) { + if !check_fd_readable( + fd, + Duration::from_millis(if self.paste_is_buffering() { 300 } else { 1 }), + ) { + FLOG!( + reader, + format!("Incomplete escape sequence: {}", DisplayBytes(buffer)) + ); return None; } let next = readb(fd)?; From 0c8f1f42206bf389162100f4ef0fe5d43c992b71 Mon Sep 17 00:00:00 2001 From: Johannes Altmanninger Date: Thu, 17 Jul 2025 08:44:32 +0200 Subject: [PATCH 60/70] Fix async-signal safety in SIGTERM handler Cherry-picked from - 941701da3d8 (Restore some async-signal discipline to SIGTERM, 2025-06-15) - 81d45caa76e (Restore terminal state on SIGTERM again, 2025-06-21) Also, be more careful in terminal_protocols_disable_ifn about accessing reader_current_data(), as pointed out in 65a4cb5245a (Revert "Restore terminal state on SIGTERM again", 2025-07-19). See #11597 --- src/builtins/fg.rs | 2 +- src/builtins/read.rs | 2 +- src/exec.rs | 4 ++-- src/input_common.rs | 4 ++-- src/key.rs | 8 ++++--- src/parser.rs | 2 +- src/reader.rs | 54 +++++++++++++++++++++++++------------------- 7 files changed, 43 insertions(+), 33 deletions(-) diff --git a/src/builtins/fg.rs b/src/builtins/fg.rs index 675a06ebd..112222ffa 100644 --- a/src/builtins/fg.rs +++ b/src/builtins/fg.rs @@ -148,7 +148,7 @@ pub fn fg(parser: &Parser, streams: &mut IoStreams, argv: &mut [&wstr]) -> Optio let job_group = job.group(); job_group.set_is_foreground(true); if job.entitled_to_terminal() { - crate::input_common::terminal_protocols_disable_ifn(); + crate::input_common::terminal_protocols_disable_ifn(false); } let tmodes = job_group.tmodes.borrow(); if job_group.wants_terminal() && tmodes.is_some() { diff --git a/src/builtins/read.rs b/src/builtins/read.rs index 1ec26c5f0..7c9d38cac 100644 --- a/src/builtins/read.rs +++ b/src/builtins/read.rs @@ -248,7 +248,7 @@ fn read_interactive( reader_readline(parser, nchars) }; - terminal_protocols_disable_ifn(); + terminal_protocols_disable_ifn(false); if let Some(line) = mline { *buff = line; if nchars > 0 && nchars < buff.len() { diff --git a/src/exec.rs b/src/exec.rs index 0b9e9b7ba..b52f6259e 100644 --- a/src/exec.rs +++ b/src/exec.rs @@ -39,7 +39,7 @@ print_exit_warning_for_jobs, InternalProc, Job, JobGroupRef, Pid, ProcStatus, Process, ProcessType, TtyTransfer, }; -use crate::reader::{reader_run_count, restore_term_mode}; +use crate::reader::{reader_run_count, safe_restore_term_mode}; use crate::redirection::{dup2_list_resolve_chain, Dup2List}; use crate::threads::{iothread_perform_cant_wait, is_forked_child}; use crate::trace::trace_if_enabled_with_args; @@ -437,7 +437,7 @@ fn launch_process_nofork(vars: &EnvStack, p: &Process) -> ! { let actual_cmd = wcs2zstring(&p.actual_cmd); // Ensure the terminal modes are what they were before we changed them. - restore_term_mode(false); + safe_restore_term_mode(false); // Bounce to launch_process. This never returns. safe_launch_process(p, &actual_cmd, &argv, &*envp); } diff --git a/src/input_common.rs b/src/input_common.rs index 469f3784f..c94d8052c 100644 --- a/src/input_common.rs +++ b/src/input_common.rs @@ -749,7 +749,7 @@ pub fn terminal_protocols_enable_ifn() { reader_current_data().map(|data| data.save_screen_state()); } -pub(crate) fn terminal_protocols_disable_ifn() { +pub(crate) fn terminal_protocols_disable_ifn(in_signal_handler: bool) { if !TERMINAL_PROTOCOLS.load(Ordering::Acquire) { return; } @@ -784,7 +784,7 @@ pub(crate) fn terminal_protocols_disable_ifn() { if IS_TMUX.load() { let _ = write_loop(&STDOUT_FILENO, "\x1b[?1004l".as_bytes()); } - if is_main_thread() { + if !in_signal_handler && is_main_thread() { reader_current_data().map(|data| data.save_screen_state()); } TERMINAL_PROTOCOLS.store(false, Ordering::Release); diff --git a/src/key.rs b/src/key.rs index 9ae9a09ca..2a7cad1b6 100644 --- a/src/key.rs +++ b/src/key.rs @@ -3,7 +3,7 @@ use crate::{ common::{escape_string, EscapeFlags, EscapeStringStyle}, fallback::fish_wcwidth, - reader::TERMINAL_MODE_ON_STARTUP, + reader::safe_get_terminal_mode_on_startup, wchar::{decode_byte_from_char, prelude::*}, wutil::{fish_is_pua, fish_wcstoul}, }; @@ -169,8 +169,10 @@ pub(crate) fn canonicalize_keyed_control_char(c: char) -> char { if c == ' ' { return Space; } - if c == char::from(TERMINAL_MODE_ON_STARTUP.lock().unwrap().c_cc[VERASE]) { - return Backspace; + if let Some(tm) = safe_get_terminal_mode_on_startup() { + if c == char::from(tm.c_cc[VERASE]) { + return Backspace; + } } if c == char::from(127) { // when it's not backspace diff --git a/src/parser.rs b/src/parser.rs index 7afca7f58..7f96f4445 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -614,7 +614,7 @@ pub fn eval_node( let mut execution_context = ExecutionContext::new(ps.clone(), block_io.clone(), Rc::clone(&line_counter)); - terminal_protocols_disable_ifn(); + terminal_protocols_disable_ifn(false); // Check the exec count so we know if anything got executed. let prev_exec_count = self.libdata().exec_count; diff --git a/src/reader.rs b/src/reader.rs index 68b450871..496982ad3 100644 --- a/src/reader.rs +++ b/src/reader.rs @@ -34,8 +34,7 @@ use std::rc::Rc; #[cfg(target_has_atomic = "64")] use std::sync::atomic::AtomicU64; -use std::sync::atomic::Ordering; -use std::sync::atomic::{AtomicI32, AtomicU32, AtomicU8}; +use std::sync::atomic::{AtomicI32, AtomicPtr, AtomicU32, AtomicU8, Ordering}; use std::sync::{Arc, Mutex, MutexGuard}; use std::time::{Duration, Instant}; @@ -152,9 +151,9 @@ enum ExitState { pub static SHELL_MODES: Lazy> = Lazy::new(|| Mutex::new(unsafe { std::mem::zeroed() })); -/// Mode on startup, which we restore on exit. -pub static TERMINAL_MODE_ON_STARTUP: Lazy> = - Lazy::new(|| Mutex::new(unsafe { std::mem::zeroed() })); +/// The valid terminal modes on startup. This is set once and not modified after. +/// Warning: this is read from the SIGTERM handler! Hence the raw global. +static TERMINAL_MODE_ON_STARTUP: AtomicPtr = AtomicPtr::new(std::ptr::null_mut()); /// Mode we use to execute programs. static TTY_MODES_FOR_EXTERNAL_CMDS: Lazy> = @@ -171,6 +170,12 @@ enum ExitState { /// This is set from a signal handler. static SIGHUP_RECEIVED: RelaxedAtomicBool = RelaxedAtomicBool::new(false); +// Get the terminal mode on startup. This is "safe" because it's async-signal safe. +pub fn safe_get_terminal_mode_on_startup() -> Option<&'static libc::termios> { + // Safety: set atomically and not modified after. + unsafe { TERMINAL_MODE_ON_STARTUP.load(Ordering::Acquire).as_ref() } +} + /// A singleton snapshot of the reader state. This is factored out for thread-safety reasons: /// it may be fetched on a background thread. fn commandline_state_snapshot() -> MutexGuard<'static, CommandlineState> { @@ -813,8 +818,15 @@ fn read_ni(parser: &Parser, fd: RawFd, io: &IoChain) -> i32 { /// Initialize the reader. pub fn reader_init(will_restore_foreground_pgroup: bool) { // Save the initial terminal mode. - let mut terminal_mode_on_startup = TERMINAL_MODE_ON_STARTUP.lock().unwrap(); - unsafe { libc::tcgetattr(STDIN_FILENO, &mut *terminal_mode_on_startup) }; + // Note this field is read by a signal handler, so do it atomically, with a leaked mode. + let mut terminal_mode_on_startup = unsafe { std::mem::zeroed::() }; + let ret = unsafe { libc::tcgetattr(libc::STDIN_FILENO, &mut terminal_mode_on_startup) }; + // TODO: rationalize behavior if initial tcgetattr() fails. + if ret == 0 { + // Must be mut because AtomicPtr doesn't have const variant. + let leaked: *mut libc::termios = Box::leak(Box::new(terminal_mode_on_startup)); + TERMINAL_MODE_ON_STARTUP.store(leaked, Ordering::Release); + } #[cfg(not(test))] assert!(AT_EXIT.get().is_none()); @@ -826,7 +838,7 @@ pub fn reader_init(will_restore_foreground_pgroup: bool) { // Set the mode used for program execution, initialized to the current mode. let mut tty_modes_for_external_cmds = TTY_MODES_FOR_EXTERNAL_CMDS.lock().unwrap(); - *tty_modes_for_external_cmds = *terminal_mode_on_startup; + *tty_modes_for_external_cmds = terminal_mode_on_startup; term_fix_external_modes(&mut tty_modes_for_external_cmds); // Disable flow control by default. @@ -838,7 +850,6 @@ pub fn reader_init(will_restore_foreground_pgroup: bool) { term_fix_modes(&mut shell_modes()); - drop(terminal_mode_on_startup); drop(tty_modes_for_external_cmds); // Set up our fixed terminal modes once, @@ -850,9 +861,11 @@ pub fn reader_init(will_restore_foreground_pgroup: bool) { } } +// TODO(pca): this is run in our "AT_EXIT" handler from a SIGTERM handler. +// It must be made async-signal-safe (or not invoked). pub fn reader_deinit(in_signal_handler: bool, restore_foreground_pgroup: bool) { - restore_term_mode(in_signal_handler); - crate::input_common::terminal_protocols_disable_ifn(); + safe_restore_term_mode(in_signal_handler); + crate::input_common::terminal_protocols_disable_ifn(in_signal_handler); if restore_foreground_pgroup { restore_term_foreground_process_group_for_exit(); } @@ -861,20 +874,15 @@ pub fn reader_deinit(in_signal_handler: bool, restore_foreground_pgroup: bool) { /// Restore the term mode if we own the terminal and are interactive (#8705). /// It's important we do this before restore_foreground_process_group, /// otherwise we won't think we own the terminal. -pub fn restore_term_mode(in_signal_handler: bool) { +/// THIS FUNCTION IS CALLED FROM A SIGNAL HANDLER. IT MUST BE ASYNC-SIGNAL-SAFE. +pub fn safe_restore_term_mode(in_signal_handler: bool) { if !is_interactive_session() || unsafe { libc::getpgrp() != libc::tcgetpgrp(STDIN_FILENO) } { return; } - - if unsafe { - libc::tcsetattr( - STDIN_FILENO, - TCSANOW, - &*TERMINAL_MODE_ON_STARTUP.lock().unwrap(), - ) == -1 - } && errno().0 == EIO - { - redirect_tty_output(in_signal_handler); + if let Some(modes) = safe_get_terminal_mode_on_startup() { + if unsafe { libc::tcsetattr(STDIN_FILENO, TCSANOW, modes) == -1 } && errno().0 == EIO { + redirect_tty_output(in_signal_handler); + } } } @@ -5812,7 +5820,7 @@ fn compute_and_apply_completions(&mut self, c: ReadlineCmd) { token_range.end += cmdsub_range.start; // Wildcard expansion and completion below check for cancellation. - terminal_protocols_disable_ifn(); + terminal_protocols_disable_ifn(false); // Check if we have a wildcard within this string; if so we first attempt to expand the // wildcard; if that succeeds we don't then apply user completions (#8593). From e200abe39c27d7b0fa74c439d4b61f0236f65b4c Mon Sep 17 00:00:00 2001 From: Johannes Altmanninger Date: Sat, 26 Jul 2025 13:04:51 +0200 Subject: [PATCH 61/70] __fish_seen_subcommand_from: fix regression causing false negatives given multiple arguments Fixes 2bfa7db7bce (Restructure __fish_seen_subcommand_from, 2024-07-07) Fixes #11685 (cherry picked from commit 4412164fd4e80376f246a6c2eacec8c61fea4633) --- share/functions/__fish_seen_subcommand_from.fish | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/share/functions/__fish_seen_subcommand_from.fish b/share/functions/__fish_seen_subcommand_from.fish index ccf7e05aa..806f114a1 100644 --- a/share/functions/__fish_seen_subcommand_from.fish +++ b/share/functions/__fish_seen_subcommand_from.fish @@ -6,5 +6,5 @@ function __fish_seen_subcommand_from set -l regex (string escape --style=regex -- (commandline -pxc)[2..] | string join '|') - string match -rq -- "^$regex"'$' $argv + string match -rq -- "^($regex)\$" $argv end From 9c0086b7afe5a1c3881e5026e882cc177896839c Mon Sep 17 00:00:00 2001 From: Johannes Altmanninger Date: Sat, 2 Aug 2025 09:49:10 +0200 Subject: [PATCH 62/70] Backport default alt-delete binding This is standard on macOS and in chrome/firefox. On master, this was sneakily added in 2bb5cbc9594 (Default bindings for token movements v2, 2025-03-04) and before that in 6af96a81a8c (Default bindings for token movement commands, 2024-10-05) Ref: https://lobste.rs/s/ndlwoh/wizard_his_shell#c_qvhnvd --- CHANGELOG.rst | 1 + share/functions/fish_default_key_bindings.fish | 1 + 2 files changed, 2 insertions(+) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index e9c0ed40f..00691d461 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -3,6 +3,7 @@ fish 4.0.3 (released ???) This release of fish fixes a number of issues identified in fish 4.0.2: +- :kbd:`alt-delete` now deletes the word right of the cursor. - fish now properly inherits $PATH under Windows WSL2 (:issue:`11354`). - the :doc:`read ` builtin no longer miscalculates width of multi-byte characters (:issue:`11412`). - A crash displaying multi-line quoted command substitutions has been fixed (:issue:`11444`). diff --git a/share/functions/fish_default_key_bindings.fish b/share/functions/fish_default_key_bindings.fish index 60cefe6f1..8c40101d0 100644 --- a/share/functions/fish_default_key_bindings.fish +++ b/share/functions/fish_default_key_bindings.fish @@ -58,6 +58,7 @@ function fish_default_key_bindings -d "emacs-like key binds" bind --preset $argv alt-c capitalize-word bind --preset $argv alt-backspace backward-kill-word + bind --preset $argv alt-delete kill-word bind --preset $argv ctrl-alt-h backward-kill-word bind --preset $argv ctrl-backspace backward-kill-word bind --preset $argv ctrl-delete kill-word From 6900b89c82bd2adee29062b7d74d07dc0e64dadf Mon Sep 17 00:00:00 2001 From: Johannes Altmanninger Date: Wed, 27 Aug 2025 09:13:27 +0200 Subject: [PATCH 63/70] Add mark-prompt feature flag So far, terminals that fail to parse OSC sequences are the only reason for wanting to turn off OSC 133. Let's allow to work around it by adding a feature flag (which is implied to be temporary). To use it, run this once, and restart fish: set -Ua fish_features no-mark-prompt Tested with fish -i | string escape | grep 133 && ! fish_features=no-mark-prompt fish -i | string escape | grep 133 See #11749 Also #11609 --- CHANGELOG.rst | 2 ++ src/future_feature_flags.rs | 12 ++++++++++++ src/reader.rs | 31 ++++++++++++++++++------------- src/screen.rs | 8 ++++++-- tests/checks/status.fish | 1 + 5 files changed, 39 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 00691d461..ad2fc69ce 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -32,6 +32,8 @@ This release of fish fixes a number of issues identified in fish 4.0.2: - The routines to save history and universal variables have seen some robustness improvements. - Vi mode: The cursor position after pasting via :kbd:`p` has been corrected. - Vi mode: Trying to replace the last character via :kbd:`r` no longer replaces the last-but-one character (:issue:`11484`), +- To work around terminals that fail to parse OSC sequences, a temporary feature flag has been added. + It allows you to disable prompt marking (OSC 133) by running (once) ``set -Ua fish_features no-mark-prompt" and restarting fish (:issue:`11749`). fish 4.0.2 (released April 20, 2025) ==================================== diff --git a/src/future_feature_flags.rs b/src/future_feature_flags.rs index 38000e38a..dbef7638b 100644 --- a/src/future_feature_flags.rs +++ b/src/future_feature_flags.rs @@ -30,6 +30,9 @@ pub enum FeatureFlag { /// Whether keyboard protocols (kitty's CSI x u, xterm's modifyOtherKeys) are used keyboard_protocols, + + /// Whether to write OSC 133 prompt markers + mark_prompt, } struct Features { @@ -118,6 +121,14 @@ pub struct FeatureMetadata { default_value: true, read_only: false, }, + FeatureMetadata { + flag: FeatureFlag::mark_prompt, + name: L!("mark-prompt"), + groups: L!("4.0"), + description: L!("Write OSC 133 prompt markers to the terminal"), + default_value: true, + read_only: false, + }, ]; thread_local!( @@ -180,6 +191,7 @@ const fn new() -> Self { AtomicBool::new(METADATA[4].default_value), AtomicBool::new(METADATA[5].default_value), AtomicBool::new(METADATA[6].default_value), + AtomicBool::new(METADATA[7].default_value), ], } } diff --git a/src/reader.rs b/src/reader.rs index 496982ad3..783aaa1b2 100644 --- a/src/reader.rs +++ b/src/reader.rs @@ -66,6 +66,7 @@ use crate::flog::{FLOG, FLOGF}; #[allow(unused_imports)] use crate::future::IsSomeAnd; +use crate::future_feature_flags::{feature_test, FeatureFlag}; use crate::global_safety::RelaxedAtomicBool; use crate::highlight::{ autosuggest_validate_from_history, highlight_shell, HighlightRole, HighlightSpec, @@ -664,13 +665,15 @@ fn read_i(parser: &Parser) -> i32 { data.command_line.clear(); data.update_buff_pos(EditableLineTag::Commandline, None); data.command_line_changed(EditableLineTag::Commandline); - // OSC 133 "Command start" - write!( - BufferedOuputter::new(&mut Outputter::stdoutput().borrow_mut()), - "\x1b]133;C;cmdline_url={}\x07", - escape_string(&command, EscapeStringStyle::Url), - ) - .unwrap(); + if feature_test(FeatureFlag::mark_prompt) { + // OSC 133 "Command start" + write!( + BufferedOuputter::new(&mut Outputter::stdoutput().borrow_mut()), + "\x1b]133;C;cmdline_url={}\x07", + escape_string(&command, EscapeStringStyle::Url), + ) + .unwrap(); + } event::fire_generic(parser, L!("fish_preexec").to_owned(), vec![command.clone()]); let eval_res = reader_run_command(parser, &command); signal_clear_cancel(); @@ -683,12 +686,14 @@ fn read_i(parser: &Parser) -> i32 { parser.libdata_mut().exit_current_script = false; // OSC 133 "Command finished" - write!( - BufferedOuputter::new(&mut Outputter::stdoutput().borrow_mut()), - "\x1b]133;D;{}\x07", - parser.get_last_status() - ) - .unwrap(); + if feature_test(FeatureFlag::mark_prompt) { + write!( + BufferedOuputter::new(&mut Outputter::stdoutput().borrow_mut()), + "\x1b]133;D;{}\x07", + parser.get_last_status() + ) + .unwrap(); + } event::fire_generic(parser, L!("fish_postexec").to_owned(), vec![command]); // Allow any pending history items to be returned in the history array. data.history.resolve_pending(); diff --git a/src/screen.rs b/src/screen.rs index 158512e1e..edcbb1c65 100644 --- a/src/screen.rs +++ b/src/screen.rs @@ -7,6 +7,7 @@ //! The current implementation is less smart than ncurses allows and can not for example move blocks //! of text around to handle text insertion. +use crate::future_feature_flags::{feature_test, FeatureFlag}; use crate::pager::{PageRendering, Pager, PAGER_MIN_HEIGHT}; use std::cell::RefCell; use std::collections::LinkedList; @@ -931,8 +932,11 @@ fn update( } else if left_prompt != zelf.actual_left_prompt || (zelf.scrolled && is_final_rendering) { zelf.r#move(0, 0); let mut start = 0; - let osc_133_prompt_start = - |zelf: &mut Screen| zelf.write_bytes(b"\x1b]133;A;special_key=1\x07"); + let osc_133_prompt_start = |zelf: &mut Screen| { + if feature_test(FeatureFlag::mark_prompt) { + zelf.write_bytes(b"\x1b]133;A;special_key=1\x07"); + } + }; if left_prompt_layout.line_breaks.is_empty() { osc_133_prompt_start(&mut zelf); } diff --git a/tests/checks/status.fish b/tests/checks/status.fish index 7626d437e..82bc2950a 100644 --- a/tests/checks/status.fish +++ b/tests/checks/status.fish @@ -63,6 +63,7 @@ status features #CHECK: remove-percent-self off 4.0 %self is no longer expanded (use $fish_pid) #CHECK: test-require-arg off 4.0 builtin test requires an argument #CHECK: keyboard-protocols on 4.0 Use keyboard protocols (kitty, xterm's modifyotherkeys +#CHECK: mark-prompt on 4.0 Write OSC 133 prompt markers to the terminal status test-feature stderr-nocaret echo $status #CHECK: 0 From 9258275fe6917adb7c7f1a142f2b22aef1a9a52a Mon Sep 17 00:00:00 2001 From: Johannes Altmanninger Date: Mon, 25 Aug 2025 10:03:52 +0200 Subject: [PATCH 64/70] config_paths: fix compiled-in locale dir for installed, non-embed builds Commit bf65b9e3a74 (Change `gettext` paths to be relocatable (#11195), 2025-03-30) broke the locale path. Commit c3740b85be4 (config_paths: fix compiled-in locale dir, 2025-06-12) fixed what it calls "case 4", but "case 2" is also affected; fix that. Before/after: $ ~/.local/opt/fish/bin/fish -d config paths.locale: /home/johannes/.local/opt/fish/share/fish/locale $ ~/.local/opt/fish/bin/fish -d config paths.locale: /home/johannes/.local/opt/fish/share/locale See https://github.com/fish-shell/fish-shell/issues/11683#issuecomment-3218190662 (cherry picked from commit 21a07f08a3666a278cb1904914e1ef6834f03a67) --- src/env/config_paths.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/env/config_paths.rs b/src/env/config_paths.rs index 47851b262..598eaf70d 100644 --- a/src/env/config_paths.rs +++ b/src/env/config_paths.rs @@ -64,7 +64,8 @@ fn determine_config_directory_paths(argv0: impl AsRef) -> ConfigPaths { let data = base_path.join("share/fish/install"); #[cfg(not(feature = "installable"))] let data = base_path.join("share/fish"); - let locale = Some(data.join("locale")); + let locale = + (!cfg!(feature = "installable")).then(|| base_path.join("share/locale")); paths = ConfigPaths { // One obvious path is ~/.local (with fish in ~/.local/bin/). // If we picked ~/.local/share/fish as our data path, From 1db0ff9f779b9234f3d43684a52850eae1c93bb6 Mon Sep 17 00:00:00 2001 From: Johannes Altmanninger Date: Thu, 4 Sep 2025 08:35:08 +0200 Subject: [PATCH 65/70] Allow overriding __fish_update_cwd_osc to work around terminal bugs See #11777 While at it, pull in the TERM=dumb check from master. (cherry picked from commit 898cc3242b15efc9be514538ff97048ed40c2d77) --- share/functions/__fish_config_interactive.fish | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/share/functions/__fish_config_interactive.fish b/share/functions/__fish_config_interactive.fish index 1b5e4b99e..66da46161 100644 --- a/share/functions/__fish_config_interactive.fish +++ b/share/functions/__fish_config_interactive.fish @@ -219,12 +219,17 @@ end" >$__fish_config_dir/config.fish end # Notify terminals when $PWD changes via OSC 7 (issue #906). - function __fish_update_cwd_osc --on-variable PWD --description 'Notify terminals when $PWD changes' - set -l host $hostname - if set -q KONSOLE_VERSION - set host '' + if not functions --query __fish_update_cwd_osc + function __fish_update_cwd_osc --on-variable PWD --description 'Notify terminals when $PWD changes' + set -l host $hostname + if set -q KONSOLE_VERSION + set host '' + end + if [ "$TERM" = dumb ] + return + end + printf \e\]7\;file://%s%s\a $host (string escape --style=url -- $PWD) end - printf \e\]7\;file://%s%s\a $host (string escape --style=url -- $PWD) end __fish_update_cwd_osc # Run once because we might have already inherited a PWD from an old tab From 201882e72aab26d5ec50e3577f54e9fa900e068f Mon Sep 17 00:00:00 2001 From: Johannes Altmanninger Date: Tue, 9 Sep 2025 07:29:04 +0200 Subject: [PATCH 66/70] CHANGELOG: fix inline literal RST syntax --- CHANGELOG.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index ad2fc69ce..9ac3c0c21 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -33,7 +33,7 @@ This release of fish fixes a number of issues identified in fish 4.0.2: - Vi mode: The cursor position after pasting via :kbd:`p` has been corrected. - Vi mode: Trying to replace the last character via :kbd:`r` no longer replaces the last-but-one character (:issue:`11484`), - To work around terminals that fail to parse OSC sequences, a temporary feature flag has been added. - It allows you to disable prompt marking (OSC 133) by running (once) ``set -Ua fish_features no-mark-prompt" and restarting fish (:issue:`11749`). + It allows you to disable prompt marking (OSC 133) by running (once) ``set -Ua fish_features no-mark-prompt`` and restarting fish (:issue:`11749`). fish 4.0.2 (released April 20, 2025) ==================================== From bdba2c227d55e880a383f3ac54b098ea62937857 Mon Sep 17 00:00:00 2001 From: Johannes Altmanninger Date: Thu, 11 Sep 2025 12:27:09 +0200 Subject: [PATCH 67/70] CHANGELOG: update --- CHANGELOG.rst | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 9ac3c0c21..d125c3a99 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -5,34 +5,34 @@ This release of fish fixes a number of issues identified in fish 4.0.2: - :kbd:`alt-delete` now deletes the word right of the cursor. - fish now properly inherits $PATH under Windows WSL2 (:issue:`11354`). -- the :doc:`read ` builtin no longer miscalculates width of multi-byte characters (:issue:`11412`). +- the :doc:`printf ` builtin no longer miscalculates width of multi-byte characters (:issue:`11412`). - A crash displaying multi-line quoted command substitutions has been fixed (:issue:`11444`). - Remote filesystems are detected properly again on non-Linux systems. -- builtin :doc:`status ` no longer prints a trailing blank line. +- builtin :doc:`status current-command ` no longer prints a trailing blank line. - A workaround has been added to prevent spurious output inside old versions of Midnight Commander. - :kbd:`ctrl-alt-h` erases the last word again (:issue:`11548`). -- :kbd:`alt-left` :kbd:`alt-right` send unexpected sequences on some terminals; a workaround has been added. (:issue:`11479`). -- fish no longer interprets invalid terminal control sequences as if they were :kbd:`alt-[` or :kbd:`alt-o` key strokes. +- :kbd:`alt-left` :kbd:`alt-right` were misinterpreted because they send unexpected sequences on some terminals; a workaround has been added. (:issue:`11479`). +- fish no longer interprets invalid control sequences from the terminal as if they were :kbd:`alt-[` or :kbd:`alt-o` key strokes. - :doc:`bind ` has been taught about the :kbd:`printscreen` and :kbd:`menu` keys. - Key bindings like ``bind shift-A`` are no longer accepted; use ``bind shift-a`` or ``bind A``. -- Key bindings like ``bind shift-a`` take precedence over ``bind A``, assuming that the key event included the shift modifier. +- Key bindings like ``bind shift-a`` take precedence over ``bind A`` when the key event included the shift modifier. - Bindings using shift with non-ASCII letters (such as :kbd:`ctrl-shift-Γ€`) are now supported. - Bindings with modifiers such as ``bind ctrl-w`` work again on non-Latin keyboard layouts such as a Russian one. This is implemented by allowing key events such as :kbd:`ctrl-Ρ†` to match bindings of the corresponding Latin key, using the kitty keyboard protocol's base layout key (:issue:`11520`). -- For a long time now, fish has been "relocatable" -- it was possible to move the entire ``CMAKE_INSTALL_PREFIX`` and fish would use paths relative to its binary. - Only gettext locale paths were still purely determined at compile time, which has been fixed. -- ``set fish_complete_path ...`` accidentally disabled completion autoloading, which has been corrected. +- For many years, fish has been "relocatable" -- it was possible to move the entire ``CMAKE_INSTALL_PREFIX`` and fish would use paths relative to its binary. + Only gettext locale paths were still determined purely at compile time, which has been fixed. +- Commands like ``set fish_complete_path ...`` accidentally disabled completion autoloading, which has been corrected. - ``nmcli`` completions have been fixed to query network information dynamically instead of only when completing the first time. -- Git completions no longer print an error when no `git-*` executable is in :envvar:`PATH`. -- the :doc:`commandline ` builtin fails to print the commandline set by a ``commandline -C`` invocation, which broken some completion scripts. +- Git completions no longer print an error when no `git-foo` executable is in :envvar:`PATH`. +- the :doc:`commandline ` builtin failed to print the commandline set by a ``commandline -C`` invocation, which broke some completion scripts. This has been corrected (:issue:`11423`). - Custom completions like ``complete foo -l long -xa ...`` that use the output of ``commandline -t``. on a command-line like ``foo --long=`` have been invalidated by a change in 4.0; the completion scripts have been adjusted accordingly (:issue:`11508`). -- Some completions were misinterpreted, which broke the completion list. This has been fixed. +- Some completions were misinterpreted, which caused garbage to be displayed in the completion list. This has been fixed. - The routines to save history and universal variables have seen some robustness improvements. - Vi mode: The cursor position after pasting via :kbd:`p` has been corrected. - Vi mode: Trying to replace the last character via :kbd:`r` no longer replaces the last-but-one character (:issue:`11484`), -- To work around terminals that fail to parse OSC sequences, a temporary feature flag has been added. +- To work around terminals that fail to parse Operating System Command (OSC) sequences, a temporary feature flag has been added. It allows you to disable prompt marking (OSC 133) by running (once) ``set -Ua fish_features no-mark-prompt`` and restarting fish (:issue:`11749`). fish 4.0.2 (released April 20, 2025) From 9ada3e6c16cbd01b84d074a909349db3e2465696 Mon Sep 17 00:00:00 2001 From: Johannes Altmanninger Date: Fri, 12 Sep 2025 11:13:54 +0200 Subject: [PATCH 68/70] Group changelog entries --- CHANGELOG.rst | 37 ++++++++++++++++++------------------- 1 file changed, 18 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index d125c3a99..e4320be0f 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -3,37 +3,36 @@ fish 4.0.3 (released ???) This release of fish fixes a number of issues identified in fish 4.0.2: -- :kbd:`alt-delete` now deletes the word right of the cursor. - fish now properly inherits $PATH under Windows WSL2 (:issue:`11354`). -- the :doc:`printf ` builtin no longer miscalculates width of multi-byte characters (:issue:`11412`). -- A crash displaying multi-line quoted command substitutions has been fixed (:issue:`11444`). - Remote filesystems are detected properly again on non-Linux systems. +- the :doc:`printf ` builtin no longer miscalculates width of multi-byte characters (:issue:`11412`). +- For many years, fish has been "relocatable" -- it was possible to move the entire ``CMAKE_INSTALL_PREFIX`` and fish would use paths relative to its binary. + Only gettext locale paths were still determined purely at compile time, which has been fixed. +- the :doc:`commandline ` builtin failed to print the commandline set by a ``commandline -C`` invocation, which broke some completion scripts. + This has been corrected (:issue:`11423`). +- To work around terminals that fail to parse Operating System Command (OSC) sequences, a temporary feature flag has been added. + It allows you to disable prompt marking (OSC 133) by running (once) ``set -Ua fish_features no-mark-prompt`` and restarting fish (:issue:`11749`). +- The routines to save history and universal variables have seen some robustness improvements. - builtin :doc:`status current-command ` no longer prints a trailing blank line. -- A workaround has been added to prevent spurious output inside old versions of Midnight Commander. -- :kbd:`ctrl-alt-h` erases the last word again (:issue:`11548`). -- :kbd:`alt-left` :kbd:`alt-right` were misinterpreted because they send unexpected sequences on some terminals; a workaround has been added. (:issue:`11479`). +- A crash displaying multi-line quoted command substitutions has been fixed (:issue:`11444`). +- Commands like ``set fish_complete_path ...`` accidentally disabled completion autoloading, which has been corrected. +- ``nmcli`` completions have been fixed to query network information dynamically instead of only when completing the first time. +- Git completions no longer print an error when no `git-foo` executable is in :envvar:`PATH`. +- Custom completions like ``complete foo -l long -xa ...`` that use the output of ``commandline -t``. + on a command-line like ``foo --long=`` have been invalidated by a change in 4.0; the completion scripts have been adjusted accordingly (:issue:`11508`). +- Some completions were misinterpreted, which caused garbage to be displayed in the completion list. This has been fixed. - fish no longer interprets invalid control sequences from the terminal as if they were :kbd:`alt-[` or :kbd:`alt-o` key strokes. - :doc:`bind ` has been taught about the :kbd:`printscreen` and :kbd:`menu` keys. +- :kbd:`alt-delete` now deletes the word right of the cursor. +- :kbd:`ctrl-alt-h` erases the last word again (:issue:`11548`). +- :kbd:`alt-left` :kbd:`alt-right` were misinterpreted because they send unexpected sequences on some terminals; a workaround has been added. (:issue:`11479`). - Key bindings like ``bind shift-A`` are no longer accepted; use ``bind shift-a`` or ``bind A``. - Key bindings like ``bind shift-a`` take precedence over ``bind A`` when the key event included the shift modifier. - Bindings using shift with non-ASCII letters (such as :kbd:`ctrl-shift-Γ€`) are now supported. - Bindings with modifiers such as ``bind ctrl-w`` work again on non-Latin keyboard layouts such as a Russian one. This is implemented by allowing key events such as :kbd:`ctrl-Ρ†` to match bindings of the corresponding Latin key, using the kitty keyboard protocol's base layout key (:issue:`11520`). -- For many years, fish has been "relocatable" -- it was possible to move the entire ``CMAKE_INSTALL_PREFIX`` and fish would use paths relative to its binary. - Only gettext locale paths were still determined purely at compile time, which has been fixed. -- Commands like ``set fish_complete_path ...`` accidentally disabled completion autoloading, which has been corrected. -- ``nmcli`` completions have been fixed to query network information dynamically instead of only when completing the first time. -- Git completions no longer print an error when no `git-foo` executable is in :envvar:`PATH`. -- the :doc:`commandline ` builtin failed to print the commandline set by a ``commandline -C`` invocation, which broke some completion scripts. - This has been corrected (:issue:`11423`). -- Custom completions like ``complete foo -l long -xa ...`` that use the output of ``commandline -t``. - on a command-line like ``foo --long=`` have been invalidated by a change in 4.0; the completion scripts have been adjusted accordingly (:issue:`11508`). -- Some completions were misinterpreted, which caused garbage to be displayed in the completion list. This has been fixed. -- The routines to save history and universal variables have seen some robustness improvements. - Vi mode: The cursor position after pasting via :kbd:`p` has been corrected. - Vi mode: Trying to replace the last character via :kbd:`r` no longer replaces the last-but-one character (:issue:`11484`), -- To work around terminals that fail to parse Operating System Command (OSC) sequences, a temporary feature flag has been added. - It allows you to disable prompt marking (OSC 133) by running (once) ``set -Ua fish_features no-mark-prompt`` and restarting fish (:issue:`11749`). fish 4.0.2 (released April 20, 2025) ==================================== From e2005c64b3ac2589b8e8a6f37ae311811caf4316 Mon Sep 17 00:00:00 2001 From: Johannes Altmanninger Date: Fri, 12 Sep 2025 10:44:02 +0200 Subject: [PATCH 69/70] Backport release-script related changes from master Will commit these to master momentarily (#10449). --- .editorconfig | 14 +- .../action.yml | 20 ++ .../actions/rust-toolchain@stable/action.yml | 20 ++ .github/workflows/mac_codesign.yml | 42 ---- .github/workflows/release.yml | 211 ++++++++++++++++++ .github/workflows/staticbuild.yml | 47 ---- build_tools/make_macos_pkg.sh | 183 +++++++++++++++ build_tools/make_pkg.sh | 70 +++--- build_tools/release.sh | 176 +++++++++++++++ cmake/MacApp.cmake | 4 +- doc_src/conf.py | 23 +- 11 files changed, 675 insertions(+), 135 deletions(-) create mode 100644 .github/actions/rust-toolchain@oldest-supported/action.yml create mode 100644 .github/actions/rust-toolchain@stable/action.yml delete mode 100644 .github/workflows/mac_codesign.yml create mode 100644 .github/workflows/release.yml delete mode 100644 .github/workflows/staticbuild.yml create mode 100755 build_tools/make_macos_pkg.sh create mode 100755 build_tools/release.sh diff --git a/.editorconfig b/.editorconfig index 053c57353..a753582e0 100644 --- a/.editorconfig +++ b/.editorconfig @@ -13,16 +13,20 @@ max_line_length = 100 indent_style = tab [*.{md,rst}] +max_line_length = unset trim_trailing_whitespace = false -[*.{sh,ac}] -indent_size = 2 +[*.sh] +indent_size = 4 + +[build_tools/release.sh] +max_line_length = 72 [Dockerfile] indent_size = 2 [share/{completions,functions}/**.fish] -max_line_length = off +max_line_length = unset -[{COMMIT_EDITMSG,git-revise-todo}] -max_line_length = 80 +[{COMMIT_EDITMSG,git-revise-todo,*.jjdescription}] +max_line_length = 72 diff --git a/.github/actions/rust-toolchain@oldest-supported/action.yml b/.github/actions/rust-toolchain@oldest-supported/action.yml new file mode 100644 index 000000000..5bde7cf45 --- /dev/null +++ b/.github/actions/rust-toolchain@oldest-supported/action.yml @@ -0,0 +1,20 @@ +name: Oldest Supported Rust Toolchain + +inputs: + targets: + description: Comma-separated list of target triples to install for this toolchain + required: false + components: + description: Comma-separated list of components to be additionally installed + required: false + +permissions: + contents: read + +runs: + using: "composite" + steps: + - uses: dtolnay/rust-toolchain@1.70 + with: + targets: ${{ inputs.targets }} + components: ${{ inputs.components}} diff --git a/.github/actions/rust-toolchain@stable/action.yml b/.github/actions/rust-toolchain@stable/action.yml new file mode 100644 index 000000000..e3d4bd0e5 --- /dev/null +++ b/.github/actions/rust-toolchain@stable/action.yml @@ -0,0 +1,20 @@ +name: Stable Rust Toolchain + +inputs: + targets: + description: Comma-separated list of target triples to install for this toolchain + required: false + components: + description: Comma-separated list of components to be additionally installed + required: false + +permissions: + contents: read + +runs: + using: "composite" + steps: + - uses: dtolnay/rust-toolchain@1.89 + with: + targets: ${{ inputs.targets }} + components: ${{ inputs.components }} diff --git a/.github/workflows/mac_codesign.yml b/.github/workflows/mac_codesign.yml deleted file mode 100644 index 10ebbaa58..000000000 --- a/.github/workflows/mac_codesign.yml +++ /dev/null @@ -1,42 +0,0 @@ -name: macOS build and codesign - -on: - workflow_dispatch: # Enables manual trigger from GitHub UI - -jobs: - build-and-code-sign: - runs-on: macos-latest - environment: macos-codesign - steps: - - uses: actions/checkout@v4 - - name: Install Rust 1.73.0 - uses: dtolnay/rust-toolchain@1.73.0 - with: - targets: x86_64-apple-darwin - - name: Install Rust Stable - uses: dtolnay/rust-toolchain@stable - with: - targets: aarch64-apple-darwin - - name: build-and-codesign - run: | - cargo install apple-codesign - mkdir -p "$FISH_ARTEFACT_PATH" - echo "$MAC_CODESIGN_APP_P12_BASE64" | base64 --decode > /tmp/app.p12 - echo "$MAC_CODESIGN_INSTALLER_P12_BASE64" | base64 --decode > /tmp/installer.p12 - echo "$MACOS_NOTARIZE_JSON" > /tmp/notarize.json - ./build_tools/make_pkg.sh -s -f /tmp/app.p12 -i /tmp/installer.p12 -p "$MAC_CODESIGN_PASSWORD" -n -j /tmp/notarize.json - rm /tmp/installer.p12 /tmp/app.p12 /tmp/notarize.json - env: - MAC_CODESIGN_APP_P12_BASE64: ${{ secrets.MAC_CODESIGN_APP_P12_BASE64 }} - MAC_CODESIGN_INSTALLER_P12_BASE64: ${{ secrets.MAC_CODESIGN_INSTALLER_P12_BASE64 }} - MAC_CODESIGN_PASSWORD: ${{ secrets.MAC_CODESIGN_PASSWORD }} - MACOS_NOTARIZE_JSON: ${{ secrets.MACOS_NOTARIZE_JSON }} - # macOS runners keep having issues loading Cargo.toml dependencies from git (GitHub) instead - # of crates.io, so give this a try. It's also sometimes significantly faster on all platforms. - CARGO_NET_GIT_FETCH_WITH_CLI: true - FISH_ARTEFACT_PATH: /tmp/fish-built - - uses: actions/upload-artifact@v4 - with: - name: macOS Artefacts - path: /tmp/fish-built/* - if-no-files-found: error diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000..2d3b1f34f --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,211 @@ +name: Create a new release + +on: + push: + tags: + - '*.*.*' + +permissions: + contents: write + +jobs: + is-release-tag: + name: Pre-release checks + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + # Workaround for https://github.com/actions/checkout/issues/882 + ref: ${{ github.ref }} + - name: Check if the pushed tag looks like a release + run: | + set -x + commit_subject=$(git log -1 --format=%s) + tag=$(git describe) + [ "$commit_subject" = "Release $tag" ] + + + source-tarball: + needs: [is-release-tag] + name: Create the source tarball + runs-on: ubuntu-latest + outputs: + version: ${{ steps.version.outputs.version }} + tarball-name: ${{ steps.version.outputs.tarball-name }} + steps: + - uses: actions/checkout@v4 + with: + # Workaround for https://github.com/actions/checkout/issues/882 + ref: ${{ github.ref }} + - name: Install dependencies + run: sudo apt install cmake gettext ninja-build python3-pip python3-sphinx + - name: Create tarball + run: | + set -x + mkdir /tmp/fish-built + FISH_ARTEFACT_PATH=/tmp/fish-built ./build_tools/make_tarball.sh + { + pip install sphinx-markdown-builder==0.6.8 + relnotes_tmp=$(mktemp -d) + mkdir "$relnotes_tmp/src" "$relnotes_tmp/out" + version=$(git describe) + minor_version=${version%.*} + # Delete notes for prior releases. + # Also fix up any relative references to other documentation files. + awk "$relnotes_tmp/src"/index.rst \ + -e 's,:doc:`\(.*\) <\([^>]*\)>`,`\1 `_,g' \ + -e 's,:envvar:`\([^`]*\)`,``$\1``,g' + # In future, we could reuse doctree from when we made HTML docs. + sphinx-build -j 1 $(: "sphinx-markdown-builder is not marked concurrency-safe") \ + -W -E -b markdown -c doc_src \ + -d "$relnotes_tmp/doctree" "$relnotes_tmp/src" $relnotes_tmp/out + # Delete title + sed -n 1p "$relnotes_tmp/out/index.md" | grep -q "^# fish .*" + sed -n 2p "$relnotes_tmp/out/index.md" | grep -q '^$' + sed -i 1,2d "$relnotes_tmp/out/index.md" + { + cat "$relnotes_tmp/out/index.md" - </tmp/fish-built/release-notes.md + rm -r "$relnotes_tmp" + } + - name: Upload tarball artifact + uses: actions/upload-artifact@v4 + with: + name: source-tarball + path: | + /tmp/fish-built/fish-${{ github.ref_name }}.tar.xz + /tmp/fish-built/release-notes.md + if-no-files-found: error + + packages-for-linux: + needs: [is-release-tag] + name: Build single-file fish for Linux (experimental) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + # Workaround for https://github.com/actions/checkout/issues/882 + ref: ${{ github.ref }} + - name: Install Rust Stable + uses: ./.github/actions/rust-toolchain@stable + with: + targets: x86_64-unknown-linux-musl,aarch64-unknown-linux-musl + - name: Install dependencies + run: sudo apt install crossbuild-essential-arm64 musl-tools python3-sphinx + - name: Build statically-linked executables + run: | + set -x + CFLAGS="-D_FORTIFY_SOURCE=2" \ + CMAKE_WITH_GETTEXT=0 \ + CC=aarch64-linux-gnu-gcc \ + RUSTFLAGS="-C linker=aarch64-linux-gnu-gcc -C link-arg=-lgcc -C link-arg=-D_FORTIFY_SOURCE=0" \ + cargo build --release --target aarch64-unknown-linux-musl --bin fish + cargo build --release --target x86_64-unknown-linux-musl --bin fish + - name: Compress + run: | + set -x + for arch in x86_64 aarch64; do + tar -cazf fish-$(git describe)-linux-$arch.tar.xz \ + -C target/$arch-unknown-linux-musl/release fish + done + - uses: actions/upload-artifact@v4 + with: + name: Static builds for Linux + path: fish-${{ github.ref_name }}-linux-*.tar.xz + if-no-files-found: error + + create-draft-release: + needs: + - is-release-tag + - source-tarball + - packages-for-linux + name: Create release draft + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + # Workaround for https://github.com/actions/checkout/issues/882 + ref: ${{ github.ref }} + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + merge-multiple: true + path: /tmp/artifacts + - name: List artifacts + run: find /tmp/artifacts -type f + - name: Create draft release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.ref_name }} + name: fish ${{ github.ref_name }} + body_path: /tmp/artifacts/release-notes.md + draft: true + files: | + /tmp/artifacts/fish-${{ github.ref_name }}.tar.xz + /tmp/artifacts/fish-${{ github.ref_name }}-linux-*.tar.xz + + packages-for-macos: + needs: [is-release-tag, create-draft-release] + name: Build packages for macOS + runs-on: macos-latest + environment: macos-codesign + steps: + - uses: actions/checkout@v4 + with: + # Workaround for https://github.com/actions/checkout/issues/882 + ref: ${{ github.ref }} + - name: Install Rust + uses: ./.github/actions/rust-toolchain@oldest-supported + with: + targets: x86_64-apple-darwin + - name: Install Rust Stable + uses: ./.github/actions/rust-toolchain@stable + with: + targets: aarch64-apple-darwin + - name: Build and codesign + run: | + die() { echo >&2 "$*"; exit 1; } + [ -n "$MAC_CODESIGN_APP_P12_BASE64" ] || die "Missing MAC_CODESIGN_APP_P12_BASE64" + [ -n "$MAC_CODESIGN_INSTALLER_P12_BASE64" ] || die "Missing MAC_CODESIGN_INSTALLER_P12_BASE64" + [ -n "$MAC_CODESIGN_PASSWORD" ] || die "Missing MAC_CODESIGN_PASSWORD" + [ -n "$MACOS_NOTARIZE_JSON" ] || die "Missing MACOS_NOTARIZE_JSON" + set -x + export FISH_ARTEFACT_PATH=/tmp/fish-built + # macOS runners keep having issues loading Cargo.toml dependencies from git (GitHub) instead + # of crates.io, so give this a try. It's also sometimes significantly faster on all platforms. + export CARGO_NET_GIT_FETCH_WITH_CLI=true + cargo install apple-codesign + mkdir -p "$FISH_ARTEFACT_PATH" + echo "$MAC_CODESIGN_APP_P12_BASE64" | base64 --decode >/tmp/app.p12 + echo "$MAC_CODESIGN_INSTALLER_P12_BASE64" | base64 --decode >/tmp/installer.p12 + echo "$MACOS_NOTARIZE_JSON" >/tmp/notarize.json + ./build_tools/make_macos_pkg.sh -s -f /tmp/app.p12 \ + -i /tmp/installer.p12 -p "$MAC_CODESIGN_PASSWORD" \ + -n -j /tmp/notarize.json + [ -f "${FISH_ARTEFACT_PATH}/fish-${{ github.ref_name }}.app.zip" ] + [ -f "${FISH_ARTEFACT_PATH}/fish-${{ github.ref_name }}.pkg" ] + rm /tmp/installer.p12 /tmp/app.p12 /tmp/notarize.json + env: + MAC_CODESIGN_APP_P12_BASE64: ${{ secrets.MAC_CODESIGN_APP_P12_BASE64 }} + MAC_CODESIGN_INSTALLER_P12_BASE64: ${{ secrets.MAC_CODESIGN_INSTALLER_P12_BASE64 }} + MAC_CODESIGN_PASSWORD: ${{ secrets.MAC_CODESIGN_PASSWORD }} + MACOS_NOTARIZE_JSON: ${{ secrets.MACOS_NOTARIZE_JSON }} + - name: Add macOS packages to the release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release upload $(git describe) \ + /tmp/fish-built/fish-${{ github.ref_name }}.app.zip \ + /tmp/fish-built/fish-${{ github.ref_name }}.pkg diff --git a/.github/workflows/staticbuild.yml b/.github/workflows/staticbuild.yml deleted file mode 100644 index da8db230a..000000000 --- a/.github/workflows/staticbuild.yml +++ /dev/null @@ -1,47 +0,0 @@ -name: staticbuilds - -on: - # release: - # types: [published] - # schedule: - # - cron: "14 13 * * *" - workflow_dispatch: - -env: - CTEST_PARALLEL_LEVEL: "1" - CMAKE_BUILD_PARALLEL_LEVEL: "4" - -jobs: - staticbuilds: - - runs-on: ubuntu-latest - - permissions: - contents: read - - steps: - - uses: dtolnay/rust-toolchain@1.70 - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - name: Prepare - run: | - sudo apt install python3-sphinx - rustup target add x86_64-unknown-linux-musl - rustup target add aarch64-unknown-linux-musl - sudo apt install musl-tools crossbuild-essential-arm64 -y - - name: Build - run: | - CFLAGS="$CFLAGS -D_FORTIFY_SOURCE=2" CMAKE_WITH_GETTEXT=0 CC=aarch64-linux-gnu-gcc RUSTFLAGS="-C linker=aarch64-linux-gnu-gcc -C link-arg=-lgcc -C link-arg=-D_FORTIFY_SOURCE=0" cargo build --release --target aarch64-unknown-linux-musl - cargo build --release --target x86_64-unknown-linux-musl - - name: Compress - run: | - tar -cazf fish-amd64.tar.xz -C target/x86_64-unknown-linux-musl/release/ fish{,_indent,_key_reader} - tar -cazf fish-aarch64.tar.xz -C target/aarch64-unknown-linux-musl/release/ fish{,_indent,_key_reader} - - uses: actions/upload-artifact@v4 - with: - name: fish - path: | - fish-amd64.tar.xz - fish-aarch64.tar.xz - retention-days: 14 diff --git a/build_tools/make_macos_pkg.sh b/build_tools/make_macos_pkg.sh new file mode 100755 index 000000000..782e1e848 --- /dev/null +++ b/build_tools/make_macos_pkg.sh @@ -0,0 +1,183 @@ +#!/usr/bin/env bash + +# Script to produce an OS X installer .pkg and .app(.zip) + +usage() { + echo "Build macOS packages, optionally signing and notarizing them." + echo "Usage: $0 options" + echo "Options:" + echo " -s Enables code signing" + echo " -f Path to .p12 file for application signing" + echo " -i Path to .p12 file for installer signing" + echo " -p Password for the .p12 files (necessary to access the certificates)" + echo " -e (Optional) Path to an entitlements XML file" + echo " -n Enables notarization. This will fail if code signing is not also enabled." + echo " -j Path to JSON file generated with \`rcodesign encode-app-store-connect-api-key\` (required for notarization)" + echo + exit 1 +} + +set -x +set -e + +SIGN= +NOTARIZE= + +ARM64_DEPLOY_TARGET='MACOSX_DEPLOYMENT_TARGET=11.0' +X86_64_DEPLOY_TARGET='MACOSX_DEPLOYMENT_TARGET=10.9' + +# As of this writing, the most recent Rust release supports macOS back to 10.12. +# The first supported version of macOS on arm64 is 10.15, so any Rust is fine for arm64. +# We wish to support back to 10.9 on x86-64; the last version of Rust to support that is +# version 1.73.0. +RUST_VERSION_X86_64=1.70.0 + +while getopts "sf:i:p:e:nj:" opt; do + case $opt in + s) SIGN=1;; + f) P12_APP_FILE=$(realpath "$OPTARG");; + i) P12_INSTALL_FILE=$(realpath "$OPTARG");; + p) P12_PASSWORD="$OPTARG";; + e) ENTITLEMENTS_FILE=$(realpath "$OPTARG");; + n) NOTARIZE=1;; + j) API_KEY_FILE=$(realpath "$OPTARG");; + \?) usage;; + esac +done + +if [ -n "$SIGN" ] && { [ -z "$P12_APP_FILE" ] || [ -z "$P12_INSTALL_FILE" ] || [ -z "$P12_PASSWORD" ]; }; then + usage +fi + +if [ -n "$NOTARIZE" ] && [ -z "$API_KEY_FILE" ]; then + usage +fi + +VERSION=$(git describe --always --dirty 2>/dev/null) +if test -z "$VERSION" ; then + echo "Could not get version from git" + if test -f version; then + VERSION=$(cat version) + fi +fi + +echo "Version is $VERSION" + +PKGDIR=$(mktemp -d) +echo "$PKGDIR" + +SRC_DIR=$PWD +OUTPUT_PATH=${FISH_ARTEFACT_PATH:-~/fish_built} + +mkdir -p "$PKGDIR/build_x86_64" "$PKGDIR/build_arm64" "$PKGDIR/root" "$PKGDIR/intermediates" "$PKGDIR/dst" + +# Build and install for arm64. +# Pass FISH_USE_SYSTEM_PCRE2=OFF because a system PCRE2 on macOS will not be signed by fish, +# and will probably not be built universal, so the package will fail to validate/run on other systems. +# Note CMAKE_OSX_ARCHITECTURES is still relevant for the Mac app. +{ cd "$PKGDIR/build_arm64" \ + && cmake \ + -DCMAKE_BUILD_TYPE=RelWithDebInfo \ + -DCMAKE_EXE_LINKER_FLAGS="-Wl,-ld_classic" \ + -DWITH_GETTEXT=OFF \ + -DRust_CARGO_TARGET=aarch64-apple-darwin \ + -DCMAKE_OSX_ARCHITECTURES='arm64;x86_64' \ + -DFISH_USE_SYSTEM_PCRE2=OFF \ + "$SRC_DIR" \ + && env $ARM64_DEPLOY_TARGET make VERBOSE=1 -j 12 \ + && env DESTDIR="$PKGDIR/root/" $ARM64_DEPLOY_TARGET make install; +} + +# Build for x86-64 but do not install; instead we will make some fat binaries inside the root. +# Set RUST_VERSION_X86_64 to the last version of Rust that supports macOS 10.9. +{ cd "$PKGDIR/build_x86_64" \ + && cmake \ + -DCMAKE_BUILD_TYPE=RelWithDebInfo \ + -DCMAKE_EXE_LINKER_FLAGS="-Wl,-ld_classic" \ + -DWITH_GETTEXT=OFF \ + -DRust_TOOLCHAIN="$RUST_VERSION_X86_64" \ + -DRust_CARGO_TARGET=x86_64-apple-darwin \ + -DCMAKE_OSX_ARCHITECTURES='arm64;x86_64' \ + -DFISH_USE_SYSTEM_PCRE2=OFF "$SRC_DIR" \ + && env $X86_64_DEPLOY_TARGET make VERBOSE=1 -j 12; } + +# Fatten them up. +for FILE in "$PKGDIR"/root/usr/local/bin/*; do + X86_FILE="$PKGDIR/build_x86_64/$(basename "$FILE")" + rcodesign macho-universal-create --output "$FILE" "$FILE" "$X86_FILE" + chmod 755 "$FILE" +done + +if test -n "$SIGN"; then + echo "Signing executables" + ARGS=( + --p12-file "$P12_APP_FILE" + --p12-password "$P12_PASSWORD" + --code-signature-flags runtime + --for-notarization + ) + if [ -n "$ENTITLEMENTS_FILE" ]; then + ARGS+=(--entitlements-xml-file "$ENTITLEMENTS_FILE") + fi + for FILE in "$PKGDIR"/root/usr/local/bin/*; do + (set +x; rcodesign sign "${ARGS[@]}" "$FILE") + done +fi + +pkgbuild --scripts "$SRC_DIR/build_tools/osx_package_scripts" --root "$PKGDIR/root/" --identifier 'com.ridiculousfish.fish-shell-pkg' --version "$VERSION" "$PKGDIR/intermediates/fish.pkg" +productbuild --package-path "$PKGDIR/intermediates" --distribution "$SRC_DIR/build_tools/osx_distribution.xml" --resources "$SRC_DIR/build_tools/osx_package_resources/" "$OUTPUT_PATH/fish-$VERSION.pkg" + +if test -n "$SIGN"; then + echo "Signing installer" + ARGS=( + --p12-file "$P12_INSTALL_FILE" + --p12-password "$P12_PASSWORD" + --code-signature-flags runtime + --for-notarization + ) + (set +x; rcodesign sign "${ARGS[@]}" "$OUTPUT_PATH/fish-$VERSION.pkg") +fi + +# Make the app +(cd "$PKGDIR/build_arm64" && env $ARM64_DEPLOY_TARGET make -j 12 fish_macapp) +(cd "$PKGDIR/build_x86_64" && env $X86_64_DEPLOY_TARGET make -j 12 fish_macapp) + +# Make the app's /usr/local/bin binaries universal. Note fish.app/Contents/MacOS/fish already is, courtesy of CMake. +cd "$PKGDIR/build_arm64" +for FILE in fish.app/Contents/Resources/base/usr/local/bin/*; do + X86_FILE="$PKGDIR/build_x86_64/fish.app/Contents/Resources/base/usr/local/bin/$(basename "$FILE")" + rcodesign macho-universal-create --output "$FILE" "$FILE" "$X86_FILE" + + # macho-universal-create screws up the permissions. + chmod 755 "$FILE" +done + +if test -n "$SIGN"; then + echo "Signing app" + ARGS=( + --p12-file "$P12_APP_FILE" + --p12-password "$P12_PASSWORD" + --code-signature-flags runtime + --for-notarization + ) + if [ -n "$ENTITLEMENTS_FILE" ]; then + ARGS+=(--entitlements-xml-file "$ENTITLEMENTS_FILE") + fi + (set +x; rcodesign sign "${ARGS[@]}" "fish.app") + +fi + +cp -R "fish.app" "$OUTPUT_PATH/fish-$VERSION.app" +cd "$OUTPUT_PATH" + +# Maybe notarize. +if test -n "$NOTARIZE"; then + echo "Notarizing" + rcodesign notarize --staple --wait --max-wait-seconds 1800 --api-key-file "$API_KEY_FILE" "$OUTPUT_PATH/fish-$VERSION.pkg" + rcodesign notarize --staple --wait --max-wait-seconds 1800 --api-key-file "$API_KEY_FILE" "$OUTPUT_PATH/fish-$VERSION.app" +fi + +# Zip it up. +zip -r "fish-$VERSION.app.zip" "fish-$VERSION.app" && rm -Rf "fish-$VERSION.app" + +rm -rf "$PKGDIR" diff --git a/build_tools/make_pkg.sh b/build_tools/make_pkg.sh index 6f70b63db..782e1e848 100755 --- a/build_tools/make_pkg.sh +++ b/build_tools/make_pkg.sh @@ -3,18 +3,18 @@ # Script to produce an OS X installer .pkg and .app(.zip) usage() { - echo "Build macOS packages, optionally signing and notarizing them." - echo "Usage: $0 options" - echo "Options:" - echo " -s Enables code signing" - echo " -f Path to .p12 file for application signing" - echo " -i Path to .p12 file for installer signing" - echo " -p Password for the .p12 files (necessary to access the certificates)" - echo " -e (Optional) Path to an entitlements XML file" - echo " -n Enables notarization. This will fail if code signing is not also enabled." - echo " -j Path to JSON file generated with `rcodesign encode-app-store-connect-api-key` (required for notarization)" - echo - exit 1 + echo "Build macOS packages, optionally signing and notarizing them." + echo "Usage: $0 options" + echo "Options:" + echo " -s Enables code signing" + echo " -f Path to .p12 file for application signing" + echo " -i Path to .p12 file for installer signing" + echo " -p Password for the .p12 files (necessary to access the certificates)" + echo " -e (Optional) Path to an entitlements XML file" + echo " -n Enables notarization. This will fail if code signing is not also enabled." + echo " -j Path to JSON file generated with \`rcodesign encode-app-store-connect-api-key\` (required for notarization)" + echo + exit 1 } set -x @@ -30,35 +30,35 @@ X86_64_DEPLOY_TARGET='MACOSX_DEPLOYMENT_TARGET=10.9' # The first supported version of macOS on arm64 is 10.15, so any Rust is fine for arm64. # We wish to support back to 10.9 on x86-64; the last version of Rust to support that is # version 1.73.0. -RUST_VERSION_X86_64=1.73.0 +RUST_VERSION_X86_64=1.70.0 while getopts "sf:i:p:e:nj:" opt; do - case $opt in - s) SIGN=1;; - f) P12_APP_FILE=$(realpath "$OPTARG");; - i) P12_INSTALL_FILE=$(realpath "$OPTARG");; - p) P12_PASSWORD="$OPTARG";; - e) ENTITLEMENTS_FILE=$(realpath "$OPTARG");; - n) NOTARIZE=1;; - j) API_KEY_FILE=$(realpath "$OPTARG");; - \?) usage;; - esac + case $opt in + s) SIGN=1;; + f) P12_APP_FILE=$(realpath "$OPTARG");; + i) P12_INSTALL_FILE=$(realpath "$OPTARG");; + p) P12_PASSWORD="$OPTARG";; + e) ENTITLEMENTS_FILE=$(realpath "$OPTARG");; + n) NOTARIZE=1;; + j) API_KEY_FILE=$(realpath "$OPTARG");; + \?) usage;; + esac done -if [ -n "$SIGN" ] && ([ -z "$P12_APP_FILE" ] || [-z "$P12_INSTALL_FILE"] || [ -z "$P12_PASSWORD" ]); then - usage +if [ -n "$SIGN" ] && { [ -z "$P12_APP_FILE" ] || [ -z "$P12_INSTALL_FILE" ] || [ -z "$P12_PASSWORD" ]; }; then + usage fi if [ -n "$NOTARIZE" ] && [ -z "$API_KEY_FILE" ]; then - usage + usage fi VERSION=$(git describe --always --dirty 2>/dev/null) if test -z "$VERSION" ; then - echo "Could not get version from git" - if test -f version; then - VERSION=$(cat version) - fi + echo "Could not get version from git" + if test -f version; then + VERSION=$(cat version) + fi fi echo "Version is $VERSION" @@ -76,7 +76,7 @@ mkdir -p "$PKGDIR/build_x86_64" "$PKGDIR/build_arm64" "$PKGDIR/root" "$PKGDIR/in # and will probably not be built universal, so the package will fail to validate/run on other systems. # Note CMAKE_OSX_ARCHITECTURES is still relevant for the Mac app. { cd "$PKGDIR/build_arm64" \ - && cmake \ + && cmake \ -DCMAKE_BUILD_TYPE=RelWithDebInfo \ -DCMAKE_EXE_LINKER_FLAGS="-Wl,-ld_classic" \ -DWITH_GETTEXT=OFF \ @@ -91,7 +91,7 @@ mkdir -p "$PKGDIR/build_x86_64" "$PKGDIR/build_arm64" "$PKGDIR/root" "$PKGDIR/in # Build for x86-64 but do not install; instead we will make some fat binaries inside the root. # Set RUST_VERSION_X86_64 to the last version of Rust that supports macOS 10.9. { cd "$PKGDIR/build_x86_64" \ - && cmake \ + && cmake \ -DCMAKE_BUILD_TYPE=RelWithDebInfo \ -DCMAKE_EXE_LINKER_FLAGS="-Wl,-ld_classic" \ -DWITH_GETTEXT=OFF \ @@ -99,11 +99,11 @@ mkdir -p "$PKGDIR/build_x86_64" "$PKGDIR/build_arm64" "$PKGDIR/root" "$PKGDIR/in -DRust_CARGO_TARGET=x86_64-apple-darwin \ -DCMAKE_OSX_ARCHITECTURES='arm64;x86_64' \ -DFISH_USE_SYSTEM_PCRE2=OFF "$SRC_DIR" \ - && env $X86_64_DEPLOY_TARGET make VERBOSE=1 -j 12; } + && env $X86_64_DEPLOY_TARGET make VERBOSE=1 -j 12; } # Fatten them up. for FILE in "$PKGDIR"/root/usr/local/bin/*; do - X86_FILE="$PKGDIR/build_x86_64/$(basename $FILE)" + X86_FILE="$PKGDIR/build_x86_64/$(basename "$FILE")" rcodesign macho-universal-create --output "$FILE" "$FILE" "$X86_FILE" chmod 755 "$FILE" done @@ -145,7 +145,7 @@ fi # Make the app's /usr/local/bin binaries universal. Note fish.app/Contents/MacOS/fish already is, courtesy of CMake. cd "$PKGDIR/build_arm64" for FILE in fish.app/Contents/Resources/base/usr/local/bin/*; do - X86_FILE="$PKGDIR/build_x86_64/fish.app/Contents/Resources/base/usr/local/bin/$(basename $FILE)" + X86_FILE="$PKGDIR/build_x86_64/fish.app/Contents/Resources/base/usr/local/bin/$(basename "$FILE")" rcodesign macho-universal-create --output "$FILE" "$FILE" "$X86_FILE" # macho-universal-create screws up the permissions. diff --git a/build_tools/release.sh b/build_tools/release.sh new file mode 100755 index 000000000..37fed3d55 --- /dev/null +++ b/build_tools/release.sh @@ -0,0 +1,176 @@ +#!/bin/sh + +{ + +set -ex + +version=$1 +repository_owner=fish-shell +remote=origin +if [ -n "$2" ]; then + set -u + repository_owner=$2 + remote=$3 + set +u + [ $# -eq 3 ] +fi + +[ -n "$version" ] + +for tool in \ + bundle \ + gh \ + ruby \ + timeout \ +; do + if ! command -v "$tool" >/dev/null; then + echo >&2 "$0: missing command: $1" + exit 1 + fi +done + +repo_root="$(dirname "$0")/.." +fish_site=$repo_root/../fish-site + +for path in . "$fish_site" +do + if ! git -C "$path" diff HEAD --quiet; then + echo >&2 "$0: index and worktree must be clean" + exit 1 + fi +done + +if git tag | grep -qxF "$version"; then + echo >&2 "$0: tag $version already exists" + exit 1 +fi + +sed -n 1p CHANGELOG.rst | grep -q '^fish .*(released ???)$' +sed -n 2p CHANGELOG.rst | grep -q '^===*$' + +changelog_title="fish $version (released $(date +'%B %d, %Y'))" +sed -i \ + -e "1c$changelog_title" \ + -e "2c$(printf %s "$changelog_title" | sed s/./=/g)" \ + CHANGELOG.rst + +CommitVersion() { + sed -i "s/^version = \".*\"/version = \"$1\"/g" Cargo.toml + cargo fetch --offline + git add CHANGELOG.rst Cargo.toml Cargo.lock + git commit -m "$2 + +Created by ./build_tools/release.sh $version" +} + +CommitVersion "$version" "Release $version" + +# N.B. this is not GPG-signed. +git tag --annotate --message="Release $version" $version + +git push $remote $version + +gh() { + command gh --repo "$repository_owner/fish-shell" "$@" +} + +run_id= +while [ -z "$run_id" ] && sleep 5 +do + run_id=$(gh run list \ + --json=databaseId --jq=.[].databaseId \ + --workflow=release.yml --limit=1 \ + --commit="$(git rev-parse "$version^{commit}")") +done + +# Update fishshell.com +tag_oid=$(git rev-parse "$version") +tmpdir=$(mktemp -d) +while ! \ + gh release download "$version" --dir="$tmpdir" \ + --pattern="fish-$version.tar.xz" +do + timeout 30 gh run watch "$run_id" ||: +done +actual_tag_oid=$(git ls-remote "$remote" | + awk '$2 == "refs/tags/'"$version"'" { print $1 }') +[ "$tag_oid" = "$actual_tag_oid" ] +tar -C "$tmpdir" xf fish-$version.tar.xz +minor_version=${version%.*} +CopyDocs() { + rm -rf "fish-site/site/docs/$1" + cp -r "$tmpdir/fish-$version/user_doc/html" "fish-site/site/docs/$1" + git -C fish-site add "site/docs/$1" +} +CopyDocs "$minor_version" +latest_release=$( + releases=$(git tag | grep '^[0-9]*\.[0-9]*\.[0-9]*.*' | + sed $(: "De-prioritize release candidates (1.2.3-rc0)") \ + 's/-/~/g' | LC_ALL=C sort --version-sort) + printf %s\\n "$releases" | tail -1 +) +if [ "$version" = "$latest_release" ]; then + CopyDocs current +fi +rm -rf "$tmpdir" +( + cd "$fish_site" + make new-release + git add -u + git commit --message="$(printf %s "\ + | Release $version + | + | Created by ../fish-shell/build_tools/release.sh + " | sed 's,^\s*| ,,')" +) + +# N.B. --exit-status doesn't fail reliably. +gh run view "$run_id" --verbose --log-failed --exit-status + +while { + ! draft=$(gh release view "$version" --json=isDraft --jq=.isDraft) \ + || [ "$draft" = true ] +} +do + sleep 20 +done + +( + cd "$fish_site" + git push git@github.com:$repository_owner/fish-site +) + +if git merge-base --is-ancestor $remote/master $version +then + git push $remote $version:master +else + # Probably on an integration branch. + # TODO Maybe push when that's safe (or move this to CI). + : +fi + +if [ "$repository_owner" = fish-shell ]; then { + mail=$(mktemp) + cat >$mail < +Subject: fish $version released + +See https://github.com/fish-shell/fish-shell/releases/tag/$version +EOF + git send-email --suppress-cc=all $mail + rm $mail +} fi + +changelog=$(cat - CHANGELOG.rst <CHANGELOG.rst +CommitVersion ${version}-snapshot "start new cycle" + +exit + +} diff --git a/cmake/MacApp.cmake b/cmake/MacApp.cmake index 9a2b5566c..afc86e95b 100644 --- a/cmake/MacApp.cmake +++ b/cmake/MacApp.cmake @@ -24,7 +24,7 @@ add_executable(fish_macapp EXCLUDE_FROM_ALL # Compute the version. Note this is done at generation time, not build time, # so cmake must be re-run after version changes for the app to be updated. But -# generally this will be run by make_pkg.sh which always re-runs cmake. +# generally this will be run by make_macos_pkg.sh which always re-runs cmake. execute_process( COMMAND ${CMAKE_CURRENT_SOURCE_DIR}/build_tools/git_version_gen.sh --stdout COMMAND cut -d- -f1 @@ -32,7 +32,7 @@ execute_process( OUTPUT_STRIP_TRAILING_WHITESPACE) -# Note CMake appends .app, so the real output name will be fish.app. +# Note CMake appends .app, so the real output name will be fish.app. # This target does not include the 'base' resource. set_target_properties(fish_macapp PROPERTIES OUTPUT_NAME "fish") diff --git a/doc_src/conf.py b/doc_src/conf.py index 21fbafd24..52ac617b7 100644 --- a/doc_src/conf.py +++ b/doc_src/conf.py @@ -10,9 +10,19 @@ import glob import os.path import subprocess import sys +from sphinx.highlighting import lexers from sphinx.errors import SphinxWarning from docutils import nodes +try: + import sphinx_markdown_builder + + extensions = [ + "sphinx_markdown_builder", + ] +except ImportError: + pass + # -- Helper functions -------------------------------------------------------- @@ -35,11 +45,14 @@ def issue_role(name, rawtext, text, lineno, inliner, options=None, content=None) return [link], [] +def remove_fish_indent_lexer(app): + if app.builder.name in ("man", "markdown"): + del lexers["fish-docs-samples"] + + # -- Load our extensions ------------------------------------------------- def setup(app): # Our own pygments lexers - from sphinx.highlighting import lexers - this_dir = os.path.dirname(os.path.realpath(__file__)) sys.path.insert(0, this_dir) from fish_indent_lexer import FishIndentLexer @@ -52,6 +65,8 @@ def setup(app): app.add_config_value("issue_url", default=None, rebuild="html") app.add_role("issue", issue_role) + app.connect("builder-inited", remove_fish_indent_lexer) + # The default language to assume highlight_language = "fish-docs-samples" @@ -59,7 +74,7 @@ highlight_language = "fish-docs-samples" # -- Project information ----------------------------------------------------- project = "fish-shell" -copyright = "2024, fish-shell developers" +copyright = "fish-shell developers" author = "fish-shell developers" issue_url = "https://github.com/fish-shell/fish-shell/issues" @@ -72,7 +87,7 @@ elif "FISH_BUILD_VERSION" in os.environ: ret = os.environ["FISH_BUILD_VERSION"] else: ret = subprocess.check_output( - ("fish_indent", "--version"), stderr=subprocess.STDOUT + ("../build_tools/git_version_gen.sh", "--stdout"), stderr=subprocess.STDOUT ).decode("utf-8") # The full version, including alpha/beta/rc tags From 7619fa316c1c93c997de032fa64c642a8b89f99a Mon Sep 17 00:00:00 2001 From: Johannes Altmanninger Date: Fri, 12 Sep 2025 11:47:41 +0200 Subject: [PATCH 70/70] Release 4.0.6 Created by ./build_tools/release.sh 4.0.6 --- CHANGELOG.rst | 4 ++-- Cargo.lock | 2 +- Cargo.toml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index e4320be0f..89b8ca321 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,5 +1,5 @@ -fish 4.0.3 (released ???) -==================================== +fish 4.0.6 (released September 12, 2025) +======================================== This release of fish fixes a number of issues identified in fish 4.0.2: diff --git a/Cargo.lock b/Cargo.lock index 924c17d2a..59d19e42d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -112,7 +112,7 @@ dependencies = [ [[package]] name = "fish" -version = "4.0.2" +version = "4.0.6" dependencies = [ "bitflags", "cc", diff --git a/Cargo.toml b/Cargo.toml index 240f348a8..cfff901f4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,7 +16,7 @@ debug = true [package] name = "fish" -version = "4.0.2" +version = "4.0.6" edition.workspace = true rust-version.workspace = true default-run = "fish"