aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authornatsuoto <[email protected]>2026-07-15 13:55:58 +0530
committernatsuoto <[email protected]>2026-07-15 13:55:58 +0530
commit2f69c608e319edb6b5af0133b4fddb2654aec96c (patch)
treefcde675b6970bcd12c1656d5503e72942f139a91
parent34694646345ce2a43c12df6fcb214371f3ee06c0 (diff)
downloadedify-2f69c608e319edb6b5af0133b4fddb2654aec96c.tar.xz
edify-2f69c608e319edb6b5af0133b4fddb2654aec96c.zip
feat(engine): unlock variable-width lookbehind under engine=regex; annotate the re-engine failure with an actionable error
-rw-r--r--edify/compile/backend.py15
-rw-r--r--edify/errors/backend.py25
-rw-r--r--tests/builder/lookbehind.test.py59
3 files changed, 96 insertions, 3 deletions
diff --git a/edify/compile/backend.py b/edify/compile/backend.py
index 581c4ff..d2bae12 100644
--- a/edify/compile/backend.py
+++ b/edify/compile/backend.py
@@ -16,7 +16,7 @@ from typing import cast
from edify.builder.types.engine import Engine
from edify.builder.types.flags import Flags
-from edify.errors.backend import MissingRegexBackendError
+from edify.errors.backend import MissingRegexBackendError, VariableWidthLookbehindNotSupportedError
def load_regex_module() -> ModuleType:
@@ -37,13 +37,24 @@ def compile_pattern(pattern: str, engine: Engine, flags: Flags) -> re.Pattern[st
The return type is declared as :class:`re.Pattern` so downstream call sites
stay strongly typed. Under ``engine="regex"`` the value is a ``regex.Pattern``
that mirrors :class:`re.Pattern`'s method surface.
+
+ Raises:
+ VariableWidthLookbehindNotSupportedError: when ``engine='re'`` and the
+ pattern uses a variable-width lookbehind body that stdlib re rejects.
+ MissingRegexBackendError: when ``engine='regex'`` and the ``regex`` module
+ is not installed.
"""
if engine == "regex":
regex_module = load_regex_module()
return cast(
re.Pattern[str], regex_module.compile(pattern, _regex_flag_bitmask(regex_module, flags))
)
- return re.compile(pattern, flags=_re_flag_bitmask(flags))
+ try:
+ return re.compile(pattern, flags=_re_flag_bitmask(flags))
+ except re.error as reason:
+ if "look-behind" in str(reason):
+ raise VariableWidthLookbehindNotSupportedError() from reason
+ raise
def _re_flag_bitmask(flags: Flags) -> int:
diff --git a/edify/errors/backend.py b/edify/errors/backend.py
index 376354e..cd7650e 100644
--- a/edify/errors/backend.py
+++ b/edify/errors/backend.py
@@ -1,4 +1,4 @@
-"""Exception raised when a selected engine's backend module is not installed."""
+"""Exceptions raised at engine-dispatch time."""
from __future__ import annotations
@@ -19,3 +19,26 @@ class MissingRegexBackendError(EdifySyntaxError):
help_line="help: install the extra with `pip install edify[regex]` and retry.",
)
super().__init__(message)
+
+
+class VariableWidthLookbehindNotSupportedError(EdifySyntaxError):
+ """Raised when ``engine='re'`` compiles a lookbehind whose body is variable-width."""
+
+ def __init__(self) -> None:
+ message = compose_annotated_message(
+ summary=(
+ "assert_behind / assert_not_behind has a variable-width body, "
+ "which the stdlib 're' engine does not accept"
+ ),
+ trigger_hint=".to_regex(engine='re') called here",
+ note=(
+ "stdlib re requires every lookbehind branch to be fixed-width, so a "
+ "quantifier like +/*/?/{m,n} or a same-frame alternation with differing "
+ "branch widths inside a lookbehind will fail to compile."
+ ),
+ help_line=(
+ "help: switch to the third-party engine with .to_regex(engine='regex'), "
+ "which supports variable-width lookbehind."
+ ),
+ )
+ super().__init__(message)
diff --git a/tests/builder/lookbehind.test.py b/tests/builder/lookbehind.test.py
new file mode 100644
index 0000000..f16a6cc
--- /dev/null
+++ b/tests/builder/lookbehind.test.py
@@ -0,0 +1,59 @@
+"""Tests for lookbehind width behavior across the ``re`` and ``regex`` engines."""
+
+import pytest
+
+from edify import RegexBuilder
+from edify.errors.backend import VariableWidthLookbehindNotSupportedError
+
+
+def _variable_width_lookbehind_builder():
+ return RegexBuilder().assert_behind().between(1, 3).string("foo").end().string("bar")
+
+
+def _fixed_width_lookbehind_builder():
+ return RegexBuilder().assert_behind().string("foo").end().string("bar")
+
+
+def test_variable_width_lookbehind_under_re_raises_annotated_error():
+ with pytest.raises(VariableWidthLookbehindNotSupportedError) as excinfo:
+ _variable_width_lookbehind_builder().to_regex(engine="re")
+ text = str(excinfo.value)
+ assert "assert_behind" in text
+ assert "engine='regex'" in text
+ assert "= note:" in text
+ assert "help:" in text
+
+
+def test_variable_width_lookbehind_under_regex_compiles_and_matches():
+ compiled = _variable_width_lookbehind_builder().to_regex(engine="regex")
+ assert compiled.engine == "regex"
+ assert compiled.search("foobar") is not None
+ assert compiled.search("foofoobar") is not None
+ assert compiled.search("foofoofoobar") is not None
+ assert compiled.search("bar") is None
+
+
+def test_fixed_width_lookbehind_still_works_under_re():
+ compiled = _fixed_width_lookbehind_builder().to_regex(engine="re")
+ assert compiled.search("foobar") is not None
+ assert compiled.search("bar") is None
+
+
+def test_variable_width_lookbehind_error_chains_the_underlying_pattern_error():
+ with pytest.raises(VariableWidthLookbehindNotSupportedError) as excinfo:
+ _variable_width_lookbehind_builder().to_regex(engine="re")
+ import re
+
+ assert isinstance(excinfo.value.__cause__, re.error)
+
+
+def test_re_engine_still_surfaces_other_pattern_errors_unchanged(monkeypatch):
+ import re
+
+ def raise_other_error(_pattern, flags=0):
+ raise re.error("some other syntax error")
+
+ monkeypatch.setattr(re, "compile", raise_other_error)
+ with pytest.raises(re.error) as excinfo:
+ RegexBuilder().digit().to_regex(engine="re")
+ assert "some other syntax error" in str(excinfo.value)