hi", "
"),
+ ("usage: tty7 [opts]", ""),
+ ("From: Jo ok", ""),
+ ("map: HashMap here", ""),
+ ] {
+ let chars: Vec = text.chars().collect();
+ let click = chars.iter().position(|&c| c == '<').unwrap();
+ let (s, e) = bracket_range(&chars, click).unwrap_or_else(|| panic!("{text}"));
+ let got: String = chars[s..=e].iter().collect();
+ assert_eq!(got, want, "{text}");
+ }
+ // Comparison operators must not pair across half a line — a space just
+ // inside either end is the tell.
+ for text in [
+ "if a < b then c > d",
+ "awk '{ if ($1 > 100 && $2 < 5) print }'",
+ "WHERE a < 10 AND b > 20",
+ "empty <> pair",
+ ] {
+ let chars: Vec = text.chars().collect();
+ for (i, &c) in chars.iter().enumerate() {
+ if c == '<' || c == '>' {
+ assert_eq!(bracket_range(&chars, i), None, "{text} at {i}");
+ }
+ }
+ }
+ // Redirections never had a partner to match in the first place.
+ for text in ["cargo build 2>&1 | tee out", "grep -rn foo src/ > /tmp/o"] {
+ let chars: Vec = text.chars().collect();
+ for (i, &c) in chars.iter().enumerate() {
+ if c == '<' || c == '>' {
+ assert_eq!(bracket_range(&chars, i), None, "{text} at {i}");
+ }
+ }
+ }
+ }
+
#[test]
fn unmatched_bracket_yields_none() {
let chars: Vec = "f(a".chars().collect();
From 2dbeb21b19dc5bebc5e6365363378720deab6ba1 Mon Sep 17 00:00:00 2001
From: thomas
Date: Sun, 19 Jul 2026 18:49:08 +0800
Subject: [PATCH 5/5] fix(terminal): keep contraction apostrophes out of quote
pairing
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`quote_range` paired quotes by parity, so English prose broke it: in
`it's a test, isn't it` the apostrophe in `it's` reads as an opener and
pairs with the one in `isn't`, and a double-click on either selects
`'s a test, isn'` instead of the stock `it's`.
The existing comment anticipated this and claimed a missing match would
fall through to `None`, but that only holds when the line has a single
apostrophe — prose usually has two. This path also returns before the
`extends` guard, so there was no safety net.
Exclude contraction apostrophes (alphanumeric on both sides) throughout:
clicking one falls through to the stock word, and they count neither
toward the parity nor as a candidate match. `"` and `` ` `` are
unaffected — they don't occur inside words. Genuine possessives like
`the 'dogs' bark` still pair, since a delimiter always has a non-word
char or a line edge on one side.
Co-Authored-By: Claude Opus 4.8
---
src/terminal/smart_select.rs | 67 ++++++++++++++++++++++++++++++++----
1 file changed, 61 insertions(+), 6 deletions(-)
diff --git a/src/terminal/smart_select.rs b/src/terminal/smart_select.rs
index ad076369..d71cbf84 100644
--- a/src/terminal/smart_select.rs
+++ b/src/terminal/smart_select.rs
@@ -425,22 +425,45 @@ pub(super) fn pair_range(chars: &[char], click: usize) -> Option<(usize, usize)>
bracket_range(chars, click).or_else(|| quote_range(chars, click))
}
+/// Whether the `'` at `i` is a contraction apostrophe rather than a quote.
+///
+/// A delimiter has whitespace, punctuation, or a line edge on at least one
+/// side; a contraction is welded into a word on both (`it's`, `isn't`,
+/// `won't`). Only `'` needs this — `"` and `` ` `` don't appear inside words.
+fn is_contraction(chars: &[char], i: usize) -> bool {
+ if chars[i] != '\'' {
+ return false;
+ }
+ let flanked = |j: Option| {
+ j.and_then(|j| chars.get(j))
+ .is_some_and(|c| c.is_alphanumeric())
+ };
+ flanked(i.checked_sub(1)) && flanked(Some(i + 1))
+}
+
/// Select through a matching symmetric quote (`'`, `"`, `` ` ``). Open and
/// close are the same char, so direction comes from parity: an even count of
/// that quote before the click means it opens (match forward), odd means it
-/// closes (match backward). Apostrophes in prose skew the parity, but a
-/// missing match just falls through to `None`.
+/// closes (match backward).
+///
+/// Contraction apostrophes are excluded throughout — clicking one falls
+/// through to the stock word, and they count neither toward the parity nor as
+/// a candidate match. Without that, `it's a test, isn't it` pairs the two
+/// contractions and a double-click on either selects `'s a test, isn'`. This
+/// path returns before the `extends` guard that keeps other candidates
+/// additive (see [`pair_is_plausible`]), so a bad match here has no safety net.
pub(super) fn quote_range(chars: &[char], click: usize) -> Option<(usize, usize)> {
let q = *chars.get(click)?;
- if !SYMMETRIC_QUOTES.contains(&q) {
+ if !SYMMETRIC_QUOTES.contains(&q) || is_contraction(chars, click) {
return None;
}
- let before = chars[..click].iter().filter(|&&c| c == q).count();
+ let quote_at = |i: usize| chars[i] == q && !is_contraction(chars, i);
+ let before = (0..click).filter(|&i| quote_at(i)).count();
if before % 2 == 0 {
- let close = (click + 1..chars.len()).find(|&i| chars[i] == q)?;
+ let close = (click + 1..chars.len()).find(|&i| quote_at(i))?;
Some((click, close))
} else {
- let open = (0..click).rev().find(|&i| chars[i] == q)?;
+ let open = (0..click).rev().find(|&i| quote_at(i))?;
Some((open, click))
}
}
@@ -921,6 +944,38 @@ mod tests {
assert_eq!(quote_range(&chars, 1), None);
}
+ #[test]
+ fn contraction_apostrophes_do_not_pair() {
+ let chars: Vec = "it's a test, isn't it".chars().collect();
+ // Clicking either contraction falls through to the stock word.
+ assert_eq!(quote_range(&chars, 2), None);
+ assert_eq!(quote_range(&chars, 16), None);
+ // And the whole line yields no smart candidate at all, so the
+ // double-click keeps alacritty's `it's`.
+ let text = "it's a test, isn't it";
+ assert_eq!(range(text, 2), None);
+ }
+
+ #[test]
+ fn contractions_do_not_skew_a_real_quote() {
+ // The apostrophes inside the quoted span must not flip the parity or
+ // steal the match from the genuine delimiters.
+ let chars: Vec = "echo 'it isn't so' done".chars().collect();
+ let open = 5;
+ let close = chars.iter().rposition(|&c| c == '\'').unwrap();
+ assert_eq!(quote_range(&chars, open), Some((open, close)));
+ assert_eq!(quote_range(&chars, close), Some((open, close)));
+ }
+
+ #[test]
+ fn trailing_apostrophe_still_closes() {
+ // `dogs'` — the apostrophe has a word char only on its left, so it is
+ // a delimiter, not a contraction.
+ let chars: Vec = "the 'dogs' bark".chars().collect();
+ assert_eq!(quote_range(&chars, 4), Some((4, 9)));
+ assert_eq!(quote_range(&chars, 9), Some((4, 9)));
+ }
+
#[test]
fn directional_cjk_quotes_pair_like_brackets() {
let chars: Vec = "他说“你好”了".chars().collect();