Skip to content

Commit 0ef6e2b

Browse files
author
Piyush Bhoyar
committed
fix #1057: Preserve indentation when pasting code blocks
1 parent 4a21ac0 commit 0ef6e2b

5 files changed

Lines changed: 205 additions & 11 deletions

File tree

‎CHANGELOG.rst‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ New features:
1111

1212

1313
Fixes:
14-
14+
* #1057: Preserve the indentation of pasted code
1515

1616
Changes to dependencies:
1717

‎bpython/curtsiesfrontend/preprocess.py‎

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@
1212
indent_empty_lines_re = LazyReCompile(r"\s*")
1313
tabs_to_spaces_re = LazyReCompile(r"^\t+")
1414

15+
# keywords introducing a clause that continues the block above it rather than
16+
# starting a new statement, even though they sit at the same indentation
17+
BLOCK_CONTINUATION_CLAUSES = frozenset(("elif", "else", "except", "finally"))
18+
1519

1620
def indent_empty_lines(s: str, compiler: CommandCompiler) -> str:
1721
"""Indents blank lines that would otherwise cause early compilation
@@ -49,5 +53,58 @@ def tab_to_space(m: Match[str]) -> str:
4953
)
5054

5155

56+
def _compiles(lines: list[str], compiler: CommandCompiler) -> bool:
57+
try:
58+
return bool(compiler("\n".join(lines)))
59+
except (ValueError, SyntaxError, OverflowError):
60+
return False
61+
62+
63+
def starts_new_statement(line: str) -> bool:
64+
"""Whether a line is a statement of its own rather than part of a block"""
65+
if not line.strip() or line[:1].isspace():
66+
return False
67+
# a comment is inert, and may well be sitting above an 'else:'
68+
if line.startswith("#"):
69+
return False
70+
return (
71+
line.split(maxsplit=1)[0].rstrip(":") not in BLOCK_CONTINUATION_CLAUSES
72+
)
73+
74+
75+
def separate_blocks(s: str, compiler: CommandCompiler) -> str:
76+
"""Insert the blank lines needed to close indented blocks
77+
78+
Interactively a block is ended by a blank line, but pasted code has none
79+
between a block and the statement that follows it, so without this that
80+
statement would be swallowed into the block instead of being run.
81+
82+
Only really works if starting on a new line"""
83+
result_lines: list[str] = []
84+
# the lines the REPL would currently be holding in its buffer
85+
buffer: list[str] = []
86+
87+
for line in s.split("\n"):
88+
# A block is merely waiting for its blank line, whereas a buffer held
89+
# open by an unclosed bracket stays unfinished however many follow.
90+
if (
91+
buffer
92+
and starts_new_statement(line)
93+
and _compiles(buffer + ["", ""], compiler)
94+
):
95+
result_lines.append("")
96+
buffer = []
97+
98+
result_lines.append(line)
99+
buffer.append(line)
100+
if not line.strip() or _compiles(buffer, compiler):
101+
# the REPL runs the buffer and starts collecting afresh
102+
buffer = []
103+
104+
return "\n".join(result_lines)
105+
106+
52107
def preprocess(s: str, compiler: CommandCompiler) -> str:
53-
return indent_empty_lines(leading_tabs_to_spaces(s), compiler)
108+
return separate_blocks(
109+
indent_empty_lines(leading_tabs_to_spaces(s), compiler), compiler
110+
)

‎bpython/curtsiesfrontend/repl.py‎

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,21 @@
8181
# i.e. control characters like '<Ctrl-a>' will be stripped
8282
MAX_EVENTS_POSSIBLY_NOT_PASTE = 20
8383

84+
# Keys that insert text instead of performing an action, mapped to the
85+
# character they stand for. In a paste these carry the content of the pasted
86+
# code - in particular the whitespace making up its indentation - so unlike
87+
# keys such as '<Ctrl-a>' they must never be dropped or deduplicated.
88+
CONTENT_KEYS = {
89+
"<Ctrl-j>": "\n",
90+
"<Ctrl-m>": "\n",
91+
"<PADENTER>": "\n",
92+
"\n": "\n",
93+
"\r": "\n",
94+
"<SPACE>": " ",
95+
"<TAB>": "\t",
96+
"\t": "\t",
97+
}
98+
8499

85100
class SearchMode(Enum):
86101
NO_SEARCH = 0
@@ -1081,6 +1096,11 @@ def process_simple_keypress(self, e: str):
10811096
pass # ignore events
10821097
elif e == "<SPACE>":
10831098
self.add_normal_character(" ")
1099+
elif e in ("<TAB>", "\t"):
1100+
# add_normal_character ignores control characters, so expand tabs
1101+
# here rather than losing the indentation they stand for
1102+
for _ in range(self.config.tab_length):
1103+
self.add_normal_character(" ")
10841104
else:
10851105
self.add_normal_character(e)
10861106

@@ -2213,6 +2233,10 @@ def compress_paste_event(paste_event):
22132233
if not all(paste_event.events[0] == e for e in paste_event.events):
22142234
return None
22152235
event = paste_event.events[0]
2236+
# A run of identical content keys is ordinary pasted text - indentation or
2237+
# blank lines - rather than a held down key, so it must not be collapsed.
2238+
if is_simple_event(event):
2239+
return None
22162240
# basically "is there a special curtsies names for this key?"
22172241
if len(event) > 1:
22182242
return event
@@ -2225,11 +2249,9 @@ def just_simple_events(event_list: Iterable[str | events.Event]) -> list[str]:
22252249
for e in event_list:
22262250
if isinstance(e, events.Event):
22272251
continue # ignore events
2228-
# '\n' necessary for pastes
2229-
elif e in ("<Ctrl-j>", "<Ctrl-m>", "<PADENTER>", "\n", "\r"):
2230-
simple_events.append("\n")
2231-
elif e == "<SPACE>":
2232-
simple_events.append(" ")
2252+
# content keys carry the text of a paste, including its indentation
2253+
elif e in CONTENT_KEYS:
2254+
simple_events.append(CONTENT_KEYS[e])
22332255
elif len(e) > 1:
22342256
continue # get rid of <Ctrl-a> etc.
22352257
else:
@@ -2240,7 +2262,4 @@ def just_simple_events(event_list: Iterable[str | events.Event]) -> list[str]:
22402262
def is_simple_event(e: str | events.Event) -> bool:
22412263
if isinstance(e, events.Event):
22422264
return False
2243-
return (
2244-
e in ("<Ctrl-j>", "<Ctrl-m>", "<PADENTER>", "\n", "\r", "<SPACE>")
2245-
or len(e) <= 1
2246-
)
2265+
return e in CONTENT_KEYS or len(e) <= 1

‎bpython/test/test_curtsies_repl.py‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -483,6 +483,51 @@ def test_control_events_in_large_paste(self):
483483
"a" + "e" * curtsiesrepl.MAX_EVENTS_POSSIBLY_NOT_PASTE,
484484
)
485485

486+
def paste(self, source):
487+
"""Paste source as the key events curtsies would report for it"""
488+
p = events.PasteEvent()
489+
p.events = [
490+
{" ": "<SPACE>", "\t": "<TAB>", "\n": "<Ctrl-j>"}.get(c, c)
491+
for c in source
492+
]
493+
self.repl.process_event(p)
494+
return [
495+
line
496+
for line, line_type in self.repl.all_logical_lines
497+
if line_type == LineType.INPUT
498+
]
499+
500+
def test_repeated_content_keys_are_not_compressed(self):
501+
"""A run of spaces is indentation, not a key being held down"""
502+
for key in ("<SPACE>", "<TAB>", "<Ctrl-j>"):
503+
with self.subTest(key=key):
504+
p = events.PasteEvent()
505+
p.events = [key] * 10
506+
self.assertIsNone(curtsiesrepl.compress_paste_event(p))
507+
508+
def test_repeated_control_keys_are_still_compressed(self):
509+
p = events.PasteEvent()
510+
p.events = ["<Ctrl-a>"] * 10
511+
self.assertEqual(curtsiesrepl.compress_paste_event(p), "<Ctrl-a>")
512+
513+
def test_deep_indentation_is_preserved(self):
514+
"""Indentation long enough to fill a read must survive (#1057)"""
515+
self.assertEqual(self.paste(" " * 12 + "x"), [])
516+
self.assertEqual(self.repl.current_line, " " * 12 + "x")
517+
518+
def test_space_indented_paste(self):
519+
self.assertEqual(
520+
self.paste("for i in (1, 2):\n x = i\n y = x\n"),
521+
["for i in (1, 2):", " x = i", " y = x"],
522+
)
523+
524+
def test_tab_indented_paste(self):
525+
"""Tabs carry indentation and must not be dropped (#1057)"""
526+
self.assertEqual(
527+
self.paste("for i in (1, 2):\n\tx = i\n\ty = x\n"),
528+
["for i in (1, 2):", " x = i", " y = x"],
529+
)
530+
486531

487532
if __name__ == "__main__":
488533
unittest.main()

‎bpython/test/test_preprocess.py‎

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,3 +105,76 @@ def test_blank_trailing_line(self):
105105

106106
def test_tabs(self):
107107
self.assertIndented(original.tabs)
108+
109+
110+
class TestSeparateBlocks(unittest.TestCase):
111+
"""Pasted code has no blank line after a block, so one has to be added
112+
for the statement that follows it to be run separately (#1057)"""
113+
114+
def test_statement_after_block(self):
115+
self.assertEqual(
116+
preproc("for i in r:\n f(i)\nprint(1)\n"),
117+
"for i in r:\n f(i)\n\nprint(1)\n",
118+
)
119+
120+
def test_statement_after_nested_blocks(self):
121+
self.assertEqual(
122+
preproc("for i in r:\n if i:\n f(i)\nprint(1)\n"),
123+
"for i in r:\n if i:\n f(i)\n\nprint(1)\n",
124+
)
125+
126+
def test_consecutive_blocks(self):
127+
self.assertEqual(
128+
preproc("if a:\n f()\nwhile b:\n g()\n"),
129+
"if a:\n f()\n\nwhile b:\n g()\n",
130+
)
131+
132+
def test_clauses_continuing_a_block_are_left_alone(self):
133+
for source in (
134+
"if a:\n f()\nelif b:\n g()\nelse:\n h()\n",
135+
"try:\n f()\nexcept E:\n g()\nfinally:\n h()\n",
136+
"for i in r:\n f(i)\nelse:\n g()\n",
137+
):
138+
with self.subTest(source=source):
139+
self.assertEqual(preproc(source), source)
140+
141+
def test_unindented_lines_inside_brackets_are_left_alone(self):
142+
# an unclosed bracket keeps the statement going, so these lines are
143+
# continuations rather than new statements
144+
for source in (
145+
"total = (\n1 +\n2\n)\n",
146+
"items = [\n'a',\n'b',\n]\n",
147+
"f(\nx,\n)\n",
148+
):
149+
with self.subTest(source=source):
150+
self.assertEqual(preproc(source), source)
151+
152+
def test_consecutive_simple_statements_are_left_alone(self):
153+
source = "a = 1\nb = 2\nprint(a + b)\n"
154+
self.assertEqual(preproc(source), source)
155+
156+
def test_existing_blank_line_is_not_doubled(self):
157+
source = "for i in r:\n f(i)\n\nprint(1)\n"
158+
self.assertEqual(preproc(source), source)
159+
160+
def test_block_after_statement(self):
161+
source = "a = 1\nfor i in r:\n f(i)\n"
162+
self.assertEqual(preproc(source), source)
163+
164+
def test_comment_above_a_continuing_clause_is_left_alone(self):
165+
source = "if a:\n f()\n# why not\nelse:\n g()\n"
166+
self.assertEqual(preproc(source), source)
167+
168+
def test_decorator_is_not_separated_from_its_definition(self):
169+
self.assertEqual(
170+
preproc("@deco\ndef f():\n pass\nprint(1)\n"),
171+
"@deco\ndef f():\n pass\n\nprint(1)\n",
172+
)
173+
174+
def test_lines_inside_a_string_are_left_alone(self):
175+
source = 's = """\nabc\ndef\n"""\nprint(s)\n'
176+
self.assertEqual(preproc(source), source)
177+
178+
def test_backslash_continuation_is_left_alone(self):
179+
source = "a = 1 + \\\n 2\nprint(a)\n"
180+
self.assertEqual(preproc(source), source)

0 commit comments

Comments
 (0)