aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authornatsuoto <[email protected]>2026-07-15 14:58:16 +0530
committernatsuoto <[email protected]>2026-07-15 14:58:16 +0530
commitcf84fbf5af3b3f321582d74ed34d53b69d8c0002 (patch)
tree5f10e1334bdf8bb9519f514c202814ccb8cdd3ef
parent5a771b525a59d94c1d339d302eae118e0e46ab1e (diff)
downloadedify-cf84fbf5af3b3f321582d74ed34d53b69d8c0002.tar.xz
edify-cf84fbf5af3b3f321582d74ed34d53b69d8c0002.zip
feat!(compile): normalize char-class escaping to minimal correct form; add equivalence corpus
-rw-r--r--edify/builder/mixins/chars.py6
-rw-r--r--edify/compile/__init__.py4
-rw-r--r--edify/compile/escape.py53
-rw-r--r--tests/builder/builder.test.py8
-rw-r--r--tests/compile/escape.test.py115
5 files changed, 176 insertions, 10 deletions
diff --git a/edify/builder/mixins/chars.py b/edify/builder/mixins/chars.py
index 56c1592..a491b3b 100644
--- a/edify/builder/mixins/chars.py
+++ b/edify/builder/mixins/chars.py
@@ -10,7 +10,7 @@ from __future__ import annotations
from typing import Self
from edify.builder.types.protocol import BuilderProtocol
-from edify.compile.escape import escape_special
+from edify.compile.escape import escape_for_char_class, escape_special
from edify.elements.types.chars import (
AnyOfCharsElement,
AnythingButCharsElement,
@@ -60,7 +60,7 @@ class CharsMixin(BuilderProtocol):
def any_of_chars(self, characters: str) -> Self:
"""Return a new builder with an inline ``[characters]`` class appended."""
- escaped_characters = escape_special(characters)
+ escaped_characters = escape_for_char_class(characters)
element = AnyOfCharsElement(value=escaped_characters)
new_state = self._state.with_element_added_to_top(element)
return self._with_state(new_state)
@@ -78,7 +78,7 @@ class CharsMixin(BuilderProtocol):
"""Return a new builder with an inline ``[^characters]`` negation appended."""
_ensure_is_string("Value", characters)
_ensure_non_empty("Value", characters)
- escaped_characters = escape_special(characters)
+ escaped_characters = escape_for_char_class(characters)
element = AnythingButCharsElement(value=escaped_characters)
new_state = self._state.with_element_added_to_top(element)
return self._with_state(new_state)
diff --git a/edify/compile/__init__.py b/edify/compile/__init__.py
index b92f5b0..eeac406 100644
--- a/edify/compile/__init__.py
+++ b/edify/compile/__init__.py
@@ -1,4 +1,4 @@
from edify.compile.dispatch import render_element
-from edify.compile.escape import escape_special
+from edify.compile.escape import escape_for_char_class, escape_special
-__all__ = ["escape_special", "render_element"]
+__all__ = ["escape_for_char_class", "escape_special", "render_element"]
diff --git a/edify/compile/escape.py b/edify/compile/escape.py
index b78454c..d69bc59 100644
--- a/edify/compile/escape.py
+++ b/edify/compile/escape.py
@@ -1,9 +1,23 @@
-"""Escape user-provided string fragments for safe insertion into a regex pattern."""
+"""Escape user-provided string fragments for safe insertion into a regex pattern.
+
+Two different escape scopes:
+
+* :func:`escape_special` — for literals that will land *outside* a character
+ class (``.string()``, ``.char()``, ``.anything_but_string()``). Every regex
+ metacharacter must be escaped because the fragment is embedded directly
+ into the concatenation stream.
+* :func:`escape_for_char_class` — for literals that will land *inside*
+ ``[...]``. Only ``\\``, ``]``, ``^`` (first position), and ``-``
+ (interior position) need escaping; everything else is a literal inside a
+ class. This is the minimal correct form.
+"""
from __future__ import annotations
import re
+_CHAR_CLASS_ESCAPE_ALWAYS = {"\\", "]"}
+
def escape_special(value: str) -> str:
"""Return ``value`` with all regex metacharacters backslash-escaped.
@@ -16,3 +30,40 @@ def escape_special(value: str) -> str:
directly into a compiled pattern.
"""
return re.escape(value)
+
+
+def escape_for_char_class(characters: str) -> str:
+ """Return ``characters`` escaped for insertion inside ``[...]``.
+
+ Escapes exactly the characters that would otherwise carry syntactic
+ meaning inside a character class:
+
+ * ``\\`` and ``]`` — always.
+ * ``^`` — only at position 0 (else the whole class would negate).
+ * ``-`` — only in interior position (position 0 and the final position
+ are unambiguously literal).
+
+ Every other character passes through untouched.
+
+ Args:
+ characters: The raw class body supplied by the user.
+
+ Returns:
+ The escaped fragment, safe to place between ``[`` and ``]``.
+ """
+ if characters == "":
+ return characters
+ last_index = len(characters) - 1
+ escaped_pieces: list[str] = []
+ for position, character in enumerate(characters):
+ if character in _CHAR_CLASS_ESCAPE_ALWAYS:
+ escaped_pieces.append("\\" + character)
+ continue
+ if character == "^" and position == 0:
+ escaped_pieces.append("\\^")
+ continue
+ if character == "-" and 0 < position < last_index:
+ escaped_pieces.append("\\-")
+ continue
+ escaped_pieces.append(character)
+ return "".join(escaped_pieces)
diff --git a/tests/builder/builder.test.py b/tests/builder/builder.test.py
index 82aa1cf..5f96b5c 100644
--- a/tests/builder/builder.test.py
+++ b/tests/builder/builder.test.py
@@ -404,14 +404,14 @@ def test_end_of_input():
def test_any_of_chars():
expr = RegexBuilder().any_of_chars("aeiou.-")
- regex_equality("[aeiou\\.\\-]", expr)
- regex_compilation("[aeiou\\.\\-]", expr)
+ regex_equality("[aeiou.-]", expr)
+ regex_compilation("[aeiou.-]", expr)
def test_anything_but_chars():
expr = RegexBuilder().anything_but_chars("aeiou.-")
- regex_equality("[^aeiou\\.\\-]", expr)
- regex_compilation("[^aeiou\\.\\-]", expr)
+ regex_equality("[^aeiou.-]", expr)
+ regex_compilation("[^aeiou.-]", expr)
def test_anything_but_string():
diff --git a/tests/compile/escape.test.py b/tests/compile/escape.test.py
new file mode 100644
index 0000000..e9ab657
--- /dev/null
+++ b/tests/compile/escape.test.py
@@ -0,0 +1,115 @@
+"""Tests for the character-class escape normalization.
+
+The minimal-correct form escapes only ``\\``, ``]``, first-position ``^``,
+and interior ``-`` — everything else passes through as a literal. Match
+behavior against a representative corpus must stay identical to the
+previous over-escaped form.
+"""
+
+import re
+
+import pytest
+
+from edify import RegexBuilder
+
+_METACHARS_UNRELATED_TO_CLASS = "#?!@$%^&*"
+_MIXED_CORPUS = [
+ "",
+ "a",
+ "z",
+ "#",
+ "?",
+ "!",
+ "@",
+ "$",
+ "%",
+ "^",
+ "&",
+ "*",
+ "-",
+ ".",
+ "abc",
+ "a#b",
+ "1-2",
+ " ",
+ "foo.bar",
+ "hello world",
+ "a^b",
+ "]",
+ "[a]",
+ "\\",
+]
+
+
+def test_any_of_chars_emits_minimal_form_for_class_safe_metachars():
+ emitted = RegexBuilder().any_of_chars(_METACHARS_UNRELATED_TO_CLASS).to_regex_string()
+ assert emitted == "[#?!@$%^&*]"
+
+
+def test_any_of_chars_escapes_backslash_and_closing_bracket_only():
+ emitted = RegexBuilder().any_of_chars("a\\b]c").to_regex_string()
+ assert emitted == "[a\\\\b\\]c]"
+
+
+def test_any_of_chars_escapes_first_position_caret_only():
+ first_caret = RegexBuilder().any_of_chars("^abc").to_regex_string()
+ later_caret = RegexBuilder().any_of_chars("a^bc").to_regex_string()
+ assert first_caret == "[\\^abc]"
+ assert later_caret == "[a^bc]"
+
+
+def test_any_of_chars_escapes_interior_dash_only():
+ edge_dashes = RegexBuilder().any_of_chars("-a-").to_regex_string()
+ interior_dash = RegexBuilder().any_of_chars("a-b").to_regex_string()
+ assert edge_dashes == "[-a-]"
+ assert interior_dash == "[a\\-b]"
+
+
+def test_any_of_chars_leaves_dot_and_asterisk_unescaped_inside_a_class():
+ emitted = RegexBuilder().any_of_chars(".*").to_regex_string()
+ assert emitted == "[.*]"
+
+
+def test_anything_but_chars_leading_position_of_body_is_not_caret():
+ emitted = RegexBuilder().anything_but_chars("^abc").to_regex_string()
+ assert emitted == "[^\\^abc]"
+
+
+def test_anything_but_chars_normalizes_the_same_way_as_any_of_chars():
+ emitted = RegexBuilder().anything_but_chars("#?!@$%^&*").to_regex_string()
+ assert emitted == "[^#?!@$%^&*]"
+
+
+def test_string_terminal_still_uses_full_escape_outside_the_class():
+ emitted = RegexBuilder().string("a.b").to_regex_string()
+ assert emitted == "a\\.b"
+
+
+def test_char_terminal_still_uses_full_escape_outside_the_class():
+ emitted = RegexBuilder().char(".").to_regex_string()
+ assert emitted == "\\."
+
+
[email protected]("candidate", _MIXED_CORPUS)
+def test_normalized_and_over_escaped_char_classes_match_the_same_inputs(candidate):
+ normalized_pattern = (
+ RegexBuilder().any_of_chars(_METACHARS_UNRELATED_TO_CLASS).to_regex_string()
+ )
+ over_escaped_pattern = f"[{re.escape(_METACHARS_UNRELATED_TO_CLASS)}]"
+ normalized_hits = re.findall(normalized_pattern, candidate)
+ over_escaped_hits = re.findall(over_escaped_pattern, candidate)
+ assert normalized_hits == over_escaped_hits
+
+
[email protected]("candidate", _MIXED_CORPUS)
+def test_normalized_and_over_escaped_negated_classes_match_the_same_inputs(candidate):
+ normalized_pattern = RegexBuilder().anything_but_chars("aeiou.-").to_regex_string()
+ over_escaped_pattern = "[^aeiou\\.\\-]"
+ normalized_hits = re.findall(normalized_pattern, candidate)
+ over_escaped_hits = re.findall(over_escaped_pattern, candidate)
+ assert normalized_hits == over_escaped_hits
+
+
+def test_any_of_chars_empty_class_still_renders_empty():
+ emitted = RegexBuilder().any_of_chars("").to_regex_string()
+ assert emitted == "[]"