summaryrefslogtreecommitdiffstats
path: root/tests
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-15 16:35:31 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-15 16:35:31 +0000
commit4f1a3b5f9ad05aa7b08715d48909a2b06ee2fcb1 (patch)
treee5dee7be2f0d963da4faad6517278d03783e3adc /tests
parentInitial commit. (diff)
downloadprompt-toolkit-4f1a3b5f9ad05aa7b08715d48909a2b06ee2fcb1.tar.xz
prompt-toolkit-4f1a3b5f9ad05aa7b08715d48909a2b06ee2fcb1.zip
Adding upstream version 3.0.43.upstream/3.0.43
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'tests')
-rw-r--r--tests/test_async_generator.py28
-rw-r--r--tests/test_buffer.py112
-rw-r--r--tests/test_cli.py941
-rw-r--r--tests/test_completion.py469
-rw-r--r--tests/test_document.py69
-rw-r--r--tests/test_filter.py131
-rw-r--r--tests/test_formatted_text.py286
-rw-r--r--tests/test_history.py103
-rw-r--r--tests/test_inputstream.py141
-rw-r--r--tests/test_key_binding.py200
-rw-r--r--tests/test_layout.py53
-rw-r--r--tests/test_memory_leaks.py35
-rw-r--r--tests/test_print_formatted_text.py92
-rw-r--r--tests/test_regular_languages.py102
-rw-r--r--tests/test_shortcuts.py68
-rw-r--r--tests/test_style.py276
-rw-r--r--tests/test_style_transformation.py51
-rw-r--r--tests/test_utils.py78
-rw-r--r--tests/test_vt100_output.py20
-rw-r--r--tests/test_widgets.py20
-rw-r--r--tests/test_yank_nth_arg.py86
21 files changed, 3361 insertions, 0 deletions
diff --git a/tests/test_async_generator.py b/tests/test_async_generator.py
new file mode 100644
index 0000000..8c95f8c
--- /dev/null
+++ b/tests/test_async_generator.py
@@ -0,0 +1,28 @@
+from __future__ import annotations
+
+from asyncio import run
+
+from prompt_toolkit.eventloop import generator_to_async_generator
+
+
+def _sync_generator():
+ yield 1
+ yield 10
+
+
+def test_generator_to_async_generator():
+ """
+ Test conversion of sync to async generator.
+ This should run the synchronous parts in a background thread.
+ """
+ async_gen = generator_to_async_generator(_sync_generator)
+
+ items = []
+
+ async def consume_async_generator():
+ async for item in async_gen:
+ items.append(item)
+
+ # Run the event loop until all items are collected.
+ run(consume_async_generator())
+ assert items == [1, 10]
diff --git a/tests/test_buffer.py b/tests/test_buffer.py
new file mode 100644
index 0000000..e636137
--- /dev/null
+++ b/tests/test_buffer.py
@@ -0,0 +1,112 @@
+from __future__ import annotations
+
+import pytest
+
+from prompt_toolkit.buffer import Buffer
+
+
+@pytest.fixture
+def _buffer():
+ buff = Buffer()
+ return buff
+
+
+def test_initial(_buffer):
+ assert _buffer.text == ""
+ assert _buffer.cursor_position == 0
+
+
+def test_insert_text(_buffer):
+ _buffer.insert_text("some_text")
+ assert _buffer.text == "some_text"
+ assert _buffer.cursor_position == len("some_text")
+
+
+def test_cursor_movement(_buffer):
+ _buffer.insert_text("some_text")
+ _buffer.cursor_left()
+ _buffer.cursor_left()
+ _buffer.cursor_left()
+ _buffer.cursor_right()
+ _buffer.insert_text("A")
+
+ assert _buffer.text == "some_teAxt"
+ assert _buffer.cursor_position == len("some_teA")
+
+
+def test_backspace(_buffer):
+ _buffer.insert_text("some_text")
+ _buffer.cursor_left()
+ _buffer.cursor_left()
+ _buffer.delete_before_cursor()
+
+ assert _buffer.text == "some_txt"
+ assert _buffer.cursor_position == len("some_t")
+
+
+def test_cursor_up(_buffer):
+ # Cursor up to a line thats longer.
+ _buffer.insert_text("long line1\nline2")
+ _buffer.cursor_up()
+
+ assert _buffer.document.cursor_position == 5
+
+ # Going up when already at the top.
+ _buffer.cursor_up()
+ assert _buffer.document.cursor_position == 5
+
+ # Going up to a line that's shorter.
+ _buffer.reset()
+ _buffer.insert_text("line1\nlong line2")
+
+ _buffer.cursor_up()
+ assert _buffer.document.cursor_position == 5
+
+
+def test_cursor_down(_buffer):
+ _buffer.insert_text("line1\nline2")
+ _buffer.cursor_position = 3
+
+ # Normally going down
+ _buffer.cursor_down()
+ assert _buffer.document.cursor_position == len("line1\nlin")
+
+ # Going down to a line that's shorter.
+ _buffer.reset()
+ _buffer.insert_text("long line1\na\nb")
+ _buffer.cursor_position = 3
+
+ _buffer.cursor_down()
+ assert _buffer.document.cursor_position == len("long line1\na")
+
+
+def test_join_next_line(_buffer):
+ _buffer.insert_text("line1\nline2\nline3")
+ _buffer.cursor_up()
+ _buffer.join_next_line()
+
+ assert _buffer.text == "line1\nline2 line3"
+
+ # Test when there is no '\n' in the text
+ _buffer.reset()
+ _buffer.insert_text("line1")
+ _buffer.cursor_position = 0
+ _buffer.join_next_line()
+
+ assert _buffer.text == "line1"
+
+
+def test_newline(_buffer):
+ _buffer.insert_text("hello world")
+ _buffer.newline()
+
+ assert _buffer.text == "hello world\n"
+
+
+def test_swap_characters_before_cursor(_buffer):
+ _buffer.insert_text("hello world")
+ _buffer.cursor_left()
+ _buffer.cursor_left()
+ _buffer.swap_characters_before_cursor()
+
+ assert _buffer.text == "hello wrold"
diff --git a/tests/test_cli.py b/tests/test_cli.py
new file mode 100644
index 0000000..3a16e9f
--- /dev/null
+++ b/tests/test_cli.py
@@ -0,0 +1,941 @@
+"""
+These are almost end-to-end tests. They create a Prompt, feed it with some
+input and check the result.
+"""
+from __future__ import annotations
+
+from functools import partial
+
+import pytest
+
+from prompt_toolkit.clipboard import ClipboardData, InMemoryClipboard
+from prompt_toolkit.enums import EditingMode
+from prompt_toolkit.filters import ViInsertMode
+from prompt_toolkit.history import InMemoryHistory
+from prompt_toolkit.input.defaults import create_pipe_input
+from prompt_toolkit.input.vt100_parser import ANSI_SEQUENCES
+from prompt_toolkit.key_binding.bindings.named_commands import prefix_meta
+from prompt_toolkit.key_binding.key_bindings import KeyBindings
+from prompt_toolkit.output import DummyOutput
+from prompt_toolkit.shortcuts import PromptSession
+
+
+def _history():
+ h = InMemoryHistory()
+ h.append_string("line1 first input")
+ h.append_string("line2 second input")
+ h.append_string("line3 third input")
+ return h
+
+
+def _feed_cli_with_input(
+ text,
+ editing_mode=EditingMode.EMACS,
+ clipboard=None,
+ history=None,
+ multiline=False,
+ check_line_ending=True,
+ key_bindings=None,
+):
+ """
+ Create a Prompt, feed it with the given user input and return the CLI
+ object.
+
+ This returns a (result, Application) tuple.
+ """
+ # If the given text doesn't end with a newline, the interface won't finish.
+ if check_line_ending:
+ assert text.endswith("\r")
+
+ with create_pipe_input() as inp:
+ inp.send_text(text)
+ session = PromptSession(
+ input=inp,
+ output=DummyOutput(),
+ editing_mode=editing_mode,
+ history=history,
+ multiline=multiline,
+ clipboard=clipboard,
+ key_bindings=key_bindings,
+ )
+
+ _ = session.prompt()
+ return session.default_buffer.document, session.app
+
+
+def test_simple_text_input():
+ # Simple text input, followed by enter.
+ result, cli = _feed_cli_with_input("hello\r")
+ assert result.text == "hello"
+ assert cli.current_buffer.text == "hello"
+
+
+def test_emacs_cursor_movements():
+ """
+ Test cursor movements with Emacs key bindings.
+ """
+ # ControlA (beginning-of-line)
+ result, cli = _feed_cli_with_input("hello\x01X\r")
+ assert result.text == "Xhello"
+
+ # ControlE (end-of-line)
+ result, cli = _feed_cli_with_input("hello\x01X\x05Y\r")
+ assert result.text == "XhelloY"
+
+ # ControlH or \b
+ result, cli = _feed_cli_with_input("hello\x08X\r")
+ assert result.text == "hellX"
+
+ # Delete. (Left, left, delete)
+ result, cli = _feed_cli_with_input("hello\x1b[D\x1b[D\x1b[3~\r")
+ assert result.text == "helo"
+
+ # Left.
+ result, cli = _feed_cli_with_input("hello\x1b[DX\r")
+ assert result.text == "hellXo"
+
+ # ControlA, right
+ result, cli = _feed_cli_with_input("hello\x01\x1b[CX\r")
+ assert result.text == "hXello"
+
+ # ControlB (backward-char)
+ result, cli = _feed_cli_with_input("hello\x02X\r")
+ assert result.text == "hellXo"
+
+ # ControlF (forward-char)
+ result, cli = _feed_cli_with_input("hello\x01\x06X\r")
+ assert result.text == "hXello"
+
+ # ControlD: delete after cursor.
+ result, cli = _feed_cli_with_input("hello\x01\x04\r")
+ assert result.text == "ello"
+
+ # ControlD at the end of the input ssshould not do anything.
+ result, cli = _feed_cli_with_input("hello\x04\r")
+ assert result.text == "hello"
+
+ # Left, Left, ControlK (kill-line)
+ result, cli = _feed_cli_with_input("hello\x1b[D\x1b[D\x0b\r")
+ assert result.text == "hel"
+
+ # Left, Left Esc- ControlK (kill-line, but negative)
+ result, cli = _feed_cli_with_input("hello\x1b[D\x1b[D\x1b-\x0b\r")
+ assert result.text == "lo"
+
+ # ControlL: should not influence the result.
+ result, cli = _feed_cli_with_input("hello\x0c\r")
+ assert result.text == "hello"
+
+ # ControlRight (forward-word)
+ result, cli = _feed_cli_with_input("hello world\x01X\x1b[1;5CY\r")
+ assert result.text == "XhelloY world"
+
+ # ContrlolLeft (backward-word)
+ result, cli = _feed_cli_with_input("hello world\x1b[1;5DY\r")
+ assert result.text == "hello Yworld"
+
+ # <esc>-f with argument. (forward-word)
+ result, cli = _feed_cli_with_input("hello world abc def\x01\x1b3\x1bfX\r")
+ assert result.text == "hello world abcX def"
+
+ # <esc>-f with negative argument. (forward-word)
+ result, cli = _feed_cli_with_input("hello world abc def\x1b-\x1b3\x1bfX\r")
+ assert result.text == "hello Xworld abc def"
+
+ # <esc>-b with argument. (backward-word)
+ result, cli = _feed_cli_with_input("hello world abc def\x1b3\x1bbX\r")
+ assert result.text == "hello Xworld abc def"
+
+ # <esc>-b with negative argument. (backward-word)
+ result, cli = _feed_cli_with_input("hello world abc def\x01\x1b-\x1b3\x1bbX\r")
+ assert result.text == "hello world abc Xdef"
+
+ # ControlW (kill-word / unix-word-rubout)
+ result, cli = _feed_cli_with_input("hello world\x17\r")
+ assert result.text == "hello "
+ assert cli.clipboard.get_data().text == "world"
+
+ result, cli = _feed_cli_with_input("test hello world\x1b2\x17\r")
+ assert result.text == "test "
+
+ # Escape Backspace (unix-word-rubout)
+ result, cli = _feed_cli_with_input("hello world\x1b\x7f\r")
+ assert result.text == "hello "
+ assert cli.clipboard.get_data().text == "world"
+
+ result, cli = _feed_cli_with_input("hello world\x1b\x08\r")
+ assert result.text == "hello "
+ assert cli.clipboard.get_data().text == "world"
+
+ # Backspace (backward-delete-char)
+ result, cli = _feed_cli_with_input("hello world\x7f\r")
+ assert result.text == "hello worl"
+ assert result.cursor_position == len("hello worl")
+
+ result, cli = _feed_cli_with_input("hello world\x08\r")
+ assert result.text == "hello worl"
+ assert result.cursor_position == len("hello worl")
+
+ # Delete (delete-char)
+ result, cli = _feed_cli_with_input("hello world\x01\x1b[3~\r")
+ assert result.text == "ello world"
+ assert result.cursor_position == 0
+
+ # Escape-\\ (delete-horizontal-space)
+ result, cli = _feed_cli_with_input("hello world\x1b8\x02\x1b\\\r")
+ assert result.text == "helloworld"
+ assert result.cursor_position == len("hello")
+
+
+def test_emacs_kill_multiple_words_and_paste():
+ # Using control-w twice should place both words on the clipboard.
+ result, cli = _feed_cli_with_input(
+ "hello world test\x17\x17--\x19\x19\r" # Twice c-w. Twice c-y.
+ )
+ assert result.text == "hello --world testworld test"
+ assert cli.clipboard.get_data().text == "world test"
+
+ # Using alt-d twice should place both words on the clipboard.
+ result, cli = _feed_cli_with_input(
+ "hello world test"
+ "\x1bb\x1bb" # Twice left.
+ "\x1bd\x1bd" # Twice kill-word.
+ "abc"
+ "\x19" # Paste.
+ "\r"
+ )
+ assert result.text == "hello abcworld test"
+ assert cli.clipboard.get_data().text == "world test"
+
+
+def test_interrupts():
+ # ControlC: raise KeyboardInterrupt.
+ with pytest.raises(KeyboardInterrupt):
+ result, cli = _feed_cli_with_input("hello\x03\r")
+
+ with pytest.raises(KeyboardInterrupt):
+ result, cli = _feed_cli_with_input("hello\x03\r")
+
+ # ControlD without any input: raises EOFError.
+ with pytest.raises(EOFError):
+ result, cli = _feed_cli_with_input("\x04\r")
+
+
+def test_emacs_yank():
+ # ControlY (yank)
+ c = InMemoryClipboard(ClipboardData("XYZ"))
+ result, cli = _feed_cli_with_input("hello\x02\x19\r", clipboard=c)
+ assert result.text == "hellXYZo"
+ assert result.cursor_position == len("hellXYZ")
+
+
+def test_quoted_insert():
+ # ControlQ - ControlB (quoted-insert)
+ result, cli = _feed_cli_with_input("hello\x11\x02\r")
+ assert result.text == "hello\x02"
+
+
+def test_transformations():
+ # Meta-c (capitalize-word)
+ result, cli = _feed_cli_with_input("hello world\01\x1bc\r")
+ assert result.text == "Hello world"
+ assert result.cursor_position == len("Hello")
+
+ # Meta-u (uppercase-word)
+ result, cli = _feed_cli_with_input("hello world\01\x1bu\r")
+ assert result.text == "HELLO world"
+ assert result.cursor_position == len("Hello")
+
+ # Meta-u (downcase-word)
+ result, cli = _feed_cli_with_input("HELLO WORLD\01\x1bl\r")
+ assert result.text == "hello WORLD"
+ assert result.cursor_position == len("Hello")
+
+ # ControlT (transpose-chars)
+ result, cli = _feed_cli_with_input("hello\x14\r")
+ assert result.text == "helol"
+ assert result.cursor_position == len("hello")
+
+ # Left, Left, Control-T (transpose-chars)
+ result, cli = _feed_cli_with_input("abcde\x1b[D\x1b[D\x14\r")
+ assert result.text == "abdce"
+ assert result.cursor_position == len("abcd")
+
+
+def test_emacs_other_bindings():
+ # Transpose characters.
+ result, cli = _feed_cli_with_input("abcde\x14X\r") # Ctrl-T
+ assert result.text == "abcedX"
+
+ # Left, Left, Transpose. (This is slightly different.)
+ result, cli = _feed_cli_with_input("abcde\x1b[D\x1b[D\x14X\r")
+ assert result.text == "abdcXe"
+
+ # Clear before cursor.
+ result, cli = _feed_cli_with_input("hello\x1b[D\x1b[D\x15X\r")
+ assert result.text == "Xlo"
+
+ # unix-word-rubout: delete word before the cursor.
+ # (ControlW).
+ result, cli = _feed_cli_with_input("hello world test\x17X\r")
+ assert result.text == "hello world X"
+
+ result, cli = _feed_cli_with_input("hello world /some/very/long/path\x17X\r")
+ assert result.text == "hello world X"
+
+ # (with argument.)
+ result, cli = _feed_cli_with_input("hello world test\x1b2\x17X\r")
+ assert result.text == "hello X"
+
+ result, cli = _feed_cli_with_input("hello world /some/very/long/path\x1b2\x17X\r")
+ assert result.text == "hello X"
+
+ # backward-kill-word: delete word before the cursor.
+ # (Esc-ControlH).
+ result, cli = _feed_cli_with_input("hello world /some/very/long/path\x1b\x08X\r")
+ assert result.text == "hello world /some/very/long/X"
+
+ # (with arguments.)
+ result, cli = _feed_cli_with_input(
+ "hello world /some/very/long/path\x1b3\x1b\x08X\r"
+ )
+ assert result.text == "hello world /some/very/X"
+
+
+def test_controlx_controlx():
+ # At the end: go to the start of the line.
+ result, cli = _feed_cli_with_input("hello world\x18\x18X\r")
+ assert result.text == "Xhello world"
+ assert result.cursor_position == 1
+
+ # At the start: go to the end of the line.
+ result, cli = _feed_cli_with_input("hello world\x01\x18\x18X\r")
+ assert result.text == "hello worldX"
+
+ # Left, Left Control-X Control-X: go to the end of the line.
+ result, cli = _feed_cli_with_input("hello world\x1b[D\x1b[D\x18\x18X\r")
+ assert result.text == "hello worldX"
+
+
+def test_emacs_history_bindings():
+ # Adding a new item to the history.
+ history = _history()
+ result, cli = _feed_cli_with_input("new input\r", history=history)
+ assert result.text == "new input"
+ history.get_strings()[-1] == "new input"
+
+ # Go up in history, and accept the last item.
+ result, cli = _feed_cli_with_input("hello\x1b[A\r", history=history)
+ assert result.text == "new input"
+
+ # Esc< (beginning-of-history)
+ result, cli = _feed_cli_with_input("hello\x1b<\r", history=history)
+ assert result.text == "line1 first input"
+
+ # Esc> (end-of-history)
+ result, cli = _feed_cli_with_input(
+ "another item\x1b[A\x1b[a\x1b>\r", history=history
+ )
+ assert result.text == "another item"
+
+ # ControlUp (previous-history)
+ result, cli = _feed_cli_with_input("\x1b[1;5A\r", history=history)
+ assert result.text == "another item"
+
+ # Esc< ControlDown (beginning-of-history, next-history)
+ result, cli = _feed_cli_with_input("\x1b<\x1b[1;5B\r", history=history)
+ assert result.text == "line2 second input"
+
+
+def test_emacs_reverse_search():
+ history = _history()
+
+ # ControlR (reverse-search-history)
+ result, cli = _feed_cli_with_input("\x12input\r\r", history=history)
+ assert result.text == "line3 third input"
+
+ # Hitting ControlR twice.
+ result, cli = _feed_cli_with_input("\x12input\x12\r\r", history=history)
+ assert result.text == "line2 second input"
+
+
+def test_emacs_arguments():
+ """
+ Test various combinations of arguments in Emacs mode.
+ """
+ # esc 4
+ result, cli = _feed_cli_with_input("\x1b4x\r")
+ assert result.text == "xxxx"
+
+ # esc 4 4
+ result, cli = _feed_cli_with_input("\x1b44x\r")
+ assert result.text == "x" * 44
+
+ # esc 4 esc 4
+ result, cli = _feed_cli_with_input("\x1b4\x1b4x\r")
+ assert result.text == "x" * 44
+
+ # esc - right (-1 position to the right, equals 1 to the left.)
+ result, cli = _feed_cli_with_input("aaaa\x1b-\x1b[Cbbbb\r")
+ assert result.text == "aaabbbba"
+
+ # esc - 3 right
+ result, cli = _feed_cli_with_input("aaaa\x1b-3\x1b[Cbbbb\r")
+ assert result.text == "abbbbaaa"
+
+ # esc - - - 3 right
+ result, cli = _feed_cli_with_input("aaaa\x1b---3\x1b[Cbbbb\r")
+ assert result.text == "abbbbaaa"
+
+
+def test_emacs_arguments_for_all_commands():
+ """
+ Test all Emacs commands with Meta-[0-9] arguments (both positive and
+ negative). No one should crash.
+ """
+ for key in ANSI_SEQUENCES:
+ # Ignore BracketedPaste. This would hang forever, because it waits for
+ # the end sequence.
+ if key != "\x1b[200~":
+ try:
+ # Note: we add an 'X' after the key, because Ctrl-Q (quoted-insert)
+ # expects something to follow. We add an additional \r, because
+ # Ctrl-R and Ctrl-S (reverse-search) expect that.
+ result, cli = _feed_cli_with_input("hello\x1b4" + key + "X\r\r")
+
+ result, cli = _feed_cli_with_input("hello\x1b-" + key + "X\r\r")
+ except KeyboardInterrupt:
+ # This exception should only be raised for Ctrl-C
+ assert key == "\x03"
+
+
+def test_emacs_kill_ring():
+ operations = (
+ # abc ControlA ControlK
+ "abc\x01\x0b"
+ # def ControlA ControlK
+ "def\x01\x0b"
+ # ghi ControlA ControlK
+ "ghi\x01\x0b"
+ # ControlY (yank)
+ "\x19"
+ )
+
+ result, cli = _feed_cli_with_input(operations + "\r")
+ assert result.text == "ghi"
+
+ result, cli = _feed_cli_with_input(operations + "\x1by\r")
+ assert result.text == "def"
+
+ result, cli = _feed_cli_with_input(operations + "\x1by\x1by\r")
+ assert result.text == "abc"
+
+ result, cli = _feed_cli_with_input(operations + "\x1by\x1by\x1by\r")
+ assert result.text == "ghi"
+
+
+def test_emacs_selection():
+ # Copy/paste empty selection should not do anything.
+ operations = (
+ "hello"
+ # Twice left.
+ "\x1b[D\x1b[D"
+ # Control-Space
+ "\x00"
+ # ControlW (cut)
+ "\x17"
+ # ControlY twice. (paste twice)
+ "\x19\x19\r"
+ )
+
+ result, cli = _feed_cli_with_input(operations)
+ assert result.text == "hello"
+
+ # Copy/paste one character.
+ operations = (
+ "hello"
+ # Twice left.
+ "\x1b[D\x1b[D"
+ # Control-Space
+ "\x00"
+ # Right.
+ "\x1b[C"
+ # ControlW (cut)
+ "\x17"
+ # ControlA (Home).
+ "\x01"
+ # ControlY (paste)
+ "\x19\r"
+ )
+
+ result, cli = _feed_cli_with_input(operations)
+ assert result.text == "lhelo"
+
+
+def test_emacs_insert_comment():
+ # Test insert-comment (M-#) binding.
+ result, cli = _feed_cli_with_input("hello\x1b#", check_line_ending=False)
+ assert result.text == "#hello"
+
+ result, cli = _feed_cli_with_input(
+ "hello\rworld\x1b#", check_line_ending=False, multiline=True
+ )
+ assert result.text == "#hello\n#world"
+
+
+def test_emacs_record_macro():
+ operations = (
+ " "
+ "\x18(" # Start recording macro. C-X(
+ "hello"
+ "\x18)" # Stop recording macro.
+ " "
+ "\x18e" # Execute macro.
+ "\x18e" # Execute macro.
+ "\r"
+ )
+
+ result, cli = _feed_cli_with_input(operations)
+ assert result.text == " hello hellohello"
+
+
+def test_emacs_nested_macro():
+ "Test calling the macro within a macro."
+ # Calling a macro within a macro should take the previous recording (if one
+ # exists), not the one that is in progress.
+ operations = (
+ "\x18(" # Start recording macro. C-X(
+ "hello"
+ "\x18e" # Execute macro.
+ "\x18)" # Stop recording macro.
+ "\x18e" # Execute macro.
+ "\r"
+ )
+
+ result, cli = _feed_cli_with_input(operations)
+ assert result.text == "hellohello"
+
+ operations = (
+ "\x18(" # Start recording macro. C-X(
+ "hello"
+ "\x18)" # Stop recording macro.
+ "\x18(" # Start recording macro. C-X(
+ "\x18e" # Execute macro.
+ "world"
+ "\x18)" # Stop recording macro.
+ "\x01\x0b" # Delete all (c-a c-k).
+ "\x18e" # Execute macro.
+ "\r"
+ )
+
+ result, cli = _feed_cli_with_input(operations)
+ assert result.text == "helloworld"
+
+
+def test_prefix_meta():
+ # Test the prefix-meta command.
+ b = KeyBindings()
+ b.add("j", "j", filter=ViInsertMode())(prefix_meta)
+
+ result, cli = _feed_cli_with_input(
+ "hellojjIX\r", key_bindings=b, editing_mode=EditingMode.VI
+ )
+ assert result.text == "Xhello"
+
+
+def test_bracketed_paste():
+ result, cli = _feed_cli_with_input("\x1b[200~hello world\x1b[201~\r")
+ assert result.text == "hello world"
+
+ result, cli = _feed_cli_with_input("\x1b[200~hello\rworld\x1b[201~\x1b\r")
+ assert result.text == "hello\nworld"
+
+ # With \r\n endings.
+ result, cli = _feed_cli_with_input("\x1b[200~hello\r\nworld\x1b[201~\x1b\r")
+ assert result.text == "hello\nworld"
+
+ # With \n endings.
+ result, cli = _feed_cli_with_input("\x1b[200~hello\nworld\x1b[201~\x1b\r")
+ assert result.text == "hello\nworld"
+
+
+def test_vi_cursor_movements():
+ """
+ Test cursor movements with Vi key bindings.
+ """
+ feed = partial(_feed_cli_with_input, editing_mode=EditingMode.VI)
+
+ result, cli = feed("\x1b\r")
+ assert result.text == ""
+ assert cli.editing_mode == EditingMode.VI
+
+ # Esc h a X
+ result, cli = feed("hello\x1bhaX\r")
+ assert result.text == "hellXo"
+
+ # Esc I X
+ result, cli = feed("hello\x1bIX\r")
+ assert result.text == "Xhello"
+
+ # Esc I X
+ result, cli = feed("hello\x1bIX\r")
+ assert result.text == "Xhello"
+
+ # Esc 2hiX
+ result, cli = feed("hello\x1b2hiX\r")
+ assert result.text == "heXllo"
+
+ # Esc 2h2liX
+ result, cli = feed("hello\x1b2h2liX\r")
+ assert result.text == "hellXo"
+
+ # Esc \b\b
+ result, cli = feed("hello\b\b\r")
+ assert result.text == "hel"
+
+ # Esc \b\b
+ result, cli = feed("hello\b\b\r")
+ assert result.text == "hel"
+
+ # Esc 2h D
+ result, cli = feed("hello\x1b2hD\r")
+ assert result.text == "he"
+
+ # Esc 2h rX \r
+ result, cli = feed("hello\x1b2hrX\r")
+ assert result.text == "heXlo"
+
+
+def test_vi_operators():
+ feed = partial(_feed_cli_with_input, editing_mode=EditingMode.VI)
+
+ # Esc g~0
+ result, cli = feed("hello\x1bg~0\r")
+ assert result.text == "HELLo"
+
+ # Esc gU0
+ result, cli = feed("hello\x1bgU0\r")
+ assert result.text == "HELLo"
+
+ # Esc d0
+ result, cli = feed("hello\x1bd0\r")
+ assert result.text == "o"
+
+
+def test_vi_text_objects():
+ feed = partial(_feed_cli_with_input, editing_mode=EditingMode.VI)
+
+ # Esc gUgg
+ result, cli = feed("hello\x1bgUgg\r")
+ assert result.text == "HELLO"
+
+ # Esc gUU
+ result, cli = feed("hello\x1bgUU\r")
+ assert result.text == "HELLO"
+
+ # Esc di(
+ result, cli = feed("before(inside)after\x1b8hdi(\r")
+ assert result.text == "before()after"
+
+ # Esc di[
+ result, cli = feed("before[inside]after\x1b8hdi[\r")
+ assert result.text == "before[]after"
+
+ # Esc da(
+ result, cli = feed("before(inside)after\x1b8hda(\r")
+ assert result.text == "beforeafter"
+
+
+def test_vi_digraphs():
+ feed = partial(_feed_cli_with_input, editing_mode=EditingMode.VI)
+
+ # C-K o/
+ result, cli = feed("hello\x0bo/\r")
+ assert result.text == "helloø"
+
+ # C-K /o (reversed input.)
+ result, cli = feed("hello\x0b/o\r")
+ assert result.text == "helloø"
+
+ # C-K e:
+ result, cli = feed("hello\x0be:\r")
+ assert result.text == "helloë"
+
+ # C-K xxy (Unknown digraph.)
+ result, cli = feed("hello\x0bxxy\r")
+ assert result.text == "helloy"
+
+
+def test_vi_block_editing():
+ "Test Vi Control-V style block insertion."
+ feed = partial(_feed_cli_with_input, editing_mode=EditingMode.VI, multiline=True)
+
+ operations = (
+ # Six lines of text.
+ "-line1\r-line2\r-line3\r-line4\r-line5\r-line6"
+ # Go to the second character of the second line.
+ "\x1bkkkkkkkj0l"
+ # Enter Visual block mode.
+ "\x16"
+ # Go down two more lines.
+ "jj"
+ # Go 3 characters to the right.
+ "lll"
+ # Go to insert mode.
+ "insert" # (Will be replaced.)
+ # Insert stars.
+ "***"
+ # Escape again.
+ "\x1b\r"
+ )
+
+ # Control-I
+ result, cli = feed(operations.replace("insert", "I"))
+
+ assert result.text == "-line1\n-***line2\n-***line3\n-***line4\n-line5\n-line6"
+
+ # Control-A
+ result, cli = feed(operations.replace("insert", "A"))
+
+ assert result.text == "-line1\n-line***2\n-line***3\n-line***4\n-line5\n-line6"
+
+
+def test_vi_block_editing_empty_lines():
+ "Test block editing on empty lines."
+ feed = partial(_feed_cli_with_input, editing_mode=EditingMode.VI, multiline=True)
+
+ operations = (
+ # Six empty lines.
+ "\r\r\r\r\r"
+ # Go to beginning of the document.
+ "\x1bgg"
+ # Enter Visual block mode.
+ "\x16"
+ # Go down two more lines.
+ "jj"
+ # Go 3 characters to the right.
+ "lll"
+ # Go to insert mode.
+ "insert" # (Will be replaced.)
+ # Insert stars.
+ "***"
+ # Escape again.
+ "\x1b\r"
+ )
+
+ # Control-I
+ result, cli = feed(operations.replace("insert", "I"))
+
+ assert result.text == "***\n***\n***\n\n\n"
+
+ # Control-A
+ result, cli = feed(operations.replace("insert", "A"))
+
+ assert result.text == "***\n***\n***\n\n\n"
+
+
+def test_vi_visual_line_copy():
+ feed = partial(_feed_cli_with_input, editing_mode=EditingMode.VI, multiline=True)
+
+ operations = (
+ # Three lines of text.
+ "-line1\r-line2\r-line3\r-line4\r-line5\r-line6"
+ # Go to the second character of the second line.
+ "\x1bkkkkkkkj0l"
+ # Enter Visual linemode.
+ "V"
+ # Go down one line.
+ "j"
+ # Go 3 characters to the right (should not do much).
+ "lll"
+ # Copy this block.
+ "y"
+ # Go down one line.
+ "j"
+ # Insert block twice.
+ "2p"
+ # Escape again.
+ "\x1b\r"
+ )
+
+ result, cli = feed(operations)
+
+ assert (
+ result.text
+ == "-line1\n-line2\n-line3\n-line4\n-line2\n-line3\n-line2\n-line3\n-line5\n-line6"
+ )
+
+
+def test_vi_visual_empty_line():
+ """
+ Test edge case with an empty line in Visual-line mode.
+ """
+ feed = partial(_feed_cli_with_input, editing_mode=EditingMode.VI, multiline=True)
+
+ # 1. Delete first two lines.
+ operations = (
+ # Three lines of text. The middle one is empty.
+ "hello\r\rworld"
+ # Go to the start.
+ "\x1bgg"
+ # Visual line and move down.
+ "Vj"
+ # Delete.
+ "d\r"
+ )
+ result, cli = feed(operations)
+ assert result.text == "world"
+
+ # 1. Delete middle line.
+ operations = (
+ # Three lines of text. The middle one is empty.
+ "hello\r\rworld"
+ # Go to middle line.
+ "\x1bggj"
+ # Delete line
+ "Vd\r"
+ )
+
+ result, cli = feed(operations)
+ assert result.text == "hello\nworld"
+
+
+def test_vi_character_delete_after_cursor():
+ "Test 'x' keypress."
+ feed = partial(_feed_cli_with_input, editing_mode=EditingMode.VI, multiline=True)
+
+ # Delete one character.
+ result, cli = feed("abcd\x1bHx\r")
+ assert result.text == "bcd"
+
+ # Delete multiple character.s
+ result, cli = feed("abcd\x1bH3x\r")
+ assert result.text == "d"
+
+ # Delete on empty line.
+ result, cli = feed("\x1bo\x1bo\x1bggx\r")
+ assert result.text == "\n\n"
+
+ # Delete multiple on empty line.
+ result, cli = feed("\x1bo\x1bo\x1bgg10x\r")
+ assert result.text == "\n\n"
+
+ # Delete multiple on empty line.
+ result, cli = feed("hello\x1bo\x1bo\x1bgg3x\r")
+ assert result.text == "lo\n\n"
+
+
+def test_vi_character_delete_before_cursor():
+ "Test 'X' keypress."
+ feed = partial(_feed_cli_with_input, editing_mode=EditingMode.VI, multiline=True)
+
+ # Delete one character.
+ result, cli = feed("abcd\x1bX\r")
+ assert result.text == "abd"
+
+ # Delete multiple character.
+ result, cli = feed("hello world\x1b3X\r")
+ assert result.text == "hello wd"
+
+ # Delete multiple character on multiple lines.
+ result, cli = feed("hello\x1boworld\x1bgg$3X\r")
+ assert result.text == "ho\nworld"
+
+ result, cli = feed("hello\x1boworld\x1b100X\r")
+ assert result.text == "hello\nd"
+
+ # Delete on empty line.
+ result, cli = feed("\x1bo\x1bo\x1b10X\r")
+ assert result.text == "\n\n"
+
+
+def test_vi_character_paste():
+ feed = partial(_feed_cli_with_input, editing_mode=EditingMode.VI)
+
+ # Test 'p' character paste.
+ result, cli = feed("abcde\x1bhhxp\r")
+ assert result.text == "abdce"
+ assert result.cursor_position == 3
+
+ # Test 'P' character paste.
+ result, cli = feed("abcde\x1bhhxP\r")
+ assert result.text == "abcde"
+ assert result.cursor_position == 2
+
+
+def test_vi_temp_navigation_mode():
+ """
+ Test c-o binding: go for one action into navigation mode.
+ """
+ feed = partial(_feed_cli_with_input, editing_mode=EditingMode.VI)
+
+ result, cli = feed("abcde" "\x0f" "3h" "x\r") # c-o # 3 times to the left.
+ assert result.text == "axbcde"
+ assert result.cursor_position == 2
+
+ result, cli = feed("abcde" "\x0f" "b" "x\r") # c-o # One word backwards.
+ assert result.text == "xabcde"
+ assert result.cursor_position == 1
+
+ # In replace mode
+ result, cli = feed(
+ "abcdef"
+ "\x1b" # Navigation mode.
+ "0l" # Start of line, one character to the right.
+ "R" # Replace mode
+ "78"
+ "\x0f" # c-o
+ "l" # One character forwards.
+ "9\r"
+ )
+ assert result.text == "a78d9f"
+ assert result.cursor_position == 5
+
+
+def test_vi_macros():
+ feed = partial(_feed_cli_with_input, editing_mode=EditingMode.VI)
+
+ # Record and execute macro.
+ result, cli = feed("\x1bqcahello\x1bq@c\r")
+ assert result.text == "hellohello"
+ assert result.cursor_position == 9
+
+ # Running unknown macro.
+ result, cli = feed("\x1b@d\r")
+ assert result.text == ""
+ assert result.cursor_position == 0
+
+ # When a macro is called within a macro.
+ # It shouldn't result in eternal recursion.
+ result, cli = feed("\x1bqxahello\x1b@xq@x\r")
+ assert result.text == "hellohello"
+ assert result.cursor_position == 9
+
+ # Nested macros.
+ result, cli = feed(
+ # Define macro 'x'.
+ "\x1bqxahello\x1bq"
+ # Define macro 'y' which calls 'x'.
+ "qya\x1b@xaworld\x1bq"
+ # Delete line.
+ "2dd"
+ # Execute 'y'
+ "@y\r"
+ )
+
+ assert result.text == "helloworld"
+
+
+def test_accept_default():
+ """
+ Test `prompt(accept_default=True)`.
+ """
+ with create_pipe_input() as inp:
+ session = PromptSession(input=inp, output=DummyOutput())
+ result = session.prompt(default="hello", accept_default=True)
+ assert result == "hello"
+
+ # Test calling prompt() for a second time. (We had an issue where the
+ # prompt reset between calls happened at the wrong time, breaking this.)
+ result = session.prompt(default="world", accept_default=True)
+ assert result == "world"
diff --git a/tests/test_completion.py b/tests/test_completion.py
new file mode 100644
index 0000000..8b3541a
--- /dev/null
+++ b/tests/test_completion.py
@@ -0,0 +1,469 @@
+from __future__ import annotations
+
+import os
+import re
+import shutil
+import tempfile
+from contextlib import contextmanager
+
+from prompt_toolkit.completion import (
+ CompleteEvent,
+ FuzzyWordCompleter,
+ NestedCompleter,
+ PathCompleter,
+ WordCompleter,
+ merge_completers,
+)
+from prompt_toolkit.document import Document
+
+
+@contextmanager
+def chdir(directory):
+ """Context manager for current working directory temporary change."""
+ orig_dir = os.getcwd()
+ os.chdir(directory)
+
+ try:
+ yield
+ finally:
+ os.chdir(orig_dir)
+
+
+def write_test_files(test_dir, names=None):
+ """Write test files in test_dir using the names list."""
+ names = names or range(10)
+ for i in names:
+ with open(os.path.join(test_dir, str(i)), "wb") as out:
+ out.write(b"")
+
+
+def test_pathcompleter_completes_in_current_directory():
+ completer = PathCompleter()
+ doc_text = ""
+ doc = Document(doc_text, len(doc_text))
+ event = CompleteEvent()
+ completions = list(completer.get_completions(doc, event))
+ assert len(completions) > 0
+
+
+def test_pathcompleter_completes_files_in_current_directory():
+ # setup: create a test dir with 10 files
+ test_dir = tempfile.mkdtemp()
+ write_test_files(test_dir)
+
+ expected = sorted(str(i) for i in range(10))
+
+ if not test_dir.endswith(os.path.sep):
+ test_dir += os.path.sep
+
+ with chdir(test_dir):
+ completer = PathCompleter()
+ # this should complete on the cwd
+ doc_text = ""
+ doc = Document(doc_text, len(doc_text))
+ event = CompleteEvent()
+ completions = list(completer.get_completions(doc, event))
+ result = sorted(c.text for c in completions)
+ assert expected == result
+
+ # cleanup
+ shutil.rmtree(test_dir)
+
+
+def test_pathcompleter_completes_files_in_absolute_directory():
+ # setup: create a test dir with 10 files
+ test_dir = tempfile.mkdtemp()
+ write_test_files(test_dir)
+
+ expected = sorted(str(i) for i in range(10))
+
+ test_dir = os.path.abspath(test_dir)
+ if not test_dir.endswith(os.path.sep):
+ test_dir += os.path.sep
+
+ completer = PathCompleter()
+ # force unicode
+ doc_text = str(test_dir)
+ doc = Document(doc_text, len(doc_text))
+ event = CompleteEvent()
+ completions = list(completer.get_completions(doc, event))
+ result = sorted(c.text for c in completions)
+ assert expected == result
+
+ # cleanup
+ shutil.rmtree(test_dir)
+
+
+def test_pathcompleter_completes_directories_with_only_directories():
+ # setup: create a test dir with 10 files
+ test_dir = tempfile.mkdtemp()
+ write_test_files(test_dir)
+
+ # create a sub directory there
+ os.mkdir(os.path.join(test_dir, "subdir"))
+
+ if not test_dir.endswith(os.path.sep):
+ test_dir += os.path.sep
+
+ with chdir(test_dir):
+ completer = PathCompleter(only_directories=True)
+ doc_text = ""
+ doc = Document(doc_text, len(doc_text))
+ event = CompleteEvent()
+ completions = list(completer.get_completions(doc, event))
+ result = [c.text for c in completions]
+ assert ["subdir"] == result
+
+ # check that there is no completion when passing a file
+ with chdir(test_dir):
+ completer = PathCompleter(only_directories=True)
+ doc_text = "1"
+ doc = Document(doc_text, len(doc_text))
+ event = CompleteEvent()
+ completions = list(completer.get_completions(doc, event))
+ assert [] == completions
+
+ # cleanup
+ shutil.rmtree(test_dir)
+
+
+def test_pathcompleter_respects_completions_under_min_input_len():
+ # setup: create a test dir with 10 files
+ test_dir = tempfile.mkdtemp()
+ write_test_files(test_dir)
+
+ # min len:1 and no text
+ with chdir(test_dir):
+ completer = PathCompleter(min_input_len=1)
+ doc_text = ""
+ doc = Document(doc_text, len(doc_text))
+ event = CompleteEvent()
+ completions = list(completer.get_completions(doc, event))
+ assert [] == completions
+
+ # min len:1 and text of len 1
+ with chdir(test_dir):
+ completer = PathCompleter(min_input_len=1)
+ doc_text = "1"
+ doc = Document(doc_text, len(doc_text))
+ event = CompleteEvent()
+ completions = list(completer.get_completions(doc, event))
+ result = [c.text for c in completions]
+ assert [""] == result
+
+ # min len:0 and text of len 2
+ with chdir(test_dir):
+ completer = PathCompleter(min_input_len=0)
+ doc_text = "1"
+ doc = Document(doc_text, len(doc_text))
+ event = CompleteEvent()
+ completions = list(completer.get_completions(doc, event))
+ result = [c.text for c in completions]
+ assert [""] == result
+
+ # create 10 files with a 2 char long name
+ for i in range(10):
+ with open(os.path.join(test_dir, str(i) * 2), "wb") as out:
+ out.write(b"")
+
+ # min len:1 and text of len 1
+ with chdir(test_dir):
+ completer = PathCompleter(min_input_len=1)
+ doc_text = "2"
+ doc = Document(doc_text, len(doc_text))
+ event = CompleteEvent()
+ completions = list(completer.get_completions(doc, event))
+ result = sorted(c.text for c in completions)
+ assert ["", "2"] == result
+
+ # min len:2 and text of len 1
+ with chdir(test_dir):
+ completer = PathCompleter(min_input_len=2)
+ doc_text = "2"
+ doc = Document(doc_text, len(doc_text))
+ event = CompleteEvent()
+ completions = list(completer.get_completions(doc, event))
+ assert [] == completions
+
+ # cleanup
+ shutil.rmtree(test_dir)
+
+
+def test_pathcompleter_does_not_expanduser_by_default():
+ completer = PathCompleter()
+ doc_text = "~"
+ doc = Document(doc_text, len(doc_text))
+ event = CompleteEvent()
+ completions = list(completer.get_completions(doc, event))
+ assert [] == completions
+
+
+def test_pathcompleter_can_expanduser():
+ completer = PathCompleter(expanduser=True)
+ doc_text = "~"
+ doc = Document(doc_text, len(doc_text))
+ event = CompleteEvent()
+ completions = list(completer.get_completions(doc, event))
+ assert len(completions) > 0
+
+
+def test_pathcompleter_can_apply_file_filter():
+ # setup: create a test dir with 10 files
+ test_dir = tempfile.mkdtemp()
+ write_test_files(test_dir)
+
+ # add a .csv file
+ with open(os.path.join(test_dir, "my.csv"), "wb") as out:
+ out.write(b"")
+
+ file_filter = lambda f: f and f.endswith(".csv")
+
+ with chdir(test_dir):
+ completer = PathCompleter(file_filter=file_filter)
+ doc_text = ""
+ doc = Document(doc_text, len(doc_text))
+ event = CompleteEvent()
+ completions = list(completer.get_completions(doc, event))
+ result = [c.text for c in completions]
+ assert ["my.csv"] == result
+
+ # cleanup
+ shutil.rmtree(test_dir)
+
+
+def test_pathcompleter_get_paths_constrains_path():
+ # setup: create a test dir with 10 files
+ test_dir = tempfile.mkdtemp()
+ write_test_files(test_dir)
+
+ # add a subdir with 10 other files with different names
+ subdir = os.path.join(test_dir, "subdir")
+ os.mkdir(subdir)
+ write_test_files(subdir, "abcdefghij")
+
+ get_paths = lambda: ["subdir"]
+
+ with chdir(test_dir):
+ completer = PathCompleter(get_paths=get_paths)
+ doc_text = ""
+ doc = Document(doc_text, len(doc_text))
+ event = CompleteEvent()
+ completions = list(completer.get_completions(doc, event))
+ result = [c.text for c in completions]
+ expected = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"]
+ assert expected == result
+
+ # cleanup
+ shutil.rmtree(test_dir)
+
+
+def test_word_completer_static_word_list():
+ completer = WordCompleter(["abc", "def", "aaa"])
+
+ # Static list on empty input.
+ completions = completer.get_completions(Document(""), CompleteEvent())
+ assert [c.text for c in completions] == ["abc", "def", "aaa"]
+
+ # Static list on non-empty input.
+ completions = completer.get_completions(Document("a"), CompleteEvent())
+ assert [c.text for c in completions] == ["abc", "aaa"]
+
+ completions = completer.get_completions(Document("A"), CompleteEvent())
+ assert [c.text for c in completions] == []
+
+ # Multiple words ending with space. (Accept all options)
+ completions = completer.get_completions(Document("test "), CompleteEvent())
+ assert [c.text for c in completions] == ["abc", "def", "aaa"]
+
+ # Multiple words. (Check last only.)
+ completions = completer.get_completions(Document("test a"), CompleteEvent())
+ assert [c.text for c in completions] == ["abc", "aaa"]
+
+
+def test_word_completer_ignore_case():
+ completer = WordCompleter(["abc", "def", "aaa"], ignore_case=True)
+ completions = completer.get_completions(Document("a"), CompleteEvent())
+ assert [c.text for c in completions] == ["abc", "aaa"]
+
+ completions = completer.get_completions(Document("A"), CompleteEvent())
+ assert [c.text for c in completions] == ["abc", "aaa"]
+
+
+def test_word_completer_match_middle():
+ completer = WordCompleter(["abc", "def", "abca"], match_middle=True)
+ completions = completer.get_completions(Document("bc"), CompleteEvent())
+ assert [c.text for c in completions] == ["abc", "abca"]
+
+
+def test_word_completer_sentence():
+ # With sentence=True
+ completer = WordCompleter(
+ ["hello world", "www", "hello www", "hello there"], sentence=True
+ )
+ completions = completer.get_completions(Document("hello w"), CompleteEvent())
+ assert [c.text for c in completions] == ["hello world", "hello www"]
+
+ # With sentence=False
+ completer = WordCompleter(
+ ["hello world", "www", "hello www", "hello there"], sentence=False
+ )
+ completions = completer.get_completions(Document("hello w"), CompleteEvent())
+ assert [c.text for c in completions] == ["www"]
+
+
+def test_word_completer_dynamic_word_list():
+ called = [0]
+
+ def get_words():
+ called[0] += 1
+ return ["abc", "def", "aaa"]
+
+ completer = WordCompleter(get_words)
+
+ # Dynamic list on empty input.
+ completions = completer.get_completions(Document(""), CompleteEvent())
+ assert [c.text for c in completions] == ["abc", "def", "aaa"]
+ assert called[0] == 1
+
+ # Static list on non-empty input.
+ completions = completer.get_completions(Document("a"), CompleteEvent())
+ assert [c.text for c in completions] == ["abc", "aaa"]
+ assert called[0] == 2
+
+
+def test_word_completer_pattern():
+ # With a pattern which support '.'
+ completer = WordCompleter(
+ ["abc", "a.b.c", "a.b", "xyz"],
+ pattern=re.compile(r"^([a-zA-Z0-9_.]+|[^a-zA-Z0-9_.\s]+)"),
+ )
+ completions = completer.get_completions(Document("a."), CompleteEvent())
+ assert [c.text for c in completions] == ["a.b.c", "a.b"]
+
+ # Without pattern
+ completer = WordCompleter(["abc", "a.b.c", "a.b", "xyz"])
+ completions = completer.get_completions(Document("a."), CompleteEvent())
+ assert [c.text for c in completions] == []
+
+
+def test_fuzzy_completer():
+ collection = [
+ "migrations.py",
+ "django_migrations.py",
+ "django_admin_log.py",
+ "api_user.doc",
+ "user_group.doc",
+ "users.txt",
+ "accounts.txt",
+ "123.py",
+ "test123test.py",
+ ]
+ completer = FuzzyWordCompleter(collection)
+ completions = completer.get_completions(Document("txt"), CompleteEvent())
+ assert [c.text for c in completions] == ["users.txt", "accounts.txt"]
+
+ completions = completer.get_completions(Document("djmi"), CompleteEvent())
+ assert [c.text for c in completions] == [
+ "django_migrations.py",
+ "django_admin_log.py",
+ ]
+
+ completions = completer.get_completions(Document("mi"), CompleteEvent())
+ assert [c.text for c in completions] == [
+ "migrations.py",
+ "django_migrations.py",
+ "django_admin_log.py",
+ ]
+
+ completions = completer.get_completions(Document("user"), CompleteEvent())
+ assert [c.text for c in completions] == [
+ "user_group.doc",
+ "users.txt",
+ "api_user.doc",
+ ]
+
+ completions = completer.get_completions(Document("123"), CompleteEvent())
+ assert [c.text for c in completions] == ["123.py", "test123test.py"]
+
+ completions = completer.get_completions(Document("miGr"), CompleteEvent())
+ assert [c.text for c in completions] == [
+ "migrations.py",
+ "django_migrations.py",
+ ]
+
+ # Multiple words ending with space. (Accept all options)
+ completions = completer.get_completions(Document("test "), CompleteEvent())
+ assert [c.text for c in completions] == collection
+
+ # Multiple words. (Check last only.)
+ completions = completer.get_completions(Document("test txt"), CompleteEvent())
+ assert [c.text for c in completions] == ["users.txt", "accounts.txt"]
+
+
+def test_nested_completer():
+ completer = NestedCompleter.from_nested_dict(
+ {
+ "show": {
+ "version": None,
+ "clock": None,
+ "interfaces": None,
+ "ip": {"interface": {"brief"}},
+ },
+ "exit": None,
+ }
+ )
+
+ # Empty input.
+ completions = completer.get_completions(Document(""), CompleteEvent())
+ assert {c.text for c in completions} == {"show", "exit"}
+
+ # One character.
+ completions = completer.get_completions(Document("s"), CompleteEvent())
+ assert {c.text for c in completions} == {"show"}
+
+ # One word.
+ completions = completer.get_completions(Document("show"), CompleteEvent())
+ assert {c.text for c in completions} == {"show"}
+
+ # One word + space.
+ completions = completer.get_completions(Document("show "), CompleteEvent())
+ assert {c.text for c in completions} == {"version", "clock", "interfaces", "ip"}
+
+ # One word + space + one character.
+ completions = completer.get_completions(Document("show i"), CompleteEvent())
+ assert {c.text for c in completions} == {"ip", "interfaces"}
+
+ # One space + one word + space + one character.
+ completions = completer.get_completions(Document(" show i"), CompleteEvent())
+ assert {c.text for c in completions} == {"ip", "interfaces"}
+
+ # Test nested set.
+ completions = completer.get_completions(
+ Document("show ip interface br"), CompleteEvent()
+ )
+ assert {c.text for c in completions} == {"brief"}
+
+
+def test_deduplicate_completer():
+ def create_completer(deduplicate: bool):
+ return merge_completers(
+ [
+ WordCompleter(["hello", "world", "abc", "def"]),
+ WordCompleter(["xyz", "xyz", "abc", "def"]),
+ ],
+ deduplicate=deduplicate,
+ )
+
+ completions = list(
+ create_completer(deduplicate=False).get_completions(
+ Document(""), CompleteEvent()
+ )
+ )
+ assert len(completions) == 8
+
+ completions = list(
+ create_completer(deduplicate=True).get_completions(
+ Document(""), CompleteEvent()
+ )
+ )
+ assert len(completions) == 5
diff --git a/tests/test_document.py b/tests/test_document.py
new file mode 100644
index 0000000..d052d53
--- /dev/null
+++ b/tests/test_document.py
@@ -0,0 +1,69 @@
+from __future__ import annotations
+
+import pytest
+
+from prompt_toolkit.document import Document
+
+
+@pytest.fixture
+def document():
+ return Document(
+ "line 1\n" + "line 2\n" + "line 3\n" + "line 4\n", len("line 1\n" + "lin")
+ )
+
+
+def test_current_char(document):
+ assert document.current_char == "e"
+ assert document.char_before_cursor == "n"
+
+
+def test_text_before_cursor(document):
+ assert document.text_before_cursor == "line 1\nlin"
+
+
+def test_text_after_cursor(document):
+ assert document.text_after_cursor == "e 2\n" + "line 3\n" + "line 4\n"
+
+
+def test_lines(document):
+ assert document.lines == ["line 1", "line 2", "line 3", "line 4", ""]
+
+
+def test_line_count(document):
+ assert document.line_count == 5
+
+
+def test_current_line_before_cursor(document):
+ assert document.current_line_before_cursor == "lin"
+
+
+def test_current_line_after_cursor(document):
+ assert document.current_line_after_cursor == "e 2"
+
+
+def test_current_line(document):
+ assert document.current_line == "line 2"
+
+
+def test_cursor_position(document):
+ assert document.cursor_position_row == 1
+ assert document.cursor_position_col == 3
+
+ d = Document("", 0)
+ assert d.cursor_position_row == 0
+ assert d.cursor_position_col == 0
+
+
+def test_translate_index_to_position(document):
+ pos = document.translate_index_to_position(len("line 1\nline 2\nlin"))
+
+ assert pos[0] == 2
+ assert pos[1] == 3
+
+ pos = document.translate_index_to_position(0)
+ assert pos == (0, 0)
+
+
+def test_is_cursor_at_the_end(document):
+ assert Document("hello", 5).is_cursor_at_the_end
+ assert not Document("hello", 4).is_cursor_at_the_end
diff --git a/tests/test_filter.py b/tests/test_filter.py
new file mode 100644
index 0000000..f7184c2
--- /dev/null
+++ b/tests/test_filter.py
@@ -0,0 +1,131 @@
+from __future__ import annotations
+
+import pytest
+
+from prompt_toolkit.filters import Always, Condition, Filter, Never, to_filter
+from prompt_toolkit.filters.base import _AndList, _OrList
+
+
+def test_never():
+ assert not Never()()
+
+
+def test_always():
+ assert Always()()
+
+
+def test_invert():
+ assert not (~Always())()
+ assert ~Never()()
+
+ c = ~Condition(lambda: False)
+ assert c()
+
+
+def test_or():
+ for a in (True, False):
+ for b in (True, False):
+ c1 = Condition(lambda: a)
+ c2 = Condition(lambda: b)
+ c3 = c1 | c2
+
+ assert isinstance(c3, Filter)
+ assert c3() == a or b
+
+
+def test_and():
+ for a in (True, False):
+ for b in (True, False):
+ c1 = Condition(lambda: a)
+ c2 = Condition(lambda: b)
+ c3 = c1 & c2
+
+ assert isinstance(c3, Filter)
+ assert c3() == (a and b)
+
+
+def test_nested_and():
+ for a in (True, False):
+ for b in (True, False):
+ for c in (True, False):
+ c1 = Condition(lambda: a)
+ c2 = Condition(lambda: b)
+ c3 = Condition(lambda: c)
+ c4 = (c1 & c2) & c3
+
+ assert isinstance(c4, Filter)
+ assert c4() == (a and b and c)
+
+
+def test_nested_or():
+ for a in (True, False):
+ for b in (True, False):
+ for c in (True, False):
+ c1 = Condition(lambda: a)
+ c2 = Condition(lambda: b)
+ c3 = Condition(lambda: c)
+ c4 = (c1 | c2) | c3
+
+ assert isinstance(c4, Filter)
+ assert c4() == (a or b or c)
+
+
+def test_to_filter():
+ f1 = to_filter(True)
+ f2 = to_filter(False)
+ f3 = to_filter(Condition(lambda: True))
+ f4 = to_filter(Condition(lambda: False))
+
+ assert isinstance(f1, Filter)
+ assert isinstance(f2, Filter)
+ assert isinstance(f3, Filter)
+ assert isinstance(f4, Filter)
+ assert f1()
+ assert not f2()
+ assert f3()
+ assert not f4()
+
+ with pytest.raises(TypeError):
+ to_filter(4)
+
+
+def test_filter_cache_regression_1():
+ # See: https://github.com/prompt-toolkit/python-prompt-toolkit/issues/1729
+
+ cond = Condition(lambda: True)
+
+ # The use of a `WeakValueDictionary` caused this following expression to
+ # fail. The problem is that the nested `(a & a)` expression gets garbage
+ # collected between the two statements and is removed from our cache.
+ x = (cond & cond) & cond
+ y = (cond & cond) & cond
+ assert x == y
+
+
+def test_filter_cache_regression_2():
+ cond1 = Condition(lambda: True)
+ cond2 = Condition(lambda: True)
+ cond3 = Condition(lambda: True)
+
+ x = (cond1 & cond2) & cond3
+ y = (cond1 & cond2) & cond3
+ assert x == y
+
+
+def test_filter_remove_duplicates():
+ cond1 = Condition(lambda: True)
+ cond2 = Condition(lambda: True)
+
+ # When a condition is appended to itself using an `&` or `|` operator, it
+ # should not be present twice. Having it twice in the `_AndList` or
+ # `_OrList` will make them more expensive to evaluate.
+
+ assert isinstance(cond1 & cond1, Condition)
+ assert isinstance(cond1 & cond1 & cond1, Condition)
+ assert isinstance(cond1 & cond1 & cond2, _AndList)
+ assert len((cond1 & cond1 & cond2).filters) == 2
+
+ assert isinstance(cond1 | cond1, Condition)
+ assert isinstance(cond1 | cond1 | cond1, Condition)
+ assert isinstance(cond1 | cond1 | cond2, _OrList)
+ assert len((cond1 | cond1 | cond2).filters) == 2
diff --git a/tests/test_formatted_text.py b/tests/test_formatted_text.py
new file mode 100644
index 0000000..843aac1
--- /dev/null
+++ b/tests/test_formatted_text.py
@@ -0,0 +1,286 @@
+from __future__ import annotations
+
+from prompt_toolkit.formatted_text import (
+ ANSI,
+ HTML,
+ FormattedText,
+ PygmentsTokens,
+ Template,
+ merge_formatted_text,
+ to_formatted_text,
+)
+from prompt_toolkit.formatted_text.utils import split_lines
+
+
+def test_basic_html():
+ html = HTML("<i>hello</i>")
+ assert to_formatted_text(html) == [("class:i", "hello")]
+
+ html = HTML("<i><b>hello</b></i>")
+ assert to_formatted_text(html) == [("class:i,b", "hello")]
+
+ html = HTML("<i><b>hello</b>world<strong>test</strong></i>after")
+ assert to_formatted_text(html) == [
+ ("class:i,b", "hello"),
+ ("class:i", "world"),
+ ("class:i,strong", "test"),
+ ("", "after"),
+ ]
+
+ # It's important that `to_formatted_text` returns a `FormattedText`
+ # instance. Otherwise, `print_formatted_text` won't recognize it and will
+ # print a list literal instead.
+ assert isinstance(to_formatted_text(html), FormattedText)
+
+
+def test_html_with_fg_bg():
+ html = HTML('<style bg="ansired">hello</style>')
+ assert to_formatted_text(html) == [
+ ("bg:ansired", "hello"),
+ ]
+
+ html = HTML('<style bg="ansired" fg="#ff0000">hello</style>')
+ assert to_formatted_text(html) == [
+ ("fg:#ff0000 bg:ansired", "hello"),
+ ]
+
+ html = HTML(
+ '<style bg="ansired" fg="#ff0000">hello <world fg="ansiblue">world</world></style>'
+ )
+ assert to_formatted_text(html) == [
+ ("fg:#ff0000 bg:ansired", "hello "),
+ ("class:world fg:ansiblue bg:ansired", "world"),
+ ]
+
+
+def test_ansi_formatting():
+ value = ANSI("\x1b[32mHe\x1b[45mllo")
+
+ assert to_formatted_text(value) == [
+ ("ansigreen", "H"),
+ ("ansigreen", "e"),
+ ("ansigreen bg:ansimagenta", "l"),
+ ("ansigreen bg:ansimagenta", "l"),
+ ("ansigreen bg:ansimagenta", "o"),
+ ]
+
+ # Bold and italic.
+ value = ANSI("\x1b[1mhe\x1b[0mllo")
+
+ assert to_formatted_text(value) == [
+ ("bold", "h"),
+ ("bold", "e"),
+ ("", "l"),
+ ("", "l"),
+ ("", "o"),
+ ]
+
+ # Zero width escapes.
+ value = ANSI("ab\001cd\002ef")
+
+ assert to_formatted_text(value) == [
+ ("", "a"),
+ ("", "b"),
+ ("[ZeroWidthEscape]", "cd"),
+ ("", "e"),
+ ("", "f"),
+ ]
+
+ assert isinstance(to_formatted_text(value), FormattedText)
+
+
+def test_ansi_256_color():
+ assert to_formatted_text(ANSI("\x1b[38;5;124mtest")) == [
+ ("#af0000", "t"),
+ ("#af0000", "e"),
+ ("#af0000", "s"),
+ ("#af0000", "t"),
+ ]
+
+
+def test_ansi_true_color():
+ assert to_formatted_text(ANSI("\033[38;2;144;238;144m$\033[0;39;49m ")) == [
+ ("#90ee90", "$"),
+ ("ansidefault bg:ansidefault", " "),
+ ]
+
+
+def test_ansi_interpolation():
+ # %-style interpolation.
+ value = ANSI("\x1b[1m%s\x1b[0m") % "hello\x1b"
+ assert to_formatted_text(value) == [
+ ("bold", "h"),
+ ("bold", "e"),
+ ("bold", "l"),
+ ("bold", "l"),
+ ("bold", "o"),
+ ("bold", "?"),
+ ]
+
+ value = ANSI("\x1b[1m%s\x1b[0m") % ("\x1bhello",)
+ assert to_formatted_text(value) == [
+ ("bold", "?"),
+ ("bold", "h"),
+ ("bold", "e"),
+ ("bold", "l"),
+ ("bold", "l"),
+ ("bold", "o"),
+ ]
+
+ value = ANSI("\x1b[32m%s\x1b[45m%s") % ("He", "\x1bllo")
+ assert to_formatted_text(value) == [
+ ("ansigreen", "H"),
+ ("ansigreen", "e"),
+ ("ansigreen bg:ansimagenta", "?"),
+ ("ansigreen bg:ansimagenta", "l"),
+ ("ansigreen bg:ansimagenta", "l"),
+ ("ansigreen bg:ansimagenta", "o"),
+ ]
+
+ # Format function.
+ value = ANSI("\x1b[32m{0}\x1b[45m{1}").format("He\x1b", "llo")
+ assert to_formatted_text(value) == [
+ ("ansigreen", "H"),
+ ("ansigreen", "e"),
+ ("ansigreen", "?"),
+ ("ansigreen bg:ansimagenta", "l"),
+ ("ansigreen bg:ansimagenta", "l"),
+ ("ansigreen bg:ansimagenta", "o"),
+ ]
+
+ value = ANSI("\x1b[32m{a}\x1b[45m{b}").format(a="\x1bHe", b="llo")
+ assert to_formatted_text(value) == [
+ ("ansigreen", "?"),
+ ("ansigreen", "H"),
+ ("ansigreen", "e"),
+ ("ansigreen bg:ansimagenta", "l"),
+ ("ansigreen bg:ansimagenta", "l"),
+ ("ansigreen bg:ansimagenta", "o"),
+ ]
+
+ value = ANSI("\x1b[32m{:02d}\x1b[45m{:.3f}").format(3, 3.14159)
+ assert to_formatted_text(value) == [
+ ("ansigreen", "0"),
+ ("ansigreen", "3"),
+ ("ansigreen bg:ansimagenta", "3"),
+ ("ansigreen bg:ansimagenta", "."),
+ ("ansigreen bg:ansimagenta", "1"),
+ ("ansigreen bg:ansimagenta", "4"),
+ ("ansigreen bg:ansimagenta", "2"),
+ ]
+
+
+def test_interpolation():
+ value = Template(" {} ").format(HTML("<b>hello</b>"))
+
+ assert to_formatted_text(value) == [
+ ("", " "),
+ ("class:b", "hello"),
+ ("", " "),
+ ]
+
+ value = Template("a{}b{}c").format(HTML("<b>hello</b>"), "world")
+
+ assert to_formatted_text(value) == [
+ ("", "a"),
+ ("class:b", "hello"),
+ ("", "b"),
+ ("", "world"),
+ ("", "c"),
+ ]
+
+
+def test_html_interpolation():
+ # %-style interpolation.
+ value = HTML("<b>%s</b>") % "&hello"
+ assert to_formatted_text(value) == [("class:b", "&hello")]
+
+ value = HTML("<b>%s</b>") % ("<hello>",)
+ assert to_formatted_text(value) == [("class:b", "<hello>")]
+
+ value = HTML("<b>%s</b><u>%s</u>") % ("<hello>", "</world>")
+ assert to_formatted_text(value) == [("class:b", "<hello>"), ("class:u", "</world>")]
+
+ # Format function.
+ value = HTML("<b>{0}</b><u>{1}</u>").format("'hello'", '"world"')
+ assert to_formatted_text(value) == [("class:b", "'hello'"), ("class:u", '"world"')]
+
+ value = HTML("<b>{a}</b><u>{b}</u>").format(a="hello", b="world")
+ assert to_formatted_text(value) == [("class:b", "hello"), ("class:u", "world")]
+
+ value = HTML("<b>{:02d}</b><u>{:.3f}</u>").format(3, 3.14159)
+ assert to_formatted_text(value) == [("class:b", "03"), ("class:u", "3.142")]
+
+
+def test_merge_formatted_text():
+ html1 = HTML("<u>hello</u>")
+ html2 = HTML("<b>world</b>")
+ result = merge_formatted_text([html1, html2])
+
+ assert to_formatted_text(result) == [
+ ("class:u", "hello"),
+ ("class:b", "world"),
+ ]
+
+
+def test_pygments_tokens():
+ text = [
+ (("A", "B"), "hello"), # Token.A.B
+ (("C", "D", "E"), "hello"), # Token.C.D.E
+ ((), "world"), # Token
+ ]
+
+ assert to_formatted_text(PygmentsTokens(text)) == [
+ ("class:pygments.a.b", "hello"),
+ ("class:pygments.c.d.e", "hello"),
+ ("class:pygments", "world"),
+ ]
+
+
+def test_split_lines():
+ lines = list(split_lines([("class:a", "line1\nline2\nline3")]))
+
+ assert lines == [
+ [("class:a", "line1")],
+ [("class:a", "line2")],
+ [("class:a", "line3")],
+ ]
+
+
+def test_split_lines_2():
+ lines = list(
+ split_lines([("class:a", "line1"), ("class:b", "line2\nline3\nline4")])
+ )
+
+ assert lines == [
+ [("class:a", "line1"), ("class:b", "line2")],
+ [("class:b", "line3")],
+ [("class:b", "line4")],
+ ]
+
+
+def test_split_lines_3():
+ "Edge cases: inputs ending with newlines."
+ # -1-
+ lines = list(split_lines([("class:a", "line1\nline2\n")]))
+
+ assert lines == [
+ [("class:a", "line1")],
+ [("class:a", "line2")],
+ [("class:a", "")],
+ ]
+
+ # -2-
+ lines = list(split_lines([("class:a", "\n")]))
+
+ assert lines == [
+ [],
+ [("class:a", "")],
+ ]
+
+ # -3-
+ lines = list(split_lines([("class:a", "")]))
+
+ assert lines == [
+ [("class:a", "")],
+ ]
diff --git a/tests/test_history.py b/tests/test_history.py
new file mode 100644
index 0000000..500b7f1
--- /dev/null
+++ b/tests/test_history.py
@@ -0,0 +1,103 @@
+from __future__ import annotations
+
+from asyncio import run
+
+from prompt_toolkit.history import FileHistory, InMemoryHistory, ThreadedHistory
+
+
+def _call_history_load(history):
+ """
+ Helper: Call the history "load" method and return the result as a list of strings.
+ """
+ result = []
+
+ async def call_load():
+ async for item in history.load():
+ result.append(item)
+
+ run(call_load())
+ return result
+
+
+def test_in_memory_history():
+ history = InMemoryHistory()
+ history.append_string("hello")
+ history.append_string("world")
+
+ # Newest should yield first.
+ assert _call_history_load(history) == ["world", "hello"]
+
+ # Test another call.
+ assert _call_history_load(history) == ["world", "hello"]
+
+ history.append_string("test3")
+ assert _call_history_load(history) == ["test3", "world", "hello"]
+
+ # Passing history as a parameter.
+ history2 = InMemoryHistory(["abc", "def"])
+ assert _call_history_load(history2) == ["def", "abc"]
+
+
+def test_file_history(tmpdir):
+ histfile = tmpdir.join("history")
+
+ history = FileHistory(histfile)
+
+ history.append_string("hello")
+ history.append_string("world")
+
+ # Newest should yield first.
+ assert _call_history_load(history) == ["world", "hello"]
+
+ # Test another call.
+ assert _call_history_load(history) == ["world", "hello"]
+
+ history.append_string("test3")
+ assert _call_history_load(history) == ["test3", "world", "hello"]
+
+ # Create another history instance pointing to the same file.
+ history2 = FileHistory(histfile)
+ assert _call_history_load(history2) == ["test3", "world", "hello"]
+
+
+def test_threaded_file_history(tmpdir):
+ histfile = tmpdir.join("history")
+
+ history = ThreadedHistory(FileHistory(histfile))
+
+ history.append_string("hello")
+ history.append_string("world")
+
+ # Newest should yield first.
+ assert _call_history_load(history) == ["world", "hello"]
+
+ # Test another call.
+ assert _call_history_load(history) == ["world", "hello"]
+
+ history.append_string("test3")
+ assert _call_history_load(history) == ["test3", "world", "hello"]
+
+ # Create another history instance pointing to the same file.
+ history2 = ThreadedHistory(FileHistory(histfile))
+ assert _call_history_load(history2) == ["test3", "world", "hello"]
+
+
+def test_threaded_in_memory_history():
+ # Threaded in memory history is not useful. But testing it anyway, just to
+ # see whether everything plays nicely together.
+ history = ThreadedHistory(InMemoryHistory())
+ history.append_string("hello")
+ history.append_string("world")
+
+ # Newest should yield first.
+ assert _call_history_load(history) == ["world", "hello"]
+
+ # Test another call.
+ assert _call_history_load(history) == ["world", "hello"]
+
+ history.append_string("test3")
+ assert _call_history_load(history) == ["test3", "world", "hello"]
+
+ # Passing history as a parameter.
+ history2 = ThreadedHistory(InMemoryHistory(["abc", "def"]))
+ assert _call_history_load(history2) == ["def", "abc"]
diff --git a/tests/test_inputstream.py b/tests/test_inputstream.py
new file mode 100644
index 0000000..ab1b036
--- /dev/null
+++ b/tests/test_inputstream.py
@@ -0,0 +1,141 @@
+from __future__ import annotations
+
+import pytest
+
+from prompt_toolkit.input.vt100_parser import Vt100Parser
+from prompt_toolkit.keys import Keys
+
+
+class _ProcessorMock:
+ def __init__(self):
+ self.keys = []
+
+ def feed_key(self, key_press):
+ self.keys.append(key_press)
+
+
+@pytest.fixture
+def processor():
+ return _ProcessorMock()
+
+
+@pytest.fixture
+def stream(processor):
+ return Vt100Parser(processor.feed_key)
+
+
+def test_control_keys(processor, stream):
+ stream.feed("\x01\x02\x10")
+
+ assert len(processor.keys) == 3
+ assert processor.keys[0].key == Keys.ControlA
+ assert processor.keys[1].key == Keys.ControlB
+ assert processor.keys[2].key == Keys.ControlP
+ assert processor.keys[0].data == "\x01"
+ assert processor.keys[1].data == "\x02"
+ assert processor.keys[2].data == "\x10"
+
+
+def test_arrows(processor, stream):
+ stream.feed("\x1b[A\x1b[B\x1b[C\x1b[D")
+
+ assert len(processor.keys) == 4
+ assert processor.keys[0].key == Keys.Up
+ assert processor.keys[1].key == Keys.Down
+ assert processor.keys[2].key == Keys.Right
+ assert processor.keys[3].key == Keys.Left
+ assert processor.keys[0].data == "\x1b[A"
+ assert processor.keys[1].data == "\x1b[B"
+ assert processor.keys[2].data == "\x1b[C"
+ assert processor.keys[3].data == "\x1b[D"
+
+
+def test_escape(processor, stream):
+ stream.feed("\x1bhello")
+
+ assert len(processor.keys) == 1 + len("hello")
+ assert processor.keys[0].key == Keys.Escape
+ assert processor.keys[1].key == "h"
+ assert processor.keys[0].data == "\x1b"
+ assert processor.keys[1].data == "h"
+
+
+def test_special_double_keys(processor, stream):
+ stream.feed("\x1b[1;3D") # Should both send escape and left.
+
+ assert len(processor.keys) == 2
+ assert processor.keys[0].key == Keys.Escape
+ assert processor.keys[1].key == Keys.Left
+ assert processor.keys[0].data == "\x1b[1;3D"
+ assert processor.keys[1].data == ""
+
+
+def test_flush_1(processor, stream):
+ # Send left key in two parts without flush.
+ stream.feed("\x1b")
+ stream.feed("[D")
+
+ assert len(processor.keys) == 1
+ assert processor.keys[0].key == Keys.Left
+ assert processor.keys[0].data == "\x1b[D"
+
+
+def test_flush_2(processor, stream):
+ # Send left key with a 'Flush' in between.
+ # The flush should make sure that we process everything before as-is,
+ # with makes the first part just an escape character instead.
+ stream.feed("\x1b")
+ stream.flush()
+ stream.feed("[D")
+
+ assert len(processor.keys) == 3
+ assert processor.keys[0].key == Keys.Escape
+ assert processor.keys[1].key == "["
+ assert processor.keys[2].key == "D"
+
+ assert processor.keys[0].data == "\x1b"
+ assert processor.keys[1].data == "["
+ assert processor.keys[2].data == "D"
+
+
+def test_meta_arrows(processor, stream):
+ stream.feed("\x1b\x1b[D")
+
+ assert len(processor.keys) == 2
+ assert processor.keys[0].key == Keys.Escape
+ assert processor.keys[1].key == Keys.Left
+
+
+def test_control_square_close(processor, stream):
+ stream.feed("\x1dC")
+
+ assert len(processor.keys) == 2
+ assert processor.keys[0].key == Keys.ControlSquareClose
+ assert processor.keys[1].key == "C"
+
+
+def test_invalid(processor, stream):
+ # Invalid sequence that has at two characters in common with other
+ # sequences.
+ stream.feed("\x1b[*")
+
+ assert len(processor.keys) == 3
+ assert processor.keys[0].key == Keys.Escape
+ assert processor.keys[1].key == "["
+ assert processor.keys[2].key == "*"
+
+
+def test_cpr_response(processor, stream):
+ stream.feed("a\x1b[40;10Rb")
+ assert len(processor.keys) == 3
+ assert processor.keys[0].key == "a"
+ assert processor.keys[1].key == Keys.CPRResponse
+ assert processor.keys[2].key == "b"
+
+
+def test_cpr_response_2(processor, stream):
+ # Make sure that the newline is not included in the CPR response.
+ stream.feed("\x1b[40;1R\n")
+ assert len(processor.keys) == 2
+ assert processor.keys[0].key == Keys.CPRResponse
+ assert processor.keys[1].key == Keys.ControlJ
diff --git a/tests/test_key_binding.py b/tests/test_key_binding.py
new file mode 100644
index 0000000..1c60880
--- /dev/null
+++ b/tests/test_key_binding.py
@@ -0,0 +1,200 @@
+from __future__ import annotations
+
+from contextlib import contextmanager
+
+import pytest
+
+from prompt_toolkit.application import Application
+from prompt_toolkit.application.current import set_app
+from prompt_toolkit.input.defaults import create_pipe_input
+from prompt_toolkit.key_binding.key_bindings import KeyBindings
+from prompt_toolkit.key_binding.key_processor import KeyPress, KeyProcessor
+from prompt_toolkit.keys import Keys
+from prompt_toolkit.layout import Layout, Window
+from prompt_toolkit.output import DummyOutput
+
+
+class Handlers:
+ def __init__(self):
+ self.called = []
+
+ def __getattr__(self, name):
+ def func(event):
+ self.called.append(name)
+
+ return func
+
+
+@contextmanager
+def set_dummy_app():
+ """
+ Return a context manager that makes sure that this dummy application is
+ active. This is important, because we need an `Application` with
+ `is_done=False` flag, otherwise no keys will be processed.
+ """
+ with create_pipe_input() as pipe_input:
+ app = Application(
+ layout=Layout(Window()),
+ output=DummyOutput(),
+ input=pipe_input,
+ )
+
+ # Don't start background tasks for these tests. The `KeyProcessor`
+ # wants to create a background task for flushing keys. We can ignore it
+ # here for these tests.
+ # This patch is not clean. In the future, when we can use Taskgroups,
+ # the `Application` should pass its task group to the constructor of
+ # `KeyProcessor`. That way, it doesn't have to do a lookup using
+ # `get_app()`.
+ app.create_background_task = lambda *_, **kw: None
+
+ with set_app(app):
+ yield
+
+
+@pytest.fixture
+def handlers():
+ return Handlers()
+
+
+@pytest.fixture
+def bindings(handlers):
+ bindings = KeyBindings()
+ bindings.add(Keys.ControlX, Keys.ControlC)(handlers.controlx_controlc)
+ bindings.add(Keys.ControlX)(handlers.control_x)
+ bindings.add(Keys.ControlD)(handlers.control_d)
+ bindings.add(Keys.ControlSquareClose, Keys.Any)(handlers.control_square_close_any)
+
+ return bindings
+
+
+@pytest.fixture
+def processor(bindings):
+ return KeyProcessor(bindings)
+
+
+def test_remove_bindings(handlers):
+ with set_dummy_app():
+ h = handlers.controlx_controlc
+ h2 = handlers.controld
+
+ # Test passing a handler to the remove() function.
+ bindings = KeyBindings()
+ bindings.add(Keys.ControlX, Keys.ControlC)(h)
+ bindings.add(Keys.ControlD)(h2)
+ assert len(bindings.bindings) == 2
+ bindings.remove(h)
+ assert len(bindings.bindings) == 1
+
+ # Test passing a key sequence to the remove() function.
+ bindings = KeyBindings()
+ bindings.add(Keys.ControlX, Keys.ControlC)(h)
+ bindings.add(Keys.ControlD)(h2)
+ assert len(bindings.bindings) == 2
+ bindings.remove(Keys.ControlX, Keys.ControlC)
+ assert len(bindings.bindings) == 1
+
+
+def test_feed_simple(processor, handlers):
+ with set_dummy_app():
+ processor.feed(KeyPress(Keys.ControlX, "\x18"))
+ processor.feed(KeyPress(Keys.ControlC, "\x03"))
+ processor.process_keys()
+
+ assert handlers.called == ["controlx_controlc"]
+
+
+def test_feed_several(processor, handlers):
+ with set_dummy_app():
+ # First an unknown key first.
+ processor.feed(KeyPress(Keys.ControlQ, ""))
+ processor.process_keys()
+
+ assert handlers.called == []
+
+ # Followed by a know key sequence.
+ processor.feed(KeyPress(Keys.ControlX, ""))
+ processor.feed(KeyPress(Keys.ControlC, ""))
+ processor.process_keys()
+
+ assert handlers.called == ["controlx_controlc"]
+
+ # Followed by another unknown sequence.
+ processor.feed(KeyPress(Keys.ControlR, ""))
+ processor.feed(KeyPress(Keys.ControlS, ""))
+
+ # Followed again by a know key sequence.
+ processor.feed(KeyPress(Keys.ControlD, ""))
+ processor.process_keys()
+
+ assert handlers.called == ["controlx_controlc", "control_d"]
+
+
+def test_control_square_closed_any(processor, handlers):
+ with set_dummy_app():
+ processor.feed(KeyPress(Keys.ControlSquareClose, ""))
+ processor.feed(KeyPress("C", "C"))
+ processor.process_keys()
+
+ assert handlers.called == ["control_square_close_any"]
+
+
+def test_common_prefix(processor, handlers):
+ with set_dummy_app():
+ # Sending Control_X should not yet do anything, because there is
+ # another sequence starting with that as well.
+ processor.feed(KeyPress(Keys.ControlX, ""))
+ processor.process_keys()
+
+ assert handlers.called == []
+
+ # When another key is pressed, we know that we did not meant the longer
+ # "ControlX ControlC" sequence and the callbacks are called.
+ processor.feed(KeyPress(Keys.ControlD, ""))
+ processor.process_keys()
+
+ assert handlers.called == ["control_x", "control_d"]
+
+
+def test_previous_key_sequence(processor):
+ """
+ test whether we receive the correct previous_key_sequence.
+ """
+ with set_dummy_app():
+ events = []
+
+ def handler(event):
+ events.append(event)
+
+ # Build registry.
+ registry = KeyBindings()
+ registry.add("a", "a")(handler)
+ registry.add("b", "b")(handler)
+ processor = KeyProcessor(registry)
+
+ # Create processor and feed keys.
+ processor.feed(KeyPress("a", "a"))
+ processor.feed(KeyPress("a", "a"))
+ processor.feed(KeyPress("b", "b"))
+ processor.feed(KeyPress("b", "b"))
+ processor.process_keys()
+
+ # Test.
+ assert len(events) == 2
+ assert len(events[0].key_sequence) == 2
+ assert events[0].key_sequence[0].key == "a"
+ assert events[0].key_sequence[0].data == "a"
+ assert events[0].key_sequence[1].key == "a"
+ assert events[0].key_sequence[1].data == "a"
+ assert events[0].previous_key_sequence == []
+
+ assert len(events[1].key_sequence) == 2
+ assert events[1].key_sequence[0].key == "b"
+ assert events[1].key_sequence[0].data == "b"
+ assert events[1].key_sequence[1].key == "b"
+ assert events[1].key_sequence[1].data == "b"
+ assert len(events[1].previous_key_sequence) == 2
+ assert events[1].previous_key_sequence[0].key == "a"
+ assert events[1].previous_key_sequence[0].data == "a"
+ assert events[1].previous_key_sequence[1].key == "a"
+ assert events[1].previous_key_sequence[1].data == "a"
diff --git a/tests/test_layout.py b/tests/test_layout.py
new file mode 100644
index 0000000..cbbbcd0
--- /dev/null
+++ b/tests/test_layout.py
@@ -0,0 +1,53 @@
+from __future__ import annotations
+
+import pytest
+
+from prompt_toolkit.layout import InvalidLayoutError, Layout
+from prompt_toolkit.layout.containers import HSplit, VSplit, Window
+from prompt_toolkit.layout.controls import BufferControl
+
+
+def test_layout_class():
+ c1 = BufferControl()
+ c2 = BufferControl()
+ c3 = BufferControl()
+ win1 = Window(content=c1)
+ win2 = Window(content=c2)
+ win3 = Window(content=c3)
+
+ layout = Layout(container=VSplit([HSplit([win1, win2]), win3]))
+
+ # Listing of windows/controls.
+ assert list(layout.find_all_windows()) == [win1, win2, win3]
+ assert list(layout.find_all_controls()) == [c1, c2, c3]
+
+ # Focusing something.
+ layout.focus(c1)
+ assert layout.has_focus(c1)
+ assert layout.has_focus(win1)
+ assert layout.current_control == c1
+ assert layout.previous_control == c1
+
+ layout.focus(c2)
+ assert layout.has_focus(c2)
+ assert layout.has_focus(win2)
+ assert layout.current_control == c2
+ assert layout.previous_control == c1
+
+ layout.focus(win3)
+ assert layout.has_focus(c3)
+ assert layout.has_focus(win3)
+ assert layout.current_control == c3
+ assert layout.previous_control == c2
+
+ # Pop focus. This should focus the previous control again.
+ layout.focus_last()
+ assert layout.has_focus(c2)
+ assert layout.has_focus(win2)
+ assert layout.current_control == c2
+ assert layout.previous_control == c1
+
+
+def test_create_invalid_layout():
+ with pytest.raises(InvalidLayoutError):
+ Layout(HSplit([]))
diff --git a/tests/test_memory_leaks.py b/tests/test_memory_leaks.py
new file mode 100644
index 0000000..31ea7c8
--- /dev/null
+++ b/tests/test_memory_leaks.py
@@ -0,0 +1,35 @@
+from __future__ import annotations
+
+import gc
+
+import pytest
+
+from prompt_toolkit.shortcuts.prompt import PromptSession
+
+
+def _count_prompt_session_instances() -> int:
+ # Run full GC collection first.
+ gc.collect()
+
+ # Count number of remaining referenced `PromptSession` instances.
+ objects = gc.get_objects()
+ return len([obj for obj in objects if isinstance(obj, PromptSession)])
+
+
+# Fails in GitHub CI, probably due to GC differences.
+@pytest.mark.xfail(reason="Memory leak testing fails in GitHub CI.")
+def test_prompt_session_memory_leak() -> None:
+ before_count = _count_prompt_session_instances()
+
+ # Somehow in CI/CD, the before_count is > 0
+ assert before_count == 0
+
+ p = PromptSession()
+
+ after_count = _count_prompt_session_instances()
+ assert after_count == before_count + 1
+
+ del p
+
+ after_delete_count = _count_prompt_session_instances()
+ assert after_delete_count == before_count
diff --git a/tests/test_print_formatted_text.py b/tests/test_print_formatted_text.py
new file mode 100644
index 0000000..26c7265
--- /dev/null
+++ b/tests/test_print_formatted_text.py
@@ -0,0 +1,92 @@
+"""
+Test the `print` function.
+"""
+from __future__ import annotations
+
+import pytest
+
+from prompt_toolkit import print_formatted_text as pt_print
+from prompt_toolkit.formatted_text import HTML, FormattedText, to_formatted_text
+from prompt_toolkit.output import ColorDepth
+from prompt_toolkit.styles import Style
+from prompt_toolkit.utils import is_windows
+
+
+class _Capture:
+ "Emulate an stdout object."
+
+ def __init__(self):
+ self._data = []
+
+ def write(self, data):
+ self._data.append(data)
+
+ @property
+ def data(self):
+ return "".join(self._data)
+
+ def flush(self):
+ pass
+
+ def isatty(self):
+ return True
+
+ def fileno(self):
+ # File descriptor is not used for printing formatted text.
+ # (It is only needed for getting the terminal size.)
+ return -1
+
+
+@pytest.mark.skipif(is_windows(), reason="Doesn't run on Windows yet.")
+def test_print_formatted_text():
+ f = _Capture()
+ pt_print([("", "hello"), ("", "world")], file=f)
+ assert "hello" in f.data
+ assert "world" in f.data
+
+
+@pytest.mark.skipif(is_windows(), reason="Doesn't run on Windows yet.")
+def test_print_formatted_text_backslash_r():
+ f = _Capture()
+ pt_print("hello\r\n", file=f)
+ assert "hello" in f.data
+
+
+@pytest.mark.skipif(is_windows(), reason="Doesn't run on Windows yet.")
+def test_formatted_text_with_style():
+ f = _Capture()
+ style = Style.from_dict(
+ {
+ "hello": "#ff0066",
+ "world": "#44ff44 italic",
+ }
+ )
+ tokens = FormattedText(
+ [
+ ("class:hello", "Hello "),
+ ("class:world", "world"),
+ ]
+ )
+
+ # NOTE: We pass the default (8bit) color depth, so that the unit tests
+ # don't start failing when environment variables change.
+ pt_print(tokens, style=style, file=f, color_depth=ColorDepth.DEFAULT)
+ assert "\x1b[0;38;5;197mHello" in f.data
+ assert "\x1b[0;38;5;83;3mworld" in f.data
+
+
+@pytest.mark.skipif(is_windows(), reason="Doesn't run on Windows yet.")
+def test_html_with_style():
+ """
+ Text `print_formatted_text` with `HTML` wrapped in `to_formatted_text`.
+ """
+ f = _Capture()
+
+ html = HTML("<ansigreen>hello</ansigreen> <b>world</b>")
+ formatted_text = to_formatted_text(html, style="class:myhtml")
+ pt_print(formatted_text, file=f, color_depth=ColorDepth.DEFAULT)
+
+ assert (
+ f.data
+ == "\x1b[0m\x1b[?7h\x1b[0;32mhello\x1b[0m \x1b[0;1mworld\x1b[0m\r\n\x1b[0m"
+ )
diff --git a/tests/test_regular_languages.py b/tests/test_regular_languages.py
new file mode 100644
index 0000000..deef6b8
--- /dev/null
+++ b/tests/test_regular_languages.py
@@ -0,0 +1,102 @@
+from __future__ import annotations
+
+from prompt_toolkit.completion import CompleteEvent, Completer, Completion
+from prompt_toolkit.contrib.regular_languages import compile
+from prompt_toolkit.contrib.regular_languages.compiler import Match, Variables
+from prompt_toolkit.contrib.regular_languages.completion import GrammarCompleter
+from prompt_toolkit.document import Document
+
+
+def test_simple_match():
+ g = compile("hello|world")
+
+ m = g.match("hello")
+ assert isinstance(m, Match)
+
+ m = g.match("world")
+ assert isinstance(m, Match)
+
+ m = g.match("somethingelse")
+ assert m is None
+
+
+def test_variable_varname():
+ """
+ Test `Variable` with varname.
+ """
+ g = compile("((?P<varname>hello|world)|test)")
+
+ m = g.match("hello")
+ variables = m.variables()
+ assert isinstance(variables, Variables)
+ assert variables.get("varname") == "hello"
+ assert variables["varname"] == "hello"
+
+ m = g.match("world")
+ variables = m.variables()
+ assert isinstance(variables, Variables)
+ assert variables.get("varname") == "world"
+ assert variables["varname"] == "world"
+
+ m = g.match("test")
+ variables = m.variables()
+ assert isinstance(variables, Variables)
+ assert variables.get("varname") is None
+ assert variables["varname"] is None
+
+
+def test_prefix():
+ """
+ Test `match_prefix`.
+ """
+ g = compile(r"(hello\ world|something\ else)")
+
+ m = g.match_prefix("hello world")
+ assert isinstance(m, Match)
+
+ m = g.match_prefix("he")
+ assert isinstance(m, Match)
+
+ m = g.match_prefix("")
+ assert isinstance(m, Match)
+
+ m = g.match_prefix("som")
+ assert isinstance(m, Match)
+
+ m = g.match_prefix("hello wor")
+ assert isinstance(m, Match)
+
+ m = g.match_prefix("no-match")
+ assert m.trailing_input().start == 0
+ assert m.trailing_input().stop == len("no-match")
+
+ m = g.match_prefix("hellotest")
+ assert m.trailing_input().start == len("hello")
+ assert m.trailing_input().stop == len("hellotest")
+
+
+def test_completer():
+ class completer1(Completer):
+ def get_completions(self, document, complete_event):
+ yield Completion("before-%s-after" % document.text, -len(document.text))
+ yield Completion("before-%s-after-B" % document.text, -len(document.text))
+
+ class completer2(Completer):
+ def get_completions(self, document, complete_event):
+ yield Completion("before2-%s-after2" % document.text, -len(document.text))
+ yield Completion("before2-%s-after2-B" % document.text, -len(document.text))
+
+ # Create grammar. "var1" + "whitespace" + "var2"
+ g = compile(r"(?P<var1>[a-z]*) \s+ (?P<var2>[a-z]*)")
+
+ # Test 'get_completions()'
+ completer = GrammarCompleter(g, {"var1": completer1(), "var2": completer2()})
+ completions = list(
+ completer.get_completions(Document("abc def", len("abc def")), CompleteEvent())
+ )
+
+ assert len(completions) == 2
+ assert completions[0].text == "before2-def-after2"
+ assert completions[0].start_position == -3
+ assert completions[1].text == "before2-def-after2-B"
+ assert completions[1].start_position == -3
diff --git a/tests/test_shortcuts.py b/tests/test_shortcuts.py
new file mode 100644
index 0000000..287c6d3
--- /dev/null
+++ b/tests/test_shortcuts.py
@@ -0,0 +1,68 @@
+from __future__ import annotations
+
+from prompt_toolkit.shortcuts import print_container
+from prompt_toolkit.shortcuts.prompt import _split_multiline_prompt
+from prompt_toolkit.widgets import Frame, TextArea
+
+
+def test_split_multiline_prompt():
+ # Test 1: no newlines:
+ tokens = [("class:testclass", "ab")]
+ has_before_tokens, before, first_input_line = _split_multiline_prompt(
+ lambda: tokens
+ )
+ assert has_before_tokens() is False
+ assert before() == []
+ assert first_input_line() == [
+ ("class:testclass", "a"),
+ ("class:testclass", "b"),
+ ]
+
+ # Test 1: multiple lines.
+ tokens = [("class:testclass", "ab\ncd\nef")]
+ has_before_tokens, before, first_input_line = _split_multiline_prompt(
+ lambda: tokens
+ )
+ assert has_before_tokens() is True
+ assert before() == [
+ ("class:testclass", "a"),
+ ("class:testclass", "b"),
+ ("class:testclass", "\n"),
+ ("class:testclass", "c"),
+ ("class:testclass", "d"),
+ ]
+ assert first_input_line() == [
+ ("class:testclass", "e"),
+ ("class:testclass", "f"),
+ ]
+
+ # Edge case 1: starting with a newline.
+ tokens = [("class:testclass", "\nab")]
+ has_before_tokens, before, first_input_line = _split_multiline_prompt(
+ lambda: tokens
+ )
+ assert has_before_tokens() is True
+ assert before() == []
+ assert first_input_line() == [("class:testclass", "a"), ("class:testclass", "b")]
+
+ # Edge case 2: starting with two newlines.
+ tokens = [("class:testclass", "\n\nab")]
+ has_before_tokens, before, first_input_line = _split_multiline_prompt(
+ lambda: tokens
+ )
+ assert has_before_tokens() is True
+ assert before() == [("class:testclass", "\n")]
+ assert first_input_line() == [("class:testclass", "a"), ("class:testclass", "b")]
+
+
+def test_print_container(tmpdir):
+ # Call `print_container`, render to a dummy file.
+ f = tmpdir.join("output")
+ with open(f, "w") as fd:
+ print_container(Frame(TextArea(text="Hello world!\n"), title="Title"), file=fd)
+
+ # Verify rendered output.
+ with open(f) as fd:
+ text = fd.read()
+ assert "Hello world" in text
+ assert "Title" in text
diff --git a/tests/test_style.py b/tests/test_style.py
new file mode 100644
index 0000000..d0a4790
--- /dev/null
+++ b/tests/test_style.py
@@ -0,0 +1,276 @@
+from __future__ import annotations
+
+from prompt_toolkit.styles import Attrs, Style, SwapLightAndDarkStyleTransformation
+
+
+def test_style_from_dict():
+ style = Style.from_dict(
+ {
+ "a": "#ff0000 bold underline strike italic",
+ "b": "bg:#00ff00 blink reverse",
+ }
+ )
+
+ # Lookup of class:a.
+ expected = Attrs(
+ color="ff0000",
+ bgcolor="",
+ bold=True,
+ underline=True,
+ strike=True,
+ italic=True,
+ blink=False,
+ reverse=False,
+ hidden=False,
+ )
+ assert style.get_attrs_for_style_str("class:a") == expected
+
+ # Lookup of class:b.
+ expected = Attrs(
+ color="",
+ bgcolor="00ff00",
+ bold=False,
+ underline=False,
+ strike=False,
+ italic=False,
+ blink=True,
+ reverse=True,
+ hidden=False,
+ )
+ assert style.get_attrs_for_style_str("class:b") == expected
+
+ # Test inline style.
+ expected = Attrs(
+ color="ff0000",
+ bgcolor="",
+ bold=False,
+ underline=False,
+ strike=False,
+ italic=False,
+ blink=False,
+ reverse=False,
+ hidden=False,
+ )
+ assert style.get_attrs_for_style_str("#ff0000") == expected
+
+ # Combine class name and inline style (Whatever is defined later gets priority.)
+ expected = Attrs(
+ color="00ff00",
+ bgcolor="",
+ bold=True,
+ underline=True,
+ strike=True,
+ italic=True,
+ blink=False,
+ reverse=False,
+ hidden=False,
+ )
+ assert style.get_attrs_for_style_str("class:a #00ff00") == expected
+
+ expected = Attrs(
+ color="ff0000",
+ bgcolor="",
+ bold=True,
+ underline=True,
+ strike=True,
+ italic=True,
+ blink=False,
+ reverse=False,
+ hidden=False,
+ )
+ assert style.get_attrs_for_style_str("#00ff00 class:a") == expected
+
+
+def test_class_combinations_1():
+ # In this case, our style has both class 'a' and 'b'.
+ # Given that the style for 'a b' is defined at the end, that one is used.
+ style = Style(
+ [
+ ("a", "#0000ff"),
+ ("b", "#00ff00"),
+ ("a b", "#ff0000"),
+ ]
+ )
+ expected = Attrs(
+ color="ff0000",
+ bgcolor="",
+ bold=False,
+ underline=False,
+ strike=False,
+ italic=False,
+ blink=False,
+ reverse=False,
+ hidden=False,
+ )
+ assert style.get_attrs_for_style_str("class:a class:b") == expected
+ assert style.get_attrs_for_style_str("class:a,b") == expected
+ assert style.get_attrs_for_style_str("class:a,b,c") == expected
+
+ # Changing the order shouldn't matter.
+ assert style.get_attrs_for_style_str("class:b class:a") == expected
+ assert style.get_attrs_for_style_str("class:b,a") == expected
+
+
+def test_class_combinations_2():
+ # In this case, our style has both class 'a' and 'b'.
+ # The style that is defined the latest get priority.
+ style = Style(
+ [
+ ("a b", "#ff0000"),
+ ("b", "#00ff00"),
+ ("a", "#0000ff"),
+ ]
+ )
+ expected = Attrs(
+ color="00ff00",
+ bgcolor="",
+ bold=False,
+ underline=False,
+ strike=False,
+ italic=False,
+ blink=False,
+ reverse=False,
+ hidden=False,
+ )
+ assert style.get_attrs_for_style_str("class:a class:b") == expected
+ assert style.get_attrs_for_style_str("class:a,b") == expected
+ assert style.get_attrs_for_style_str("class:a,b,c") == expected
+
+ # Defining 'a' latest should give priority to 'a'.
+ expected = Attrs(
+ color="0000ff",
+ bgcolor="",
+ bold=False,
+ underline=False,
+ strike=False,
+ italic=False,
+ blink=False,
+ reverse=False,
+ hidden=False,
+ )
+ assert style.get_attrs_for_style_str("class:b class:a") == expected
+ assert style.get_attrs_for_style_str("class:b,a") == expected
+
+
+def test_substyles():
+ style = Style(
+ [
+ ("a.b", "#ff0000 bold"),
+ ("a", "#0000ff"),
+ ("b", "#00ff00"),
+ ("b.c", "#0000ff italic"),
+ ]
+ )
+
+ # Starting with a.*
+ expected = Attrs(
+ color="0000ff",
+ bgcolor="",
+ bold=False,
+ underline=False,
+ strike=False,
+ italic=False,
+ blink=False,
+ reverse=False,
+ hidden=False,
+ )
+ assert style.get_attrs_for_style_str("class:a") == expected
+
+ expected = Attrs(
+ color="ff0000",
+ bgcolor="",
+ bold=True,
+ underline=False,
+ strike=False,
+ italic=False,
+ blink=False,
+ reverse=False,
+ hidden=False,
+ )
+ assert style.get_attrs_for_style_str("class:a.b") == expected
+ assert style.get_attrs_for_style_str("class:a.b.c") == expected
+
+ # Starting with b.*
+ expected = Attrs(
+ color="00ff00",
+ bgcolor="",
+ bold=False,
+ underline=False,
+ strike=False,
+ italic=False,
+ blink=False,
+ reverse=False,
+ hidden=False,
+ )
+ assert style.get_attrs_for_style_str("class:b") == expected
+ assert style.get_attrs_for_style_str("class:b.a") == expected
+
+ expected = Attrs(
+ color="0000ff",
+ bgcolor="",
+ bold=False,
+ underline=False,
+ strike=False,
+ italic=True,
+ blink=False,
+ reverse=False,
+ hidden=False,
+ )
+ assert style.get_attrs_for_style_str("class:b.c") == expected
+ assert style.get_attrs_for_style_str("class:b.c.d") == expected
+
+
+def test_swap_light_and_dark_style_transformation():
+ transformation = SwapLightAndDarkStyleTransformation()
+
+ # Test with 6 digit hex colors.
+ before = Attrs(
+ color="440000",
+ bgcolor="888844",
+ bold=True,
+ underline=True,
+ strike=True,
+ italic=True,
+ blink=False,
+ reverse=False,
+ hidden=False,
+ )
+ after = Attrs(
+ color="ffbbbb",
+ bgcolor="bbbb76",
+ bold=True,
+ underline=True,
+ strike=True,
+ italic=True,
+ blink=False,
+ reverse=False,
+ hidden=False,
+ )
+
+ assert transformation.transform_attrs(before) == after
+
+ # Test with ANSI colors.
+ before = Attrs(
+ color="ansired",
+ bgcolor="ansiblack",
+ bold=True,
+ underline=True,
+ strike=True,
+ italic=True,
+ blink=False,
+ reverse=False,
+ hidden=False,
+ )
+ after = Attrs(
+ color="ansibrightred",
+ bgcolor="ansiwhite",
+ bold=True,
+ underline=True,
+ strike=True,
+ italic=True,
+ blink=False,
+ reverse=False,
+ hidden=False,
+ )
+
+ assert transformation.transform_attrs(before) == after
diff --git a/tests/test_style_transformation.py b/tests/test_style_transformation.py
new file mode 100644
index 0000000..e4eee7c
--- /dev/null
+++ b/tests/test_style_transformation.py
@@ -0,0 +1,51 @@
+from __future__ import annotations
+
+import pytest
+
+from prompt_toolkit.styles import AdjustBrightnessStyleTransformation, Attrs
+
+
+@pytest.fixture
+def default_attrs():
+ return Attrs(
+ color="",
+ bgcolor="",
+ bold=False,
+ underline=False,
+ strike=False,
+ italic=False,
+ blink=False,
+ reverse=False,
+ hidden=False,
+ )
+
+
+def test_adjust_brightness_style_transformation(default_attrs):
+ tr = AdjustBrightnessStyleTransformation(0.5, 1.0)
+
+ attrs = tr.transform_attrs(default_attrs._replace(color="ff0000"))
+ assert attrs.color == "ff7f7f"
+
+ attrs = tr.transform_attrs(default_attrs._replace(color="00ffaa"))
+ assert attrs.color == "7fffd4"
+
+ # When a background color is given, nothing should change.
+ attrs = tr.transform_attrs(default_attrs._replace(color="00ffaa", bgcolor="white"))
+ assert attrs.color == "00ffaa"
+
+ # Test ansi colors.
+ attrs = tr.transform_attrs(default_attrs._replace(color="ansiblue"))
+ assert attrs.color == "6666ff"
+
+ # Test 'ansidefault'. This shouldn't change.
+ attrs = tr.transform_attrs(default_attrs._replace(color="ansidefault"))
+ assert attrs.color == "ansidefault"
+
+ # When 0 and 1 are given, don't do any style transformation.
+ tr2 = AdjustBrightnessStyleTransformation(0, 1)
+
+ attrs = tr2.transform_attrs(default_attrs._replace(color="ansiblue"))
+ assert attrs.color == "ansiblue"
+
+ attrs = tr2.transform_attrs(default_attrs._replace(color="00ffaa"))
+ assert attrs.color == "00ffaa"
diff --git a/tests/test_utils.py b/tests/test_utils.py
new file mode 100644
index 0000000..9d4c808
--- /dev/null
+++ b/tests/test_utils.py
@@ -0,0 +1,78 @@
+from __future__ import annotations
+
+import itertools
+
+import pytest
+
+from prompt_toolkit.utils import take_using_weights
+
+
+def test_using_weights():
+ def take(generator, count):
+ return list(itertools.islice(generator, 0, count))
+
+ # Check distribution.
+ data = take(take_using_weights(["A", "B", "C"], [5, 10, 20]), 35)
+ assert data.count("A") == 5
+ assert data.count("B") == 10
+ assert data.count("C") == 20
+
+ assert data == [
+ "A",
+ "B",
+ "C",
+ "C",
+ "B",
+ "C",
+ "C",
+ "A",
+ "B",
+ "C",
+ "C",
+ "B",
+ "C",
+ "C",
+ "A",
+ "B",
+ "C",
+ "C",
+ "B",
+ "C",
+ "C",
+ "A",
+ "B",
+ "C",
+ "C",
+ "B",
+ "C",
+ "C",
+ "A",
+ "B",
+ "C",
+ "C",
+ "B",
+ "C",
+ "C",
+ ]
+
+ # Another order.
+ data = take(take_using_weights(["A", "B", "C"], [20, 10, 5]), 35)
+ assert data.count("A") == 20
+ assert data.count("B") == 10
+ assert data.count("C") == 5
+
+ # Bigger numbers.
+ data = take(take_using_weights(["A", "B", "C"], [20, 10, 5]), 70)
+ assert data.count("A") == 40
+ assert data.count("B") == 20
+ assert data.count("C") == 10
+
+ # Negative numbers.
+ data = take(take_using_weights(["A", "B", "C"], [-20, 10, 0]), 70)
+ assert data.count("A") == 0
+ assert data.count("B") == 70
+ assert data.count("C") == 0
+
+ # All zero-weight items.
+ with pytest.raises(ValueError):
+ take(take_using_weights(["A", "B", "C"], [0, 0, 0]), 70)
diff --git a/tests/test_vt100_output.py b/tests/test_vt100_output.py
new file mode 100644
index 0000000..65c8377
--- /dev/null
+++ b/tests/test_vt100_output.py
@@ -0,0 +1,20 @@
+from __future__ import annotations
+
+from prompt_toolkit.output.vt100 import _get_closest_ansi_color
+
+
+def test_get_closest_ansi_color():
+ # White
+ assert _get_closest_ansi_color(255, 255, 255) == "ansiwhite"
+ assert _get_closest_ansi_color(250, 250, 250) == "ansiwhite"
+
+ # Black
+ assert _get_closest_ansi_color(0, 0, 0) == "ansiblack"
+ assert _get_closest_ansi_color(5, 5, 5) == "ansiblack"
+
+ # Green
+ assert _get_closest_ansi_color(0, 255, 0) == "ansibrightgreen"
+ assert _get_closest_ansi_color(10, 255, 0) == "ansibrightgreen"
+ assert _get_closest_ansi_color(0, 255, 10) == "ansibrightgreen"
+
+ assert _get_closest_ansi_color(220, 220, 100) == "ansiyellow"
diff --git a/tests/test_widgets.py b/tests/test_widgets.py
new file mode 100644
index 0000000..ee7745a
--- /dev/null
+++ b/tests/test_widgets.py
@@ -0,0 +1,20 @@
+from __future__ import annotations
+
+from prompt_toolkit.formatted_text import fragment_list_to_text
+from prompt_toolkit.layout import to_window
+from prompt_toolkit.widgets import Button
+
+
+def _to_text(button: Button) -> str:
+ control = to_window(button).content
+ return fragment_list_to_text(control.text())
+
+
+def test_default_button():
+ button = Button("Exit")
+ assert _to_text(button) == "< Exit >"
+
+
+def test_custom_button():
+ button = Button("Exit", left_symbol="[", right_symbol="]")
+ assert _to_text(button) == "[ Exit ]"
diff --git a/tests/test_yank_nth_arg.py b/tests/test_yank_nth_arg.py
new file mode 100644
index 0000000..7167a26
--- /dev/null
+++ b/tests/test_yank_nth_arg.py
@@ -0,0 +1,86 @@
+from __future__ import annotations
+
+import pytest
+
+from prompt_toolkit.buffer import Buffer
+from prompt_toolkit.history import InMemoryHistory
+
+
+@pytest.fixture
+def _history():
+ "Prefilled history."
+ history = InMemoryHistory()
+ history.append_string("alpha beta gamma delta")
+ history.append_string("one two three four")
+ return history
+
+
+# Test yank_last_arg.
+
+
+def test_empty_history():
+ buf = Buffer()
+ buf.yank_last_arg()
+ assert buf.document.current_line == ""
+
+
+def test_simple_search(_history):
+ buff = Buffer(history=_history)
+ buff.yank_last_arg()
+ assert buff.document.current_line == "four"
+
+
+def test_simple_search_with_quotes(_history):
+ _history.append_string("""one two "three 'x' four"\n""")
+ buff = Buffer(history=_history)
+ buff.yank_last_arg()
+ assert buff.document.current_line == '''"three 'x' four"'''
+
+
+def test_simple_search_with_arg(_history):
+ buff = Buffer(history=_history)
+ buff.yank_last_arg(n=2)
+ assert buff.document.current_line == "three"
+
+
+def test_simple_search_with_arg_out_of_bounds(_history):
+ buff = Buffer(history=_history)
+ buff.yank_last_arg(n=8)
+ assert buff.document.current_line == ""
+
+
+def test_repeated_search(_history):
+ buff = Buffer(history=_history)
+ buff.yank_last_arg()
+ buff.yank_last_arg()
+ assert buff.document.current_line == "delta"
+
+
+def test_repeated_search_with_wraparound(_history):
+ buff = Buffer(history=_history)
+ buff.yank_last_arg()
+ buff.yank_last_arg()
+ buff.yank_last_arg()
+ assert buff.document.current_line == "four"
+
+
+# Test yank_last_arg.
+
+
+def test_yank_nth_arg(_history):
+ buff = Buffer(history=_history)
+ buff.yank_nth_arg()
+ assert buff.document.current_line == "two"
+
+
+def test_repeated_yank_nth_arg(_history):
+ buff = Buffer(history=_history)
+ buff.yank_nth_arg()
+ buff.yank_nth_arg()
+ assert buff.document.current_line == "beta"
+
+
+def test_yank_nth_arg_with_arg(_history):
+ buff = Buffer(history=_history)
+ buff.yank_nth_arg(n=2)
+ assert buff.document.current_line == "three"