From 23507d0e705efe533a4728385f7f2c5db31d2f10 Mon Sep 17 00:00:00 2001 From: Jared Miller Date: Sat, 7 Feb 2026 22:55:53 -0500 Subject: [PATCH] Add editor class with buffer, commands, and undo --- src/mudlib/editor.py | 373 +++++++++++++++++++++++++++++++++++++++++++ tests/test_editor.py | 346 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 719 insertions(+) create mode 100644 src/mudlib/editor.py create mode 100644 tests/test_editor.py diff --git a/src/mudlib/editor.py b/src/mudlib/editor.py new file mode 100644 index 0000000..f90d1ae --- /dev/null +++ b/src/mudlib/editor.py @@ -0,0 +1,373 @@ +"""Text editor for in-game content editing.""" + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass, field + + +@dataclass +class EditorResponse: + """Response from editor input handling.""" + + output: str # text to send back to the player + done: bool # True if editor should be closed (quit/save-and-quit) + saved: bool = False # True if save was performed + + +@dataclass +class Editor: + """Line-based text editor for in-game content. + + Doesn't know about telnet — takes string input, returns string output. + Will be integrated into the shell loop in a later step. + """ + + buffer: list[str] = field(default_factory=list) + cursor: int = 0 # current line (0-indexed internally) + undo_stack: list[list[str]] = field(default_factory=list) + save_callback: Callable[[str], Awaitable[None]] | None = None + content_type: str = "text" # hint for syntax highlighting later + dirty: bool = False + + def __init__( + self, + initial_content: str = "", + save_callback: Callable[[str], Awaitable[None]] | None = None, + content_type: str = "text", + ): + """Initialize editor with optional content.""" + self.save_callback = save_callback + self.content_type = content_type + self.cursor = 0 + self.undo_stack = [] + self.dirty = False + + if initial_content: + self.buffer = initial_content.split("\n") + else: + self.buffer = [] + + def _save_undo_state(self) -> None: + """Save current buffer state to undo stack.""" + self.undo_stack.append(self.buffer.copy()) + # Cap undo stack at 50 entries + if len(self.undo_stack) > 50: + self.undo_stack.pop(0) + + def _mark_dirty(self) -> None: + """Mark buffer as modified.""" + self.dirty = True + + def _view_buffer(self) -> str: + """Return formatted view of entire buffer.""" + if not self.buffer: + return "(empty buffer)" + + # Calculate padding for line numbers + max_line_num = len(self.buffer) + padding = len(str(max_line_num)) + + lines = [] + for i, line in enumerate(self.buffer, 1): + lines.append(f"{i:>{padding}} {line}") + return "\n".join(lines) + + def _view_line(self, line_num: int) -> str: + """Return formatted view of specific line with context.""" + if line_num < 1 or line_num > len(self.buffer): + return f"Error: Line {line_num} does not exist" + + # Show 3 lines of context on each side + context = 3 + start = max(0, line_num - 1 - context) + end = min(len(self.buffer), line_num + context) + + # Calculate padding for line numbers + max_line_num = end + padding = len(str(max_line_num)) + + lines = [] + for i in range(start, end): + display_num = i + 1 + lines.append(f"{display_num:>{padding}} {self.buffer[i]}") + return "\n".join(lines) + + def _insert_line(self, line_num: int, text: str) -> str: + """Insert text at specified line number.""" + if line_num < 1: + return "Error: Line number must be >= 1" + + self._save_undo_state() + + # Insert at end if line_num is beyond buffer + if line_num > len(self.buffer): + self.buffer.append(text) + else: + self.buffer.insert(line_num - 1, text) + + self._mark_dirty() + return f"Inserted at line {line_num}" + + def _delete_line(self, start: int, end: int | None = None) -> str: + """Delete line(s) at specified range.""" + if start < 1 or start > len(self.buffer): + return f"Error: Line {start} does not exist" + + self._save_undo_state() + + if end is None: + # Delete single line + self.buffer.pop(start - 1) + self._mark_dirty() + return f"Deleted line {start}" + else: + # Delete range + if end < start or end > len(self.buffer): + return f"Error: Invalid range {start}-{end}" + + # Delete from start to end (inclusive) + del self.buffer[start - 1 : end] + self._mark_dirty() + return f"Deleted lines {start}-{end}" + + def _replace_line(self, line_num: int, text: str) -> str: + """Replace line at specified number with new text.""" + if line_num < 1 or line_num > len(self.buffer): + return f"Error: Line {line_num} does not exist" + + self._save_undo_state() + self.buffer[line_num - 1] = text + self._mark_dirty() + return f"Replaced line {line_num}" + + def _search_replace(self, old: str, new: str, replace_all: bool = False) -> str: + """Search and replace text in buffer.""" + self._save_undo_state() + count = 0 + + for i, line in enumerate(self.buffer): + if old in line: + if replace_all: + self.buffer[i] = line.replace(old, new) + count += line.count(old) + else: + self.buffer[i] = line.replace(old, new, 1) + count = 1 + self._mark_dirty() + return f"Replaced '{old}' with '{new}' on line {i + 1}" + + if count > 0: + self._mark_dirty() + return f"Replaced {count} occurrence(s) of '{old}' with '{new}'" + else: + # Restore undo state since nothing changed + self.undo_stack.pop() + return f"Not found: '{old}'" + + def _undo(self) -> str: + """Undo last buffer change.""" + if not self.undo_stack: + return "Nothing to undo" + + self.buffer = self.undo_stack.pop() + # Undo might restore clean state, but we'll keep dirty flag + # (conservative approach - user can save to clear it) + return "Undone last change" + + async def _save(self) -> str: + """Save buffer content via callback.""" + if self.save_callback is None: + return "Error: Cannot save (no save callback configured)" + + content = "\n".join(self.buffer) + await self.save_callback(content) + self.dirty = False + return "Saved" + + def _help(self) -> str: + """Return help text.""" + return """Editor Commands: +: - view entire buffer with line numbers +:N - view line N with context +:h - show this help +:i N text - insert 'text' at line N +:d N - delete line N +:d N M - delete lines N through M +:r N text - replace line N with 'text' +:s old new - search and replace first occurrence +:sa old new - search and replace all occurrences +:w - save +:wq - save and quit +:q - quit (warns if unsaved changes) +:q! - force quit without saving +:u - undo last change +. - save and quit (MOO convention) + +Any line not starting with : is appended to the buffer.""" + + async def handle_input(self, line: str) -> EditorResponse: + """Handle editor input and return response.""" + # Special case: dot on its own saves and quits + if line == ".": + if self.save_callback is None: + return EditorResponse( + output="Error: Cannot save (no save callback configured)", + done=False, + ) + save_msg = await self._save() + return EditorResponse(output=save_msg, done=True, saved=True) + + # Check if it's a command + if not line.startswith(":"): + # Append to buffer + self._save_undo_state() + self.buffer.append(line) + self._mark_dirty() + return EditorResponse(output="", done=False) + + # Parse command + cmd = line[1:].strip() + + # Empty command - view buffer + if not cmd: + output = self._view_buffer() + return EditorResponse(output=output, done=False) + + # Check for numeric line view + if cmd.isdigit(): + line_num = int(cmd) + output = self._view_line(line_num) + return EditorResponse(output=output, done=False) + + # Parse command parts + parts = cmd.split(None, 1) + command = parts[0] + args = parts[1] if len(parts) > 1 else "" + + # Help + if command == "h": + output = self._help() + return EditorResponse(output=output, done=False) + + # Insert + if command == "i": + if not args: + return EditorResponse( + output="Error: :i requires line number and text", done=False + ) + parts = args.split(None, 1) + if len(parts) < 2: + return EditorResponse( + output="Error: :i requires line number and text", done=False + ) + try: + line_num = int(parts[0]) + text = parts[1] + output = self._insert_line(line_num, text) + return EditorResponse(output=output, done=False) + except ValueError: + return EditorResponse(output="Error: Invalid line number", done=False) + + # Delete + if command == "d": + if not args: + return EditorResponse( + output="Error: :d requires line number(s)", done=False + ) + parts = args.split() + try: + start = int(parts[0]) + end = int(parts[1]) if len(parts) > 1 else None + output = self._delete_line(start, end) + return EditorResponse(output=output, done=False) + except (ValueError, IndexError): + return EditorResponse( + output="Error: Invalid line number(s)", done=False + ) + + # Replace + if command == "r": + if not args: + return EditorResponse( + output="Error: :r requires line number and text", done=False + ) + parts = args.split(None, 1) + if len(parts) < 2: + return EditorResponse( + output="Error: :r requires line number and text", done=False + ) + try: + line_num = int(parts[0]) + text = parts[1] + output = self._replace_line(line_num, text) + return EditorResponse(output=output, done=False) + except ValueError: + return EditorResponse(output="Error: Invalid line number", done=False) + + # Search and replace + if command == "s": + parts = args.split(None, 1) + if len(parts) < 2: + return EditorResponse( + output="Error: :s requires old and new text", done=False + ) + old_new = parts[1].split(None, 1) if len(parts) > 1 else [] + if len(old_new) < 2: + # Try parsing the whole args as "old new" + split_args = args.split(None, 1) + if len(split_args) < 2: + return EditorResponse( + output="Error: :s requires old and new text", done=False + ) + old = split_args[0] + new = split_args[1] + else: + old = parts[0] + new = old_new[1] if len(old_new) > 1 else old_new[0] + output = self._search_replace(old, new, replace_all=False) + return EditorResponse(output=output, done=False) + + # Search and replace all + if command == "sa": + parts = args.split(None, 1) + if len(parts) < 2: + return EditorResponse( + output="Error: :sa requires old and new text", done=False + ) + old = parts[0] + new = parts[1] + output = self._search_replace(old, new, replace_all=True) + return EditorResponse(output=output, done=False) + + # Undo + if command == "u": + output = self._undo() + return EditorResponse(output=output, done=False) + + # Save + if command == "w": + output = await self._save() + return EditorResponse(output=output, done=False, saved=True) + + # Save and quit + if command == "wq": + output = await self._save() + return EditorResponse(output=output, done=True, saved=True) + + # Quit + if command == "q": + if self.dirty: + return EditorResponse( + output=( + "Unsaved changes! Use :wq to save and quit, " + "or :q! to force quit" + ), + done=False, + ) + return EditorResponse(output="", done=True) + + # Force quit + if command == "q!": + return EditorResponse(output="", done=True) + + # Unknown command + return EditorResponse(output=f"Unknown command: :{command}", done=False) diff --git a/tests/test_editor.py b/tests/test_editor.py new file mode 100644 index 0000000..17b446e --- /dev/null +++ b/tests/test_editor.py @@ -0,0 +1,346 @@ +"""Tests for the text editor.""" + +import pytest + +from mudlib.editor import Editor + + +@pytest.mark.asyncio +async def test_create_editor_with_empty_buffer(): + """Editor starts with empty buffer.""" + editor = Editor() + assert editor.buffer == [] + assert editor.cursor == 0 + assert editor.dirty is False + + +@pytest.mark.asyncio +async def test_create_editor_with_initial_content(): + """Editor can be initialized with content.""" + editor = Editor(initial_content="line 1\nline 2\nline 3") + assert editor.buffer == ["line 1", "line 2", "line 3"] + assert editor.dirty is False + + +@pytest.mark.asyncio +async def test_append_line(): + """Typing text that isn't a command appends to buffer.""" + editor = Editor() + response = await editor.handle_input("hello world") + assert response.output == "" + assert response.done is False + assert editor.buffer == ["hello world"] + assert editor.dirty is True + + +@pytest.mark.asyncio +async def test_append_multiple_lines(): + """Multiple lines can be appended.""" + editor = Editor() + await editor.handle_input("line 1") + await editor.handle_input("line 2") + await editor.handle_input("line 3") + assert editor.buffer == ["line 1", "line 2", "line 3"] + assert editor.dirty is True + + +@pytest.mark.asyncio +async def test_view_buffer(): + """':' shows the entire buffer with line numbers.""" + editor = Editor(initial_content="line 1\nline 2\nline 3") + response = await editor.handle_input(":") + assert "1 line 1" in response.output + assert "2 line 2" in response.output + assert "3 line 3" in response.output + + +@pytest.mark.asyncio +async def test_view_empty_buffer(): + """':' on empty buffer shows appropriate message.""" + editor = Editor() + response = await editor.handle_input(":") + assert "empty" in response.output.lower() or response.output == "" + + +@pytest.mark.asyncio +async def test_view_specific_line(): + """':5' shows line 5 with context.""" + editor = Editor(initial_content="\n".join([f"line {i}" for i in range(1, 11)])) + response = await editor.handle_input(":5") + # Should show lines 2-8 (3 lines of context on each side) + assert "5 line 5" in response.output + assert "2 line 2" in response.output + assert "8 line 8" in response.output + + +@pytest.mark.asyncio +async def test_view_specific_line_at_start(): + """Viewing line near start shows available context.""" + editor = Editor(initial_content="line 1\nline 2\nline 3") + response = await editor.handle_input(":1") + assert "1 line 1" in response.output + + +@pytest.mark.asyncio +async def test_insert_at_line(): + """':i 5 text' inserts 'text' at line 5.""" + editor = Editor(initial_content="\n".join([f"line {i}" for i in range(1, 6)])) + await editor.handle_input(":i 3 inserted text") + assert editor.buffer[2] == "inserted text" + assert editor.buffer[3] == "line 3" + assert len(editor.buffer) == 6 + assert editor.dirty is True + + +@pytest.mark.asyncio +async def test_insert_at_end(): + """':i' can insert at end of buffer.""" + editor = Editor(initial_content="line 1\nline 2") + await editor.handle_input(":i 3 line 3") + assert editor.buffer[2] == "line 3" + + +@pytest.mark.asyncio +async def test_delete_single_line(): + """':d 5' deletes line 5.""" + editor = Editor(initial_content="\n".join([f"line {i}" for i in range(1, 6)])) + await editor.handle_input(":d 3") + assert editor.buffer == ["line 1", "line 2", "line 4", "line 5"] + assert editor.dirty is True + + +@pytest.mark.asyncio +async def test_delete_range(): + """':d 5 8' deletes lines 5 through 8.""" + editor = Editor(initial_content="\n".join([f"line {i}" for i in range(1, 11)])) + await editor.handle_input(":d 3 6") + expected = ["line 1", "line 2", "line 7", "line 8", "line 9", "line 10"] + assert editor.buffer == expected + assert editor.dirty is True + + +@pytest.mark.asyncio +async def test_delete_invalid_line(): + """Deleting invalid line shows error.""" + editor = Editor(initial_content="line 1\nline 2") + response = await editor.handle_input(":d 10") + assert "error" in response.output.lower() or "invalid" in response.output.lower() + assert editor.buffer == ["line 1", "line 2"] + + +@pytest.mark.asyncio +async def test_replace_line(): + """':r 5 text' replaces line 5 with 'text'.""" + editor = Editor(initial_content="\n".join([f"line {i}" for i in range(1, 6)])) + await editor.handle_input(":r 3 replaced text") + assert editor.buffer[2] == "replaced text" + assert len(editor.buffer) == 5 + assert editor.dirty is True + + +@pytest.mark.asyncio +async def test_search_and_replace_first(): + """':s old new' replaces first occurrence.""" + editor = Editor(initial_content="hello world\nhello again\nhello there") + await editor.handle_input(":s hello hi") + assert editor.buffer[0] == "hi world" + assert editor.buffer[1] == "hello again" + assert editor.dirty is True + + +@pytest.mark.asyncio +async def test_search_and_replace_all(): + """':sa old new' replaces all occurrences.""" + editor = Editor(initial_content="hello world\nhello again\nhello there") + await editor.handle_input(":sa hello hi") + assert editor.buffer[0] == "hi world" + assert editor.buffer[1] == "hi again" + assert editor.buffer[2] == "hi there" + assert editor.dirty is True + + +@pytest.mark.asyncio +async def test_search_replace_no_match(): + """Search/replace with no match shows message.""" + editor = Editor(initial_content="hello world") + response = await editor.handle_input(":s foo bar") + assert ( + "not found" in response.output.lower() or "no match" in response.output.lower() + ) + + +@pytest.mark.asyncio +async def test_undo(): + """':u' undoes last buffer change.""" + editor = Editor(initial_content="line 1\nline 2") + await editor.handle_input("line 3") + assert len(editor.buffer) == 3 + response = await editor.handle_input(":u") + assert editor.buffer == ["line 1", "line 2"] + assert "undo" in response.output.lower() + + +@pytest.mark.asyncio +async def test_undo_multiple_times(): + """Undo can be called multiple times.""" + editor = Editor(initial_content="line 1") + await editor.handle_input("line 2") + await editor.handle_input("line 3") + await editor.handle_input(":u") + await editor.handle_input(":u") + assert editor.buffer == ["line 1"] + + +@pytest.mark.asyncio +async def test_undo_when_empty(): + """Undo with no history shows message.""" + editor = Editor() + response = await editor.handle_input(":u") + assert ( + "nothing to undo" in response.output.lower() + or "no undo" in response.output.lower() + ) + + +@pytest.mark.asyncio +async def test_save(): + """':w' calls save callback.""" + saved_content = None + + async def save_callback(content: str): + nonlocal saved_content + saved_content = content + + editor = Editor(initial_content="line 1\nline 2", save_callback=save_callback) + await editor.handle_input("line 3") + response = await editor.handle_input(":w") + assert response.saved is True + assert response.done is False + assert saved_content == "line 1\nline 2\nline 3" + assert editor.dirty is False + + +@pytest.mark.asyncio +async def test_save_without_callback(): + """':w' without callback shows error.""" + editor = Editor(initial_content="line 1") + response = await editor.handle_input(":w") + assert ( + "cannot save" in response.output.lower() or "no save" in response.output.lower() + ) + + +@pytest.mark.asyncio +async def test_save_and_quit(): + """':wq' saves and quits.""" + saved_content = None + + async def save_callback(content: str): + nonlocal saved_content + saved_content = content + + editor = Editor(initial_content="line 1", save_callback=save_callback) + response = await editor.handle_input(":wq") + assert response.saved is True + assert response.done is True + assert saved_content == "line 1" + + +@pytest.mark.asyncio +async def test_quit_when_dirty_warns(): + """':q' when dirty warns and doesn't quit.""" + editor = Editor(initial_content="line 1") + await editor.handle_input("line 2") + response = await editor.handle_input(":q") + assert response.done is False + assert "unsaved" in response.output.lower() or "save" in response.output.lower() + + +@pytest.mark.asyncio +async def test_quit_when_clean(): + """':q' when clean quits successfully.""" + editor = Editor(initial_content="line 1") + response = await editor.handle_input(":q") + assert response.done is True + + +@pytest.mark.asyncio +async def test_force_quit(): + """':q!' quits without saving.""" + editor = Editor(initial_content="line 1") + await editor.handle_input("line 2") + response = await editor.handle_input(":q!") + assert response.done is True + assert response.saved is False + + +@pytest.mark.asyncio +async def test_dot_saves_and_quits(): + """'.' on its own line saves and quits (MOO convention).""" + saved_content = None + + async def save_callback(content: str): + nonlocal saved_content + saved_content = content + + editor = Editor(initial_content="line 1", save_callback=save_callback) + await editor.handle_input("line 2") + response = await editor.handle_input(".") + assert response.done is True + assert response.saved is True + assert saved_content == "line 1\nline 2" + + +@pytest.mark.asyncio +async def test_dirty_flag_tracking(): + """Dirty flag is properly tracked.""" + editor = Editor(initial_content="line 1") + assert editor.dirty is False + + await editor.handle_input("line 2") + assert editor.dirty is True + + # Mock save + saved_content = None + + async def save_callback(content: str): + nonlocal saved_content + saved_content = content + + editor.save_callback = save_callback + await editor.handle_input(":w") + assert editor.dirty is False + + +@pytest.mark.asyncio +async def test_help(): + """':h' shows help.""" + editor = Editor() + response = await editor.handle_input(":h") + assert ":w" in response.output + assert ":q" in response.output + assert ":i" in response.output + assert ":d" in response.output + + +@pytest.mark.asyncio +async def test_undo_stack_size_limit(): + """Undo stack is capped at 50 entries.""" + editor = Editor() + # Add 60 lines + for i in range(60): + await editor.handle_input(f"line {i}") + # Undo stack should be capped + assert len(editor.undo_stack) <= 50 + + +@pytest.mark.asyncio +async def test_line_numbers_padded(): + """Line numbers in view are left-padded.""" + editor = Editor(initial_content="\n".join([f"line {i}" for i in range(1, 12)])) + response = await editor.handle_input(":") + # Line 1 should have padding, line 11 should not have extra padding + lines = response.output.split("\n") + # Find lines with numbers + assert any(" 1 line 1" in line for line in lines) + assert any("11 line 11" in line for line in lines)