From 551cd53a826782e35174f1cb61cf1708482944fb Mon Sep 17 00:00:00 2001 From: fan wu <336798967+FanWu-ai@users.noreply.github.com> Date: Sun, 11 Oct 2026 10:19:28 -0700 Subject: [PATCH] Unescape regular-language matches only once AI assistance: OpenAI Codex was used for investigation, implementation, regression tests, and automated validation. --- .../contrib/regular_languages/completion.py | 9 +-- .../contrib/regular_languages/validation.py | 6 +- tests/test_regular_languages.py | 62 ++++++++++++++++++- 3 files changed, 66 insertions(+), 11 deletions(-) diff --git a/src/prompt_toolkit/contrib/regular_languages/completion.py b/src/prompt_toolkit/contrib/regular_languages/completion.py index 2181a42cc..bccaba67c 100644 --- a/src/prompt_toolkit/contrib/regular_languages/completion.py +++ b/src/prompt_toolkit/contrib/regular_languages/completion.py @@ -57,19 +57,16 @@ def _get_completions_for_match( completer = self.completers.get(varname) if completer: + # Match.end_nodes() has already unescaped this value. text = match_variable.value - # Unwrap text. - unwrapped_text = self.compiled_grammar.unescape(varname, text) - # Create a document, for the completions API (text/cursor_position) - document = Document(unwrapped_text, len(unwrapped_text)) + document = Document(text, len(text)) # Call completer for completion in completer.get_completions(document, complete_event): new_text = ( - unwrapped_text[: len(text) + completion.start_position] - + completion.text + text[: len(text) + completion.start_position] + completion.text ) # Wrap again. diff --git a/src/prompt_toolkit/contrib/regular_languages/validation.py b/src/prompt_toolkit/contrib/regular_languages/validation.py index e6cfd7413..7a895a689 100644 --- a/src/prompt_toolkit/contrib/regular_languages/validation.py +++ b/src/prompt_toolkit/contrib/regular_languages/validation.py @@ -41,11 +41,9 @@ def validate(self, document: Document) -> None: validator = self.validators.get(v.varname) if validator: - # Unescape text. - unwrapped_text = self.compiled_grammar.unescape(v.varname, v.value) - + # Match.variables() has already unescaped this value. # Create a document, for the completions API (text/cursor_position) - inner_document = Document(unwrapped_text, len(unwrapped_text)) + inner_document = Document(v.value, len(v.value)) try: validator.validate(inner_document) diff --git a/tests/test_regular_languages.py b/tests/test_regular_languages.py index 3d55dbc51..50ad89e47 100644 --- a/tests/test_regular_languages.py +++ b/tests/test_regular_languages.py @@ -1,10 +1,21 @@ from __future__ import annotations -from prompt_toolkit.completion import CompleteEvent, Completer, Completion +import re + +import pytest + +from prompt_toolkit.completion import ( + CompleteEvent, + Completer, + Completion, + WordCompleter, +) 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.contrib.regular_languages.validation import GrammarValidator from prompt_toolkit.document import Document +from prompt_toolkit.validation import Validator def test_simple_match(): @@ -100,3 +111,52 @@ def get_completions(self, document, complete_event): assert completions[0].start_position == -3 assert completions[1].text == "before2-def-after2-B" assert completions[1].start_position == -3 + + +def _escape_argument(text): + return re.sub(r"([\\ ])", r"\\\1", text) + + +def _unescape_argument(text): + return re.sub(r"\\(.)", r"\1", text) + + +@pytest.mark.parametrize("text", [r"foo\b", r"foo\ b", r"foo\\b", "foo b", "foo"]) +@pytest.mark.parametrize("suffix", ["", " trailing"]) +def test_completer_unescapes_once(text, suffix): + grammar = compile( + r"cmd\s+(?P.*)", + escape_funcs={"arg": _escape_argument}, + unescape_funcs={"arg": _unescape_argument}, + ) + target = text + "ar" + completer = GrammarCompleter( + grammar, {"arg": WordCompleter([target], sentence=True)} + ) + escaped = _escape_argument(text) + before_cursor = "cmd " + escaped + document = Document(before_cursor + suffix, len(before_cursor)) + + completions = list(completer.get_completions(document, CompleteEvent())) + + assert len(completions) == 1 + assert completions[0].text == _escape_argument(target) + assert completions[0].start_position == -len(escaped) + + +@pytest.mark.parametrize("text", [r"foo\b", r"foo\ b", r"foo\\b", "foo b", "foo"]) +def test_validator_unescapes_once(text): + received = [] + + class RecordingValidator(Validator): + def validate(self, document): + received.append((document.text, document.cursor_position)) + + grammar = compile( + r"cmd\s+(?P.*)", + unescape_funcs={"arg": _unescape_argument}, + ) + validator = GrammarValidator(grammar, {"arg": RecordingValidator()}) + validator.validate(Document("cmd " + _escape_argument(text))) + + assert received == [(text, len(text))]