Skip to content

feat: add opt-in automatic pair insertion, with a highlighter veto - #1135

Merged
kronberger-droid merged 16 commits into
nushell:mainfrom
eitsupi:feat/auto-pairs-context-veto
Sep 1, 2026
Merged

feat: add opt-in automatic pair insertion, with a highlighter veto#1135
kronberger-droid merged 16 commits into
nushell:mainfrom
eitsupi:feat/auto-pairs-context-veto

Conversation

@eitsupi

@eitsupi eitsupi commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Summary

This adds opt-in automatic pair insertion to reedline, continuing PR #1114 by @altacountbabi. Its two commits are preserved unchanged so the original author keeps authorship.

with_auto_pairs enables it; otherwise behaviour is unchanged. Once enabled:

  • typing an opening character inserts the pair, cursor between the halves
  • typing a closing character that already sits at the cursor steps over it
  • backspace between the halves of an empty pair deletes both, as one undo step
  • with a selection active, an opening character wraps the selection

The feature needs context-aware vetoes. Character-only logic cannot handle consumer-specific cases such as quotes inside unterminated strings. Downstream applications otherwise need keybinding-layer workarounds based on shadow editor state, which can drift from reedline's real buffer after:

  • history navigation
  • completion
  • hint acceptance
  • submitting a line

The veto is on Highlighter, following the should_expand_abbr precedent from #1094. Highlighters already own consumer parser state, so this avoids another policy channel. The API and bundled example remain consumer-neutral; language-specific policy stays downstream. If maintainers prefer a separate hook or builder, I can move it.

Public API. All of this is new and unreleased.

// configuration and builders
pub struct AutoPairs { /* ... */ }          // Debug, Default, Clone, PartialEq, Eq
impl Reedline {
    pub fn with_auto_pairs(self, auto_pairs: AutoPairs) -> Self;
    pub fn disable_auto_pairs(self) -> Self;
}

// two new EditCommand variants (the enum is already #[non_exhaustive])
EditCommand::InsertPair { open: char, close: char }
EditCommand::BackspacePair { open: char, close: char }

// the veto
#[non_exhaustive]
pub enum AutoPairAction { Open, SkipExistingCloser, BackspacePair }

#[non_exhaustive]
pub struct AutoPairContext<'a> { /* buffer, insertion_point, pair, selection, action */ }

pub trait Highlighter: Send {
    fn should_auto_pair(&self, context: &AutoPairContext<'_>) -> bool { true }
}

should_auto_pair defaults to true, so existing highlighters compile unchanged. The new commands also expand the strum discriminants used for keybinding configuration.

Before

reedline has no automatic pair insertion.

After

Auto-pairing runs in the engine against the real buffer and cursor. Pair insertion, closer skip-over, and empty-pair deletion are each resolved before should_auto_pair; returning false executes the original command unchanged.

Additional notes

How this differs from #1114 as it stands

#1114 This PR
Aggressive closing with no veto Highlighter::should_auto_pair gates all three actions
Skip-over happened before context checks All actions are resolved before the shared veto
Paste behaviour was undocumented with_auto_pairs documents the bracketed-paste guard
Pair lookup order was implicit InsertChar checks the closer before the opener
Tests did not cover vi/helix, selection, undo, or paste Expanded engine-level coverage includes all of those paths
Example only configured pairs Example demonstrates a consumer-neutral context policy

Tests

The focused engine-level suites cover independent vetoes and exact fallback commands, decision order, selections, undo grouping, same-character and overlapping pairs, multiline and CRLF buffers, vi and helix replays, balanced or unbalanced Event::Paste, and reverse history search. The context-policy suite also covers allowing non-quote pairs inside an unterminated region and vetoing the matching quote delimiter.

The latest regression additions cover two state transitions that previously caused downstream auto-pair bugs:

  • replacing the buffer through a history/menu-like edit, then deleting an empty pair
  • inserting and deleting a newline inside a pair without deleting its closer

They assert behaviour from the live editor buffer and cursor; they do not track which characters auto-pairing inserted and contain no downstream language policy.

Local focused checks after the latest upstream main merge:

  • cargo test --all-features auto_pair --lib — 33 passed
  • cargo test --all-features context_policy --lib — 2 passed
  • cargo fmt --check
  • cargo clippy --all-targets --all-features -- -D warnings
  • env -u NO_COLOR cargo test --all-features -- --test-threads=1 — 1635 unit tests passed, 1 ignored; 30 doc tests passed

The Helix insert-mode positive path is covered alongside the existing Vi test, and the Helix veto test runs unconditionally now that the crate no longer has a helix feature flag.

Downstream validation

arf PR #330 now uses this API from the pushed git revision. Its R-specific decision remains in arf's highlighter; reedline contains no arf-specific behavior.

The downstream migration removes the shadow-buffer keybinding workaround. The arf PTY suite covering history replacement, pair deletion, newline deletion, raw strings, pasting, and history menus passes with the new implementation (18 passed). The new history/newline regressions were also added on a branch based on arf main and passed against the old implementation before being merged into the feature branch, so they pin existing user-visible behaviour rather than defining behaviour around this implementation.

Pasting

with_auto_pairs does not change terminal settings. With bracketed paste, paste arrives as EditCommand::InsertString and is not auto-paired. Without it, pasted input is indistinguishable from typing, so an unmatched opener can gain a closer.

The example enables bracketed paste except on Windows, matching nushell. Stock crossterm on Windows has no Event::Paste path, so this remains unsolved there. I would rather not have with_auto_pairs enable bracketed paste implicitly: reedline cannot tell at compile time whether the terminal supports it.

Two behaviours worth your opinion

  • If a combining mark follows a closer, skip-over moves past the whole grapheme cluster. This follows reedline's grapheme-based cursor movement.
  • Skip-over uses UndoBehavior::MoveCursor, so it creates no undo boundary. Undoing after typing ( then ) returns to an empty buffer.

Follow-up

@fdncred asked on #1114 for a nushell-side toggle. I can add one once this API shape is settled.

Not included

HistoryCompleter can leave trailing text behind when a history suggestion is selected with the cursor mid-buffer. This is independent of auto-pairing and will be a separate PR.

altacountbabi and others added 8 commits June 30, 2026 03:26
Auto-pairing had no way to consult the surrounding syntax, so language
specific rules such as "do not pair a quote inside an unterminated string"
could not be expressed and downstream projects had to work around it at
the keybinding layer.

Add `Highlighter::should_auto_pair`, a defaulted veto that mirrors the
existing `should_expand_abbr` hook: consumers already own their parser in
the highlighter, and reusing that seam avoids a second, parallel policy
channel with its own precedence rules.

The veto receives an `AutoPairContext` carrying the buffer, the insertion
point, the pair being acted on, the selection and which of the three
actions (`Open`, `SkipExistingCloser`, `BackspacePair`) is pending.
Returning `false` runs the originally typed command verbatim.

Resolving the action now happens before any conversion, so all three
actions pass through the same gate. Previously skip-over short circuited
first and could not be suppressed at all.

`AutoPairs` keeps its shape and derives, and no new builder is added.
Behaviour is unchanged for existing users: the default implementation
returns true, and a highlighter that does not override it auto-pairs
exactly as before.
Extend the `with_auto_pairs` documentation the way `with_abbreviations`
points at `should_expand_abbr`: say that auto-pairing applies everywhere
by default and that `Highlighter::should_auto_pair` is how to suppress it.

Also document two things that were previously implicit: for `InsertChar`
the closer is looked up before the opener, so a character registered on
both sides of two different pairs resolves by what sits at the cursor
rather than by registration order; and this builder deliberately does not
touch the terminal's bracketed paste setting, which matters because
without bracketed paste a pasted opener is auto-paired like a typed one.

Rewrite the example around a highlighter that implements the rules an R
console actually uses, so it shows a real policy rather than a toy one.
The context derived its selection by comparing the selection anchor
against the insertion point. Under a forward vi-normal block selection
those two disagree: the caret sits one grapheme behind the widened head,
so the range handed to the veto dropped the last selected grapheme while
`insert_pair` went on to wrap the full range from `Editor::get_selection`.

Use `get_selection` for the context as well, so a policy sees exactly the
range the edit will affect.
The example's quote detector scanned for one quote character at a time,
so a quote sitting inside a string of the other kind counted as a
delimiter: after `'foo"` it reported an unclosed double-quoted string and
suppressed pairing another `"`. Track both kinds at once, the way
`ExampleHighlighter::should_expand_abbr` already does, so each kind is
inert while the other string is open.
It is minimal on purpose and carries no tests; a consumer should answer
this from the parser it already owns, which is why the veto sits on the
highlighter in the first place.
@eitsupi
eitsupi marked this pull request as ready for review August 23, 2026 05:05
@eitsupi

eitsupi commented Aug 23, 2026

Copy link
Copy Markdown
Contributor Author

I've replaced arf's auto-pair feature in eitsupi/arf#330.
It seems working fine.

@kronberger-droid

Copy link
Copy Markdown
Collaborator

Nice I will take a look and test it out.
I am really excited for the feature!

The `helix` feature flag is gone from the crate, so gating this test on
it silently excluded it from every build. Drop the gate.
@kronberger-droid

kronberger-droid commented Aug 28, 2026

Copy link
Copy Markdown
Collaborator

@eitsupi
Looks good, and works great.
Just minor issues I had style-wise:

  • open/close vs left/right: almost everything says open/close, but the public EditCommand pairs say left/right. I would prefer open/close everywhere.
  • Related to this is (char, char): Do you see methods accumulating on it, something like is_symmetric for same char case the example handles? If so a Pair { open, close } would maybe make sense. But that's more follow-up worthy topic i assume.
  • The feature spreads over four files. Maybe its worth adding an auto_pairs module.

Thanks again, nice work!

@kronberger-droid

Copy link
Copy Markdown
Collaborator

Also helix seems to be missing the no veto test:

  • src/engine.rs:3522 vi_insert_mode_auto_pair_routes_through_veto
  • src/engine.rs:3534 vi_insert_mode_auto_pair_still_pairs_when_not_vetoed
  • src/engine.rs:3545 helix_insert_mode_auto_pair_routes_through_veto — no counterpart

@eitsupi

eitsupi commented Aug 28, 2026

Copy link
Copy Markdown
Contributor Author

Updated.
Regarding pair, I didn't think it was worthwhile to introduce a new structure at this time, so I left it as is.

The latest HEAD hasn't been tested in arf because I haven't yet incorporated the breaking changes in the main branch.
However, I believe this is acceptable as there are no changes to the API.

-> Tested at eitsupi/arf#330

@kronberger-droid
kronberger-droid merged commit 3897b0b into nushell:main Sep 1, 2026
7 checks passed
@eitsupi
eitsupi deleted the feat/auto-pairs-context-veto branch September 1, 2026 22:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants