Skip to content

Fix that ViFindBrace doesn't search for brace when current char is not a brace#4862

Open
sharpchen wants to merge 3 commits intoPowerShell:masterfrom
sharpchen:vifindbrace
Open

Fix that ViFindBrace doesn't search for brace when current char is not a brace#4862
sharpchen wants to merge 3 commits intoPowerShell:masterfrom
sharpchen:vifindbrace

Conversation

@sharpchen
Copy link
Copy Markdown

@sharpchen sharpchen commented Jul 22, 2025

PR Summary

Vi searches right pair of first left brace, or first right brace when current char is not a brace. This pr added this functionality.

After the update, ViGotoBrace should work like this(of course this will also affect other methods such as ViDeleteBrace):
foo

PR Checklist

  • PR has a meaningful title
    • Use the present tense and imperative mood when describing your changes
  • Summarized changes
  • Make sure you've added one or more new tests
  • Make sure you've tested these changes in terminals that PowerShell is commonly used in (i.e. conhost.exe, Windows Terminal, Visual Studio Code Integrated Terminal, etc.)
  • User-facing changes
    • Not Applicable
    • OR
    • Documentation needed at PowerShell-Docs
      • Doc Issue filed:
Microsoft Reviewers: Open in CodeFlow

@Aston32
Copy link
Copy Markdown

Aston32 commented Mar 17, 2026

S

@andyleejordan
Copy link
Copy Markdown
Member

You are right that's how it works. I'm hoping we can do two things: verify the order of precedence in vanilla Vi (I was playing around trying to determine it and couldn't make heads or tails of it), and add some unit tests. Thanks! Love the fix though.

@sharpchen
Copy link
Copy Markdown
Author

verify the order of precedence in vanilla Vi

Not sure what it means? If you mean the standard behavior, :h % documented the expected behavior in vim:

Find the next item in this line after or under the cursor and jump to its match.

And :h cpo-% described how the Vi behavior differs the way of Vim enhanced:

Parens inside single and double quotes are also counted, causing a string that contains a paren to disturb the matching. For example, in a line like "if (strcmp("foo(", s))" the first paren does not match the last one.

It indeed requires some tests. I did find at least one inconsistent behavior.

@sharpchen
Copy link
Copy Markdown
Author

Hi @andyleejordan. I've rewrote the implementation, I think it's now good to go.
But I fail to run the test using ./build.ps1 -Test, it seems test/MovementTest.VI.cs is ignored? Tests pass even on false conditions.

// find next of any kind of paren
for (; nextParen < _buffer.Length; nextParen++)
for (int idx = 0; idx < parenthese.Length; idx++)
if (parenthese[idx] == _buffer[nextParen]) goto Outer;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The rare great use of goto

@andyleejordan
Copy link
Copy Markdown
Member

I'm not sure but if this build is like others (looking at PSScriptAnalyzer right now), -Test won't rebuild so you have to run ./build.ps1 after any change and then follow it with ./build.ps1 -Test.

AppVeyor is at least running tests, looks like Test.en_US_Windows.ViGotoBrace and Test.ScreenReader.ViGotoBrace need to be checked and updated.

@sharpchen sharpchen force-pushed the vifindbrace branch 3 times, most recently from 611243c to fee3566 Compare March 18, 2026 23:26
@sharpchen
Copy link
Copy Markdown
Author

Looks like tests are only available on Windows.

Shouldn't call ReadKey when there are no more keys

I honestly have no idea why test's failing. There's is a input but claims no key.

@andyleejordan
Copy link
Copy Markdown
Member

Looks like tests are only available on Windows.

I forgot about that but yes this is unfortunately true.

@andyleejordan
Copy link
Copy Markdown
Member

@sharpchen I will get this built on Windows and see if I can't fix the test so we can get this in!

@andyleejordan
Copy link
Copy Markdown
Member

Claude Opus 4.6 was actualy very adapt at fixing this error (it helps that the test was failing for the same reason as other similar tests, which then already had a fix it applied).

These tests need to be cleared with `ddi` and then
asserted to be empty. It's the same fix for the
other tests whose input contains unmatched braces.
Copilot's plausible explanation is that the
unmatched braces cause the input to be flagged by
PowerShell's parser as incomplete, so
`AcceptLineImpl` instead waits for more input,
causing the exception.
Copy link
Copy Markdown
Member

@andyleejordan andyleejordan left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM let's get it in @daxian-dbw

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR updates vi-mode brace matching so that when the cursor is not on a brace/paren/bracket, % searches forward for the next one and then jumps to its match (affecting ViGotoBrace, ViDeleteBrace, etc.).

Changes:

  • Extend ViFindBrace to scan forward for the next { } ( ) [ ] when the current character is not a brace.
  • Add new ViGotoBrace test cases covering “cursor not on a brace” scenarios and unmatched-brace behavior.
  • Minor test-file formatting/comment adjustments.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 5 comments.

File Description
PSReadLine/Movement.vi.cs Implements the new “scan forward for next brace” behavior in ViFindBrace.
test/MovementTest.VI.cs Adds/extends xUnit coverage for % behavior when the cursor is not positioned on a brace.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

for (int idx = 0; idx < parenthese.Length; idx++)
if (parenthese[idx] == _buffer[nextParen]) goto Outer;

Outer:
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the default case, if no brace/paren/bracket is found from the current position to the end of the buffer, nextParen will end up equal to _buffer.Length, and _buffer[nextParen] will throw IndexOutOfRangeException. Add an explicit check after the scan (e.g., if nextParen >= _buffer.Length return i) before indexing into _buffer.

Suggested change
Outer:
Outer:
if (nextParen >= _buffer.Length)
{
return i;
}

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the default case, if no brace/paren/bracket is found from the current position to the end of the buffer, nextParen will end up equal to _buffer.Length, and _buffer[nextParen] will throw IndexOutOfRangeException

This is a real problem. I prefer to not using the Outer label here. For the goto statement, we should avoid using it unless it's really needed.

Comment on lines +257 to +262
ReadOnlySpan<char> parenthese = stackalloc char[] { '{', '}', '(', ')', '[', ']' };
int nextParen = i;
// find next of any kind of paren
for (; nextParen < _buffer.Length; nextParen++)
for (int idx = 0; idx < parenthese.Length; idx++)
if (parenthese[idx] == _buffer[nextParen]) goto Outer;
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Variable name parenthese appears to be a typo (should be parentheses or similar). Renaming would improve readability and avoid spreading the misspelling.

Suggested change
ReadOnlySpan<char> parenthese = stackalloc char[] { '{', '}', '(', ')', '[', ']' };
int nextParen = i;
// find next of any kind of paren
for (; nextParen < _buffer.Length; nextParen++)
for (int idx = 0; idx < parenthese.Length; idx++)
if (parenthese[idx] == _buffer[nextParen]) goto Outer;
ReadOnlySpan<char> parentheses = stackalloc char[] { '{', '}', '(', ')', '[', ']' };
int nextParen = i;
// find next of any kind of paren
for (; nextParen < _buffer.Length; nextParen++)
for (int idx = 0; idx < parentheses.Length; idx++)
if (parentheses[idx] == _buffer[nextParen]) goto Outer;

Copilot uses AI. Check for mistakes.
Comment on lines +1 to +2
using System.Collections.Generic;
using Xunit;
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

using System.Collections.Generic; appears unused in this file (no List, Dictionary, etc.). Please remove it to avoid compiler warnings and keep usings minimal.

Suggested change
using System.Collections.Generic;
using Xunit;
using Xunit;

Copilot uses AI. Check for mistakes.
Comment on lines +478 to +487
// Closing paren without backward match
string input2 = $"0]2)4foo{closing}";
Test(input2, Keys(
input2,
CheckThat(() => AssertCursorLeftIs(9)),
_.Escape, CheckThat(() => AssertCursorLeftIs(8)),
"0ff", CheckThat(() => AssertCursorLeftIs(5)),
_.Percent, CheckThat(() => AssertCursorLeftIs(5)), // stay still
_.Percent, CheckThat(() => AssertCursorLeftIs(5))
));
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the "Closing paren without backward match" case, % should still trigger a ding (consistent with other unmatched-brace cases in this test and with ViGotoBrace dinging when ViFindBrace returns the current position). Consider using TestMustDing here so the test actually asserts the expected ding behavior, not just that the cursor stays still.

Copilot uses AI. Check for mistakes.
Comment on lines +260 to +261
for (; nextParen < _buffer.Length; nextParen++)
for (int idx = 0; idx < parenthese.Length; idx++)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can find the next parenthesis by the code below, instead of using nested loop here:

ReadOnlySpan<char> parens = stackalloc char[] { '{', '}', '(', ')', '[', ']' };

// Find next parenthesis of any type
int idx = _buffer.AsSpan(i).IndexOfAny(parens);
if (idx < 0)
{
    // No parenthesis, so nothing to match
    return i;
}

int nextParen = i + rel;
int match = _buffer[nextParen] switch
{
    ...
}

return match == nextParen ? i : match;

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's no AsSpan extension for StringBuilder though, did I miss something?
If that's true I think goto is succinct choice here to avoid extra boolean flag, we just need one more guard for the implicit increment on exit.

Copy link
Copy Markdown
Member

@daxian-dbw daxian-dbw Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, I incorrectly thought _buffer was a string.
Then I'm fine keeping the goto. You just need to add an early return before int match = _buffer[nextParen] switch.

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.

5 participants