Skip to content

Commit feca5e6

Browse files
authored
SNOW-2116658: fix repl pasting with trailing new lines (#2588)
1 parent d74c970 commit feca5e6

File tree

3 files changed

+243
-7
lines changed

3 files changed

+243
-7
lines changed

RELEASE-NOTES.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
* `!` commands no longer require trailing `;` for evaluation
2727
* Bumped to `typer=0.17.3`. Improved displaying help messages.
2828
* Fixed using `ctx.var` in `snow sql` with Jinja templating.
29+
* Fixed issues when pasting content with trailing new lines.
2930

3031

3132
# v3.11.0

src/snowflake/cli/_plugins/sql/repl.py

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,20 @@ def _setup_key_bindings(self) -> KeyBindings:
8282
def not_searching():
8383
return not is_searching()
8484

85+
@kb.add(Keys.BracketedPaste)
86+
def _(event):
87+
"""Handle bracketed paste - normalize line endings and strip trailing whitespace."""
88+
pasted_data = event.data
89+
# Normalize line endings: \r\n -> \n, \r -> \n
90+
normalized_data = pasted_data.replace("\r\n", "\n").replace("\r", "\n")
91+
# Strip trailing whitespace
92+
cleaned_data = normalized_data.rstrip()
93+
buffer = event.app.current_buffer
94+
buffer.insert_text(cleaned_data)
95+
log.debug(
96+
"handled paste operation, normalized line endings and stripped trailing whitespace"
97+
)
98+
8599
@kb.add(Keys.Enter, filter=not_searching)
86100
def _(event):
87101
"""Handle Enter key press with intelligent execution logic.
@@ -93,23 +107,27 @@ def _(event):
93107
4. All other input - add new line for multi-line editing
94108
"""
95109
buffer = event.app.current_buffer
96-
stripped_buffer = buffer.text.strip()
110+
buffer_text = buffer.text
111+
stripped_text = buffer_text.strip()
97112

98-
if stripped_buffer:
113+
if stripped_text:
99114
log.debug("evaluating repl input")
100115
cursor_position = buffer.cursor_position
101-
ends_with_semicolon = buffer.text.endswith(";")
102-
is_command = detect_command(stripped_buffer) is not None
116+
ends_with_semicolon = stripped_text.endswith(";")
117+
is_command = detect_command(stripped_text) is not None
118+
119+
meaningful_content_end = len(buffer_text.rstrip())
120+
cursor_at_meaningful_end = cursor_position >= meaningful_content_end
103121

104-
if stripped_buffer.lower() in EXIT_KEYWORDS:
105-
log.debug("exit keyword detected %r", stripped_buffer)
122+
if stripped_text.lower() in EXIT_KEYWORDS:
123+
log.debug("exit keyword detected %r", stripped_text)
106124
buffer.validate_and_handle()
107125

108126
elif is_command:
109127
log.debug("command detected, submitting input")
110128
buffer.validate_and_handle()
111129

112-
elif ends_with_semicolon and cursor_position >= len(stripped_buffer):
130+
elif ends_with_semicolon and cursor_at_meaningful_end:
113131
log.debug("semicolon detected, submitting input")
114132
buffer.validate_and_handle()
115133

tests/sql/test_repl.py

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
from unittest import mock
33

44
import pytest
5+
from prompt_toolkit.buffer import Buffer
6+
from prompt_toolkit.keys import Keys
57
from snowflake.cli._plugins.sql.manager import SqlManager
68
from snowflake.cli._plugins.sql.repl import Repl
79
from snowflake.cli._plugins.sql.repl_commands import EditCommand
@@ -321,3 +323,218 @@ def test_edit_command_integration_with_repl_prompt(
321323

322324
# Verify _next_input is cleared after use
323325
assert repl.next_input is None
326+
327+
328+
class TestReplPasteHandling:
329+
"""Test cases for REPL paste handling functionality."""
330+
331+
@pytest.fixture
332+
def mock_app_buffer(self):
333+
"""Create a mock application with a buffer for testing key bindings."""
334+
buffer = Buffer()
335+
app = mock.MagicMock()
336+
app.current_buffer = buffer
337+
return app, buffer
338+
339+
def _find_bracketed_paste_handler(self, key_bindings):
340+
"""Find the bracketed paste handler from key bindings."""
341+
for binding in key_bindings.bindings:
342+
if binding.keys == (Keys.BracketedPaste,):
343+
return binding.handler
344+
raise AssertionError("BracketedPaste handler not found")
345+
346+
def _find_enter_handler(self, key_bindings):
347+
"""Find the Enter key handler from key bindings."""
348+
for binding in key_bindings.bindings:
349+
if binding.keys == (Keys.Enter,) and hasattr(binding, "filter"):
350+
return binding.handler
351+
raise AssertionError("Enter handler not found")
352+
353+
def test_bracketed_paste_strips_trailing_newlines(self, repl, mock_app_buffer):
354+
"""Test that bracketed paste strips trailing newlines from pasted content."""
355+
app, buffer = mock_app_buffer
356+
key_bindings = repl._setup_key_bindings() # noqa: SLF001
357+
358+
sql_with_trailing_newlines = "SELECT * FROM table;\n\n\n"
359+
paste_event = mock.MagicMock()
360+
paste_event.app = app
361+
paste_event.data = sql_with_trailing_newlines
362+
363+
paste_handler = self._find_bracketed_paste_handler(key_bindings)
364+
paste_handler(paste_event)
365+
366+
expected_clean_sql = "SELECT * FROM table;"
367+
assert buffer.text == expected_clean_sql
368+
assert not buffer.text.endswith("\n")
369+
370+
def test_bracketed_paste_handles_mixed_line_endings(self, repl, mock_app_buffer):
371+
"""Test that bracketed paste handles both \\n and \\r\\n line endings."""
372+
app, buffer = mock_app_buffer
373+
key_bindings = repl._setup_key_bindings() # noqa: SLF001
374+
375+
sql_with_mixed_endings = "SELECT 1;\r\nSELECT 2;\n\r\n\n"
376+
paste_event = mock.MagicMock()
377+
paste_event.app = app
378+
paste_event.data = sql_with_mixed_endings
379+
380+
paste_handler = self._find_bracketed_paste_handler(key_bindings)
381+
paste_handler(paste_event)
382+
383+
expected_normalized_sql = "SELECT 1;\nSELECT 2;"
384+
assert buffer.text == expected_normalized_sql
385+
assert not buffer.text.endswith(("\n", "\r"))
386+
387+
def test_bracketed_paste_preserves_internal_newlines(self, repl, mock_app_buffer):
388+
"""Test that internal newlines in pasted content are preserved."""
389+
app, buffer = mock_app_buffer
390+
key_bindings = repl._setup_key_bindings() # noqa: SLF001
391+
392+
multiline_sql_with_trailing_newlines = (
393+
"SELECT\n column1,\n column2\nFROM table;\n\n"
394+
)
395+
paste_event = mock.MagicMock()
396+
paste_event.app = app
397+
paste_event.data = multiline_sql_with_trailing_newlines
398+
399+
paste_handler = self._find_bracketed_paste_handler(key_bindings)
400+
paste_handler(paste_event)
401+
402+
expected_multiline_sql = "SELECT\n column1,\n column2\nFROM table;"
403+
assert buffer.text == expected_multiline_sql
404+
405+
def test_bracketed_paste_handles_carriage_return_only(self, repl, mock_app_buffer):
406+
"""Test that bracketed paste handles \\r (carriage return only) line endings."""
407+
app, buffer = mock_app_buffer
408+
key_bindings = repl._setup_key_bindings() # noqa: SLF001
409+
410+
sql_with_cr_endings = "SELECT 1;\rSELECT 2;\r\r"
411+
paste_event = mock.MagicMock()
412+
paste_event.app = app
413+
paste_event.data = sql_with_cr_endings
414+
415+
paste_handler = self._find_bracketed_paste_handler(key_bindings)
416+
paste_handler(paste_event)
417+
418+
expected_normalized_sql = "SELECT 1;\nSELECT 2;"
419+
assert buffer.text == expected_normalized_sql
420+
421+
def test_enter_key_with_semicolon_at_meaningful_end(self, repl, mock_app_buffer):
422+
"""Test Enter key behavior when cursor is at meaningful content end with semicolon."""
423+
app, buffer = mock_app_buffer
424+
key_bindings = repl._setup_key_bindings() # noqa: SLF001
425+
426+
sql_with_trailing_whitespace = "SELECT 1; \n "
427+
meaningful_content = "SELECT 1;"
428+
buffer.text = sql_with_trailing_whitespace
429+
buffer.cursor_position = len(meaningful_content)
430+
buffer.validate_and_handle = mock.MagicMock()
431+
432+
enter_event = mock.MagicMock()
433+
enter_event.app = app
434+
435+
enter_handler = self._find_enter_handler(key_bindings)
436+
enter_handler(enter_event)
437+
438+
buffer.validate_and_handle.assert_called_once()
439+
440+
def test_enter_key_without_semicolon_adds_newline(self, repl, mock_app_buffer):
441+
"""Test Enter key adds newline when no semicolon at meaningful content end."""
442+
app, buffer = mock_app_buffer
443+
key_bindings = repl._setup_key_bindings() # noqa: SLF001
444+
445+
incomplete_sql = "SELECT 1"
446+
buffer.text = incomplete_sql
447+
buffer.cursor_position = len(buffer.text)
448+
449+
enter_event = mock.MagicMock()
450+
enter_event.app = app
451+
452+
enter_handler = self._find_enter_handler(key_bindings)
453+
enter_handler(enter_event)
454+
455+
expected_sql_with_newline = "SELECT 1\n"
456+
assert buffer.text == expected_sql_with_newline
457+
458+
def test_enter_key_with_cursor_in_middle_adds_newline(self, repl, mock_app_buffer):
459+
"""Test Enter key adds newline when cursor is not at meaningful content end."""
460+
app, buffer = mock_app_buffer
461+
key_bindings = repl._setup_key_bindings() # noqa: SLF001
462+
463+
complete_sql = "SELECT 1;"
464+
cursor_in_middle_position = 3
465+
buffer.text = complete_sql
466+
buffer.cursor_position = cursor_in_middle_position
467+
468+
enter_event = mock.MagicMock()
469+
enter_event.app = app
470+
471+
enter_handler = self._find_enter_handler(key_bindings)
472+
enter_handler(enter_event)
473+
474+
expected_sql_with_newline_in_middle = "SEL\nECT 1;"
475+
assert buffer.text == expected_sql_with_newline_in_middle
476+
477+
def test_enter_key_handles_exit_keywords(self, repl, mock_app_buffer):
478+
"""Test Enter key handles exit keywords correctly."""
479+
app, buffer = mock_app_buffer
480+
key_bindings = repl._setup_key_bindings() # noqa: SLF001
481+
482+
exit_command = "exit"
483+
buffer.text = exit_command
484+
buffer.cursor_position = len(buffer.text)
485+
buffer.validate_and_handle = mock.MagicMock()
486+
487+
enter_event = mock.MagicMock()
488+
enter_event.app = app
489+
490+
enter_handler = self._find_enter_handler(key_bindings)
491+
enter_handler(enter_event)
492+
493+
buffer.validate_and_handle.assert_called_once()
494+
495+
@pytest.mark.parametrize("exit_keyword", ["exit", "quit", "EXIT", "QUIT"])
496+
def test_enter_key_handles_all_exit_keywords(
497+
self, exit_keyword, repl, mock_app_buffer
498+
):
499+
"""Test Enter key handles all exit keywords case-insensitively."""
500+
app, buffer = mock_app_buffer
501+
key_bindings = repl._setup_key_bindings() # noqa: SLF001
502+
503+
buffer.text = exit_keyword
504+
buffer.cursor_position = len(buffer.text)
505+
buffer.validate_and_handle = mock.MagicMock()
506+
507+
enter_event = mock.MagicMock()
508+
enter_event.app = app
509+
510+
enter_handler = self._find_enter_handler(key_bindings)
511+
enter_handler(enter_event)
512+
513+
buffer.validate_and_handle.assert_called_once()
514+
515+
def test_paste_and_enter_integration(self, repl, mock_app_buffer):
516+
"""Test integration of paste handling followed by Enter key."""
517+
app, buffer = mock_app_buffer
518+
key_bindings = repl._setup_key_bindings() # noqa: SLF001
519+
520+
sql_query_with_trailing_newlines = "SELECT * FROM users WHERE id = 1;\n\n\n"
521+
paste_event = mock.MagicMock()
522+
paste_event.app = app
523+
paste_event.data = sql_query_with_trailing_newlines
524+
525+
paste_handler = self._find_bracketed_paste_handler(key_bindings)
526+
paste_handler(paste_event)
527+
528+
expected_clean_query = "SELECT * FROM users WHERE id = 1;"
529+
assert buffer.text == expected_clean_query
530+
531+
buffer.cursor_position = len(buffer.text)
532+
buffer.validate_and_handle = mock.MagicMock()
533+
534+
enter_event = mock.MagicMock()
535+
enter_event.app = app
536+
537+
enter_handler = self._find_enter_handler(key_bindings)
538+
enter_handler(enter_event)
539+
540+
buffer.validate_and_handle.assert_called_once()

0 commit comments

Comments
 (0)