Skip to content

Commit 4c0d732

Browse files
committed
Improve auto-indent/dedent behavior
1 parent 59e9dfa commit 4c0d732

File tree

9 files changed

+216
-42
lines changed

9 files changed

+216
-42
lines changed

src/Readline/Interactive/Actions/DedentLeadingIndentationAction.php

Lines changed: 2 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,6 @@
2222
*/
2323
class DedentLeadingIndentationAction implements ActionInterface
2424
{
25-
private const INDENT_WIDTH = 4;
26-
2725
/**
2826
* {@inheritdoc}
2927
*/
@@ -52,7 +50,7 @@ public function execute(Buffer $buffer, Terminal $terminal, Readline $readline):
5250

5351
$leadingSpaces = \strspn($line, ' ');
5452

55-
return $buffer->deleteForward($this->spacesToPreviousTabStop($leadingSpaces));
53+
return $buffer->deleteForward($buffer->spacesToPreviousTabStop($leadingSpaces));
5654
}
5755

5856
// Cursor is within leading whitespace: backward-delete to previous tab stop.
@@ -63,7 +61,7 @@ public function execute(Buffer $buffer, Terminal $terminal, Readline $readline):
6361
$beforeCursor = \substr($line, 0, $cursorInLine);
6462
$trailingSpaces = \strlen($beforeCursor) - \strlen(\rtrim($beforeCursor, ' '));
6563

66-
return $buffer->deleteBackward($this->spacesToPreviousTabStop($trailingSpaces));
64+
return $buffer->deleteBackward($buffer->spacesToPreviousTabStop($trailingSpaces));
6765
}
6866

6967
/**
@@ -73,14 +71,4 @@ public function getName(): string
7371
{
7472
return 'dedent-leading-indentation';
7573
}
76-
77-
/**
78-
* Get the number of spaces to remove to reach the previous tab stop.
79-
*/
80-
private function spacesToPreviousTabStop(int $spaces): int
81-
{
82-
$remainder = $spaces % self::INDENT_WIDTH;
83-
84-
return $remainder === 0 ? self::INDENT_WIDTH : $remainder;
85-
}
8674
}

src/Readline/Interactive/Actions/InsertIndentOnTabAction.php

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,6 @@
2020
*/
2121
class InsertIndentOnTabAction implements ActionInterface
2222
{
23-
private const INDENT_WIDTH = 4;
24-
2523
/**
2624
* {@inheritdoc}
2725
*/
@@ -39,7 +37,7 @@ public function execute(Buffer $buffer, Terminal $terminal, Readline $readline):
3937
return false;
4038
}
4139

42-
$spaces = self::INDENT_WIDTH - ($cursorInLine % self::INDENT_WIDTH);
40+
$spaces = $buffer->spacesToNextTabStop($cursorInLine);
4341
$buffer->insert(\str_repeat(' ', $spaces));
4442

4543
return true;

src/Readline/Interactive/Actions/InsertLineBreakAction.php

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,15 +27,30 @@ class InsertLineBreakAction implements ActionInterface
2727
public function execute(Buffer $buffer, Terminal $terminal, Readline $readline): bool
2828
{
2929
$indent = $buffer->calculateIndentBeforeCursor();
30+
$expandBracket = $this->isCursorBeforeClosingBracket($buffer);
3031

31-
$buffer->insert("\n");
32-
if ($indent !== '') {
33-
$buffer->insert($indent);
32+
$buffer->insert("\n".$indent);
33+
34+
// Push the closing bracket to its own line, dedented one level.
35+
if ($expandBracket) {
36+
$cursorPos = $buffer->getCursor();
37+
$buffer->insert("\n".$buffer->dedent($indent));
38+
$buffer->setCursor($cursorPos);
3439
}
3540

3641
return true;
3742
}
3843

44+
/**
45+
* Check whether the cursor is immediately before a closing bracket.
46+
*/
47+
private function isCursorBeforeClosingBracket(Buffer $buffer): bool
48+
{
49+
$afterCursor = $buffer->getAfterCursor();
50+
51+
return $afterCursor !== '' && \in_array($afterCursor[0], BracketPair::CLOSING_BRACKETS);
52+
}
53+
3954
/**
4055
* {@inheritdoc}
4156
*/

src/Readline/Interactive/Actions/InsertLineBreakOnUnclosedBracketsAction.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,12 @@ public function execute(Buffer $buffer, Terminal $terminal, Readline $readline):
3838
return false;
3939
}
4040

41+
// If the full buffer is semantically complete, let it submit
42+
// even if the cursor is between brackets.
43+
if ($buffer->isCompleteStatement()) {
44+
return false;
45+
}
46+
4147
return $this->lineBreakAction->execute($buffer, $terminal, $readline);
4248
}
4349

src/Readline/Interactive/Input/Buffer.php

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -798,6 +798,30 @@ public function calculateIndentBeforeCursor(): string
798798
return $this->indentationPolicy->calculateNextLineIndent($textBeforeCursor, $tokens);
799799
}
800800

801+
/**
802+
* Remove one level of indentation from an indent string.
803+
*/
804+
public function dedent(string $indent): string
805+
{
806+
return $this->indentationPolicy->dedent($indent);
807+
}
808+
809+
/**
810+
* Get the number of spaces needed to reach the next tab stop.
811+
*/
812+
public function spacesToNextTabStop(int $column): int
813+
{
814+
return $this->indentationPolicy->spacesToNextTabStop($column);
815+
}
816+
817+
/**
818+
* Get the number of spaces to remove to reach the previous tab stop.
819+
*/
820+
public function spacesToPreviousTabStop(int $spaces): int
821+
{
822+
return $this->indentationPolicy->spacesToPreviousTabStop($spaces);
823+
}
824+
801825
/**
802826
* Find the start position of the previous token.
803827
*

src/Readline/Interactive/Input/IndentationPolicy.php

Lines changed: 63 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
*/
1919
class IndentationPolicy
2020
{
21+
private const INDENT_WIDTH = 4;
22+
2123
/**
2224
* @param array $tokens Snapshot tokens for the current full buffer
2325
*/
@@ -38,17 +40,56 @@ public function calculateNextLineIndent(string $bufferText, array $tokens): stri
3840
$currentIndent = $this->getLineIndentation($lastLine);
3941
$lastChar = \substr(\rtrim($trimmedLastLine), -1);
4042

41-
if ($lastChar === '{' || $lastChar === '[' || $lastChar === '(') {
42-
return $currentIndent.' ';
43+
if (\in_array($lastChar, BracketPair::OPENING_BRACKETS)) {
44+
return $this->indent($currentIndent);
4345
}
4446

4547
if ($this->endsWithControlStructure($trimmedLastLine)) {
46-
return $currentIndent.' ';
48+
return $this->indent($currentIndent);
4749
}
4850

4951
return $currentIndent;
5052
}
5153

54+
/**
55+
* Add one level of indentation.
56+
*/
57+
public function indent(string $currentIndent): string
58+
{
59+
return $currentIndent.\str_repeat(' ', self::INDENT_WIDTH);
60+
}
61+
62+
/**
63+
* Remove one level of indentation.
64+
*/
65+
public function dedent(string $indent): string
66+
{
67+
if ($indent === '') {
68+
return '';
69+
}
70+
71+
// Calculate the visual column width, accounting for tabs.
72+
$column = 0;
73+
$length = \strlen($indent);
74+
75+
for ($i = 0; $i < $length; $i++) {
76+
$column += $indent[$i] === "\t" ? $this->spacesToNextTabStop($column) : 1;
77+
}
78+
79+
$target = $column - $this->spacesToPreviousTabStop($column);
80+
81+
// Find the byte position where truncating leaves us at the target column.
82+
$column = 0;
83+
for ($i = 0; $i < $length; $i++) {
84+
$column += $indent[$i] === "\t" ? $this->spacesToNextTabStop($column) : 1;
85+
if ($column > $target) {
86+
return \substr($indent, 0, $i);
87+
}
88+
}
89+
90+
return $indent;
91+
}
92+
5293
/**
5394
* Calculate how many spaces to remove when typing a closing bracket.
5495
*
@@ -93,7 +134,7 @@ public function calculateClosingBracketDedent(string $char, string $text, int $c
93134
return 0;
94135
}
95136

96-
return \min(4, $leadingSpaces);
137+
return $this->spacesToPreviousTabStop($leadingSpaces);
97138
}
98139

99140
/**
@@ -166,6 +207,24 @@ private function isEndOfLineInStringOrComment(string $bufferText, array $tokens)
166207
return false;
167208
}
168209

210+
/**
211+
* Get the number of spaces needed to reach the next tab stop.
212+
*/
213+
public function spacesToNextTabStop(int $column): int
214+
{
215+
return self::INDENT_WIDTH - ($column % self::INDENT_WIDTH);
216+
}
217+
218+
/**
219+
* Get the number of spaces to remove to reach the previous tab stop.
220+
*/
221+
public function spacesToPreviousTabStop(int $spaces): int
222+
{
223+
$remainder = $spaces % self::INDENT_WIDTH;
224+
225+
return $remainder === 0 ? self::INDENT_WIDTH : $remainder;
226+
}
227+
169228
/**
170229
* Extract leading whitespace from a line.
171230
*/

test/Readline/Interactive/Actions/AcceptLineActionTest.php

Lines changed: 44 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -52,47 +52,55 @@ private function createEnterAction(bool $smartBrackets): FallbackAction
5252
], false);
5353
}
5454

55-
public function testEnterBetweenParensInsertsNewline()
55+
public function testEnterBetweenParensSubmitsWhenComplete()
5656
{
5757
$action = $this->createEnterAction(true);
5858

59+
// foo() is a complete statement, so Enter submits even with
60+
// cursor between the parens.
5961
$this->setBufferState($this->buffer, 'foo(<cursor>)');
6062

61-
$this->readline->method('isMultilineMode')
62-
->willReturn(false);
63+
$this->readline->method('isCommand')->willReturn(false);
64+
$this->readline->method('isMultilineMode')->willReturn(false);
65+
$this->readline->method('getInputFrameOuterRowCount')->willReturn(2);
66+
$this->terminal->expects($this->once())->method('write')->with("\n\n\n");
6367

6468
$result = $action->execute($this->buffer, $this->terminal, $this->readline);
6569

66-
$this->assertTrue($result);
67-
$this->assertBufferState("foo(\n <cursor>)", $this->buffer);
70+
$this->assertFalse($result);
6871
}
6972

70-
public function testEnterBetweenBracketsInsertsNewline()
73+
public function testEnterBetweenBracesSubmitsWhenComplete()
7174
{
7275
$action = $this->createEnterAction(true);
7376

74-
$this->setBufferState($this->buffer, 'array[<cursor>]');
77+
// function() {} is a complete expression, so Enter submits.
78+
$this->setBufferState($this->buffer, 'function() {<cursor>}');
7579

80+
$this->readline->method('isCommand')->willReturn(false);
7681
$this->readline->method('isMultilineMode')->willReturn(false);
82+
$this->readline->method('getInputFrameOuterRowCount')->willReturn(2);
83+
$this->terminal->expects($this->once())->method('write')->with("\n\n\n");
7784

7885
$result = $action->execute($this->buffer, $this->terminal, $this->readline);
7986

80-
$this->assertTrue($result);
81-
$this->assertBufferState("array[\n <cursor>]", $this->buffer);
87+
$this->assertFalse($result);
8288
}
8389

84-
public function testEnterBetweenBracesInsertsNewline()
90+
public function testEnterBetweenParensInsertsNewlineWhenIncomplete()
8591
{
8692
$action = $this->createEnterAction(true);
8793

88-
$this->setBufferState($this->buffer, 'function() {<cursor>}');
94+
// $fn = function(<cursor>) is incomplete (no body yet), so Enter
95+
// inserts a newline inside the parens.
96+
$this->setBufferState($this->buffer, '$fn = function(<cursor>)');
8997

9098
$this->readline->method('isMultilineMode')->willReturn(false);
9199

92100
$result = $action->execute($this->buffer, $this->terminal, $this->readline);
93101

94102
$this->assertTrue($result);
95-
$this->assertBufferState("function() {\n <cursor>}", $this->buffer);
103+
$this->assertBufferState("\$fn = function(\n <cursor>\n)", $this->buffer);
96104
}
97105

98106
public function testEnterAfterBracketsExecutes()
@@ -162,16 +170,38 @@ public function testEnterInMultilineMovesBelowLowerSpacingRows(): void
162170
$this->assertFalse($result);
163171
}
164172

165-
public function testEnterBetweenBracketsInMultilineMode()
173+
public function testEnterBetweenBracketsInMultilineModeSubmitsWhenComplete()
166174
{
167175
$action = $this->createEnterAction(true);
168176

177+
// function test() {} is a complete declaration, so Enter submits
178+
// even in multiline mode.
169179
$this->setBufferState($this->buffer, 'function test() {<cursor>}');
170180

181+
$this->readline->method('isCommand')->willReturn(false);
182+
$this->readline->method('isInOpenStringOrComment')->willReturn(false);
171183
$this->readline->method('isMultilineMode')->willReturn(true);
184+
$this->readline->method('getInputFrameOuterRowCount')->willReturn(2);
185+
$this->terminal->expects($this->once())->method('write')->with("\n\n\n");
186+
187+
$result = $action->execute($this->buffer, $this->terminal, $this->readline);
188+
189+
$this->assertFalse($result);
190+
}
191+
192+
public function testEnterBetweenBracketsInMultilineModeInsertsNewlineWhenIncomplete()
193+
{
194+
$action = $this->createEnterAction(true);
195+
196+
// $fn = function(<cursor>) is incomplete (no body), so Enter
197+
// still inserts a newline even in multiline mode.
198+
$this->setBufferState($this->buffer, '$fn = function(<cursor>)');
199+
200+
$this->readline->method('isMultilineMode')->willReturn(true);
201+
172202
$result = $action->execute($this->buffer, $this->terminal, $this->readline);
173203

174204
$this->assertTrue($result);
175-
$this->assertBufferState("function test() {\n <cursor>}", $this->buffer);
205+
$this->assertBufferState("\$fn = function(\n <cursor>\n)", $this->buffer);
176206
}
177207
}

0 commit comments

Comments
 (0)