Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ Internal
* Refactor suggestion logic into declarative rules.
* Factor the `--batch` execution modes out of `main.py`.
* Sort coverage report in tox suite.
* Make multi-line detection and special cases more robust.


1.67.1 (2026/03/28)
Expand Down
5 changes: 2 additions & 3 deletions mycli/clibuffer.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,10 @@
def cli_is_multiline(mycli) -> Filter:
@Condition
def cond():
doc = get_app().layout.get_buffer_by_name(DEFAULT_BUFFER).document

if not mycli.multi_line:
return False
else:
doc = get_app().layout.get_buffer_by_name(DEFAULT_BUFFER).document
return not _multiline_exception(doc.text)

return cond
Expand All @@ -22,7 +21,7 @@ def cond():
def _multiline_exception(text: str) -> bool:
orig = text
text = text.strip()
first_word = text.split(' ')[0]
first_word = text.split()[0] if text else ''

# Multi-statement favorite query is a special case. Because there will
# be a semicolon separating statements, we can't consider semicolon an
Expand Down
115 changes: 115 additions & 0 deletions test/pytests/test_clibuffer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
from dataclasses import dataclass
from types import SimpleNamespace

import pytest

from mycli import clibuffer


@dataclass
class DummyDocument:
text: str


@dataclass
class DummyBuffer:
document: DummyDocument


@dataclass
class DummyLayout:
buffer: DummyBuffer
requested_names: list[str]

def get_buffer_by_name(self, name: str) -> DummyBuffer:
self.requested_names.append(name)
return self.buffer


def make_app_for_text(text: str) -> tuple[SimpleNamespace, DummyLayout]:
layout = DummyLayout(
buffer=DummyBuffer(document=DummyDocument(text=text)),
requested_names=[],
)
return SimpleNamespace(layout=layout), layout


def test_multiline_exception_handles_favorite_queries_only_after_blank_line() -> None:
assert clibuffer._multiline_exception(r'\fs demo select 1; select 2') is False
assert clibuffer._multiline_exception('\\fs demo select 1; select 2\n') is True


@pytest.mark.parametrize(
('text', 'expected'),
(
(r'\dt', True),
('select 1 //', True),
('select 1 \\g', True),
('select 1 \\G', True),
('select 1 \\e', True),
('select 1 \\edit', True),
('select 1 \\clip', True),
('help topic', True),
('HELP topic', True),
(' ', True),
('select 1', False),
),
)
def test_multiline_exception_detects_commands_terminators_and_plain_sql(
monkeypatch,
text: str,
expected: bool,
) -> None:
monkeypatch.setattr(clibuffer.iocommands, 'get_current_delimiter', lambda: '//')
monkeypatch.setattr(clibuffer, 'SPECIAL_COMMANDS', {'help': object(), 'exit': object()})

assert clibuffer._multiline_exception(text) is expected


def test_cli_is_multiline_returns_false_when_multiline_mode_is_disabled(monkeypatch) -> None:
mycli = SimpleNamespace(multi_line=False)

def fail_get_app() -> None:
raise AssertionError('get_app() should not be called when multiline mode is disabled')

monkeypatch.setattr(clibuffer, 'get_app', fail_get_app)

multiline_filter = clibuffer.cli_is_multiline(mycli)

assert multiline_filter() is False


@pytest.mark.parametrize('text', ('help\tselect', 'HELP\nselect'))
def test_multiline_exception_recognizes_non_backslashed_special_commands_with_general_whitespace(
monkeypatch,
text: str,
) -> None:
monkeypatch.setattr(clibuffer.iocommands, 'get_current_delimiter', lambda: ';')
monkeypatch.setattr(clibuffer, 'SPECIAL_COMMANDS', {'help': object(), 'exit': object()})

assert clibuffer._multiline_exception(text) is True


@pytest.mark.parametrize(
('text', 'expected'),
(
('select 1', True),
('help select', False),
),
)
def test_cli_is_multiline_uses_buffer_text_when_multiline_mode_is_enabled(
monkeypatch,
text: str,
expected: bool,
) -> None:
app, layout = make_app_for_text(text)
mycli = SimpleNamespace(multi_line=True)

monkeypatch.setattr(clibuffer, 'get_app', lambda: app)
monkeypatch.setattr(clibuffer.iocommands, 'get_current_delimiter', lambda: ';')
monkeypatch.setattr(clibuffer, 'SPECIAL_COMMANDS', {'help': object()})

multiline_filter = clibuffer.cli_is_multiline(mycli)

assert multiline_filter() is expected
assert layout.requested_names == [clibuffer.DEFAULT_BUFFER]
Loading