Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8c08a353b6 | |||
| 873a2ec6ad | |||
| 285b27ec20 | |||
| 405e483354 | |||
| 83c7c91da2 | |||
| fedd9e4902 | |||
| b1149c5f1c | |||
| 66d22a746d | |||
| 9341f9dea7 | |||
| 19d622cade | |||
| 8146ee4cfa | |||
| 53fde1a30e | |||
| 74859eb88c | |||
| 80c7c32dc9 | |||
| 352813814d | |||
| e86a5dede4 | |||
| a5cfcf79c2 | |||
| 681f2b7e4e | |||
| 5ef0b3cb72 | |||
| 72a65bcda8 | |||
| 9d42a01264 |
@@ -26,7 +26,7 @@ jobs:
|
||||
run: pytest tests/ -v
|
||||
|
||||
- name: Run linter
|
||||
run: ruff check .
|
||||
run: ruff check regex_humanizer/ tests/
|
||||
|
||||
- name: Run type check
|
||||
run: mypy regex_humanizer/
|
||||
|
||||
49
.github/workflows/ci.yml
vendored
Normal file
49
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,49 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ['3.9', '3.10', '3.11', '3.12']
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -e ".[dev]"
|
||||
|
||||
- name: Run tests
|
||||
run: pytest tests/ -v --cov=regex_humanizer
|
||||
|
||||
- name: Run linting
|
||||
run: ruff check regex_humanizer/ tests/
|
||||
|
||||
i18n-check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install i18n-key-sync
|
||||
run: pip install -e .
|
||||
|
||||
- name: Validate i18n keys
|
||||
run: i18n-key-sync validate ./i18n_key_sync --strict
|
||||
@@ -30,8 +30,8 @@ dependencies = [
|
||||
dev = [
|
||||
"pytest>=7.0",
|
||||
"pytest-cov",
|
||||
"flake8",
|
||||
"mypy",
|
||||
"ruff>=0.1.0",
|
||||
"mypy>=1.0.0",
|
||||
"black",
|
||||
"isort",
|
||||
]
|
||||
@@ -57,7 +57,7 @@ profile = "black"
|
||||
line_length = 100
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.8"
|
||||
python_version = "3.9"
|
||||
warn_return_any = true
|
||||
warn_unused_configs = true
|
||||
ignore_missing_imports = true
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
"""Main CLI module for Regex Humanizer."""
|
||||
|
||||
import json as json_module
|
||||
from typing import List
|
||||
|
||||
import click
|
||||
|
||||
from .parser import parse_regex, ParseError
|
||||
from .parser import ASTNode, ParseError
|
||||
from .converter import convert_to_english, convert_to_english_verbose
|
||||
from .examples import generate_examples
|
||||
from .flavors import (
|
||||
@@ -72,7 +73,7 @@ def explain(pattern: str, flavor: str, verbose: bool, output_format: str):
|
||||
click.echo(f"Flavor: {result['flavor']}")
|
||||
click.echo(f"\nDescription:\n{result['description']}")
|
||||
if result.get('structure'):
|
||||
click.echo(f"\nStructure:")
|
||||
click.echo("\nStructure:")
|
||||
for item in result['structure']:
|
||||
click.echo(f" - {item}")
|
||||
else:
|
||||
@@ -87,7 +88,7 @@ def explain(pattern: str, flavor: str, verbose: bool, output_format: str):
|
||||
|
||||
warnings = get_compatibility_warnings(pattern, flavor)
|
||||
if warnings:
|
||||
click.echo(f"\nCompatibility warnings:")
|
||||
click.echo("\nCompatibility warnings:")
|
||||
for w in warnings:
|
||||
click.echo(f" [{w.severity.upper()}] {w.feature}: {w.message}")
|
||||
|
||||
@@ -150,7 +151,7 @@ def generate(pattern: str, flavor: str, count: int, output_format: str):
|
||||
else:
|
||||
click.echo(f"\nPattern: {pattern}")
|
||||
click.echo(f"Flavor: {flavor}")
|
||||
click.echo(f"\nMatching examples:")
|
||||
click.echo("\nMatching examples:")
|
||||
for i, example in enumerate(examples, 1):
|
||||
click.echo(f" {i}. {example}")
|
||||
|
||||
@@ -198,7 +199,7 @@ def from_english(description: str, flavor: str, output_format: str):
|
||||
click.echo(f"Flavor: {result['flavor']}")
|
||||
|
||||
if result.get('warnings'):
|
||||
click.echo(f"\nWarnings:")
|
||||
click.echo("\nWarnings:")
|
||||
for w in result['warnings']:
|
||||
click.echo(f" - {w}")
|
||||
|
||||
@@ -226,7 +227,7 @@ def build(flavor: str):
|
||||
click.echo(f"Flavor: {flavor}")
|
||||
click.echo("Enter 'quit' to exit, 'back' to go back, 'done' when finished.\n")
|
||||
|
||||
pattern_parts = []
|
||||
pattern_parts: List[ASTNode] = []
|
||||
|
||||
while True:
|
||||
current_pattern = "".join(p.to_regex() if hasattr(p, 'to_regex') else str(p) for p in pattern_parts)
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
from typing import List, Optional
|
||||
"""Implementation of regex to English conversion."""
|
||||
|
||||
from typing import Any, List
|
||||
|
||||
from ..parser import (
|
||||
Alternation,
|
||||
@@ -15,6 +17,7 @@ from ..parser import (
|
||||
|
||||
|
||||
def quantifier_description(quantifier: Quantifier, child_desc: str) -> str:
|
||||
"""Generate description for a quantifier."""
|
||||
if quantifier.min == 0 and quantifier.max == 1:
|
||||
base = "optionally"
|
||||
elif quantifier.min == 0 and quantifier.max == Quantifier.MAX_UNBOUNDED:
|
||||
@@ -37,6 +40,7 @@ def quantifier_description(quantifier: Quantifier, child_desc: str) -> str:
|
||||
|
||||
|
||||
def literal_description(node: Literal) -> str:
|
||||
"""Generate description for a literal character."""
|
||||
if node.value == " ":
|
||||
return "a space"
|
||||
elif node.value == "\t":
|
||||
@@ -52,6 +56,7 @@ def literal_description(node: Literal) -> str:
|
||||
|
||||
|
||||
def character_class_description(node: CharacterClass) -> str:
|
||||
"""Generate description for a character class."""
|
||||
parts = []
|
||||
|
||||
if node.inverted:
|
||||
@@ -90,6 +95,7 @@ def character_class_description(node: CharacterClass) -> str:
|
||||
|
||||
|
||||
def special_sequence_description(node: SpecialSequence) -> str:
|
||||
"""Generate description for a special sequence."""
|
||||
sequences = {
|
||||
".": "any single character",
|
||||
r"\d": "a digit (0-9)",
|
||||
@@ -111,6 +117,7 @@ def special_sequence_description(node: SpecialSequence) -> str:
|
||||
|
||||
|
||||
def anchor_description(node: Anchor) -> str:
|
||||
"""Generate description for an anchor."""
|
||||
anchors = {
|
||||
"^": "the start of the string",
|
||||
"$": "the end of the string",
|
||||
@@ -121,6 +128,7 @@ def anchor_description(node: Anchor) -> str:
|
||||
|
||||
|
||||
def group_description(node: Group) -> str:
|
||||
"""Generate description for a group."""
|
||||
if node.name:
|
||||
name_desc = f"named '{node.name}'"
|
||||
elif not node.capturing:
|
||||
@@ -136,6 +144,7 @@ def group_description(node: Group) -> str:
|
||||
|
||||
|
||||
def alternation_description(node: Alternation) -> str:
|
||||
"""Generate description for an alternation."""
|
||||
option_descs = []
|
||||
for option in node.options:
|
||||
if option:
|
||||
@@ -150,6 +159,7 @@ def alternation_description(node: Alternation) -> str:
|
||||
|
||||
|
||||
def backreference_description(node: Backreference) -> str:
|
||||
"""Generate description for a backreference."""
|
||||
if isinstance(node.reference, int):
|
||||
return f"whatever was matched by capture group {node.reference}"
|
||||
else:
|
||||
@@ -157,6 +167,7 @@ def backreference_description(node: Backreference) -> str:
|
||||
|
||||
|
||||
def generate_description(nodes: List[ASTNode]) -> str:
|
||||
"""Generate a human-readable description for a list of AST nodes."""
|
||||
if not nodes:
|
||||
return "an empty pattern"
|
||||
|
||||
@@ -201,6 +212,15 @@ def generate_description(nodes: List[ASTNode]) -> str:
|
||||
|
||||
|
||||
def convert_to_english(pattern: str, flavor: str = "pcre") -> str:
|
||||
"""Convert a regex pattern to human-readable English.
|
||||
|
||||
Args:
|
||||
pattern: The regex pattern to convert.
|
||||
flavor: The regex flavor (pcre, javascript, python, go).
|
||||
|
||||
Returns:
|
||||
A human-readable English description of the pattern.
|
||||
"""
|
||||
try:
|
||||
ast = parse_regex(pattern)
|
||||
return generate_description(ast)
|
||||
@@ -209,10 +229,19 @@ def convert_to_english(pattern: str, flavor: str = "pcre") -> str:
|
||||
|
||||
|
||||
def convert_to_english_verbose(pattern: str, flavor: str = "pcre") -> dict:
|
||||
"""Convert a regex pattern to detailed structure.
|
||||
|
||||
Args:
|
||||
pattern: The regex pattern to convert.
|
||||
flavor: The regex flavor.
|
||||
|
||||
Returns:
|
||||
A dictionary with pattern analysis.
|
||||
"""
|
||||
try:
|
||||
ast = parse_regex(pattern)
|
||||
|
||||
result = {
|
||||
result: dict[str, Any] = {
|
||||
"pattern": pattern,
|
||||
"flavor": flavor,
|
||||
"description": generate_description(ast),
|
||||
@@ -234,8 +263,9 @@ def convert_to_english_verbose(pattern: str, flavor: str = "pcre") -> dict:
|
||||
}
|
||||
|
||||
|
||||
def node_to_dict(node: ASTNode) -> dict:
|
||||
result = {"type": type(node).__name__}
|
||||
def node_to_dict(node: ASTNode) -> dict[str, Any]:
|
||||
"""Convert an AST node to a dictionary."""
|
||||
result: dict[str, Any] = {"type": type(node).__name__}
|
||||
|
||||
if hasattr(node, 'position'):
|
||||
result["position"] = node.position
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Bidirectional conversion from English descriptions to regex patterns."""
|
||||
|
||||
import re
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from ..parser import parse_regex
|
||||
|
||||
@@ -100,18 +102,117 @@ PATTERN_TEMPLATES = {
|
||||
],
|
||||
"builder": lambda m: r"\B",
|
||||
},
|
||||
"character_class_any": {
|
||||
"patterns": [
|
||||
r"any\s+(?:of\s+)?(character|in)\s+([a-zA-Z])[-–—]([a-zA-Z])",
|
||||
r"(?:characters?|in)\s+range\s+([a-zA-Z])[-–—]([a-zA-Z])",
|
||||
],
|
||||
"builder": lambda m: f"[{m.group(1)}-{m.group(2)}]",
|
||||
},
|
||||
"character_class_specific": {
|
||||
"patterns": [
|
||||
r"any\s+(?:of\s+)?['\"]?([a-zA-Z0-9])['\"]?",
|
||||
],
|
||||
"builder": lambda m: f"[{m.group(1)}]",
|
||||
},
|
||||
"optional": {
|
||||
"patterns": [
|
||||
r"(?:optionally|optional|zero\s+or\s+one)\s+(.*)",
|
||||
],
|
||||
"builder": lambda m: f"(?:{m.group(1)})?",
|
||||
},
|
||||
"zero_or_more": {
|
||||
"patterns": [
|
||||
r"(?:zero\s+or\s+more|star|asterisk)\s+(.*)",
|
||||
],
|
||||
"builder": lambda m: f"(?:{m.group(1)})*",
|
||||
},
|
||||
"one_or_more": {
|
||||
"patterns": [
|
||||
r"(?:one\s+or\s+more|plus)\s+(.*)",
|
||||
],
|
||||
"builder": lambda m: f"(?:{m.group(1)})+",
|
||||
},
|
||||
"exactly": {
|
||||
"patterns": [
|
||||
r"exactly\s+(\d+)\s+(?:times?)?\s+(.*)",
|
||||
],
|
||||
"builder": lambda m: f"(?:{m.group(2)}){{{m.group(1)}}}",
|
||||
},
|
||||
"between": {
|
||||
"patterns": [
|
||||
r"between\s+(\d+)\s+and\s+(\d+)\s+(?:times?)?\s+(.*)",
|
||||
],
|
||||
"builder": lambda m: f"(?:{m.group(3)}){{{m.group(1)},{m.group(2)}}}",
|
||||
},
|
||||
"at_least": {
|
||||
"patterns": [
|
||||
r"at\s+least\s+(\d+)\s+(?:times?)?\s+(.*)",
|
||||
],
|
||||
"builder": lambda m: f"(?:{m.group(2)}){{{m.group(1)},}}",
|
||||
},
|
||||
"group": {
|
||||
"patterns": [
|
||||
r"(?:a\s+)?(?:capturing\s+)?group\s+(?:containing|with)\s+(.*)",
|
||||
],
|
||||
"builder": lambda m: f"({m.group(1)})",
|
||||
},
|
||||
"non_capturing_group": {
|
||||
"patterns": [
|
||||
r"(?:a\s+)?non-?capturing\s+group\s+(?:containing|with)\s+(.*)",
|
||||
],
|
||||
"builder": lambda m: f"(?:{m.group(1)})",
|
||||
},
|
||||
"named_group": {
|
||||
"patterns": [
|
||||
r"(?:a\s+)?(?:named\s+)?group\s+(?:named|called)\s+'([^']+)'\s+(?:containing|with)\s+(.*)",
|
||||
],
|
||||
"builder": lambda m: f"(?P<{m.group(1)}>{m.group(2)})",
|
||||
},
|
||||
"or": {
|
||||
"patterns": [
|
||||
r"(.*?)\s+or\s+(.*)",
|
||||
],
|
||||
"builder": lambda m: f"{m.group(1)}|{m.group(2)}",
|
||||
},
|
||||
"alternation": {
|
||||
"patterns": [
|
||||
r"(?:either\s+)?(.+?)\s+(?:or|\/\/)\s+(.+)",
|
||||
],
|
||||
"builder": lambda m: f"{m.group(1)}|{m.group(2)}",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def parse_english(description: str) -> str:
|
||||
"""Convert an English description to a regex pattern.
|
||||
|
||||
Args:
|
||||
description: The English description of the pattern.
|
||||
|
||||
Returns:
|
||||
The corresponding regex pattern.
|
||||
"""
|
||||
result = description
|
||||
|
||||
result = re.sub(r"\s+", " ", result).strip()
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def english_to_regex(description: str, flavor: str = "pcre") -> Tuple[str, List[str]]:
|
||||
"""Convert an English description to a regex pattern.
|
||||
|
||||
Args:
|
||||
description: The English description of the pattern.
|
||||
flavor: The target regex flavor.
|
||||
|
||||
Returns:
|
||||
A tuple of (regex_pattern, warnings).
|
||||
"""
|
||||
pattern = description.lower()
|
||||
warnings = []
|
||||
|
||||
warnings: List[str] = []
|
||||
|
||||
replacements = []
|
||||
|
||||
@@ -162,12 +263,21 @@ def english_to_regex(description: str, flavor: str = "pcre") -> Tuple[str, List[
|
||||
|
||||
result = re.sub(r"\s+", "", result)
|
||||
|
||||
result = re.sub(r"\[\^?([a-z])-([a-z])\]", lambda m: f"[{m.group(1)}-{m.group(2)}]", result, flags=re.IGNORECASE)
|
||||
result = re.sub(r"\[^?([a-z])-([a-z])\]", lambda m: f"[{m.group(1)}-{m.group(2)}]", result, flags=re.IGNORECASE)
|
||||
|
||||
return result, warnings
|
||||
|
||||
|
||||
def validate_roundtrip(original: str, converted: str) -> Tuple[bool, Optional[str]]:
|
||||
"""Validate that converting from regex to English and back produces a valid pattern.
|
||||
|
||||
Args:
|
||||
original: The original regex pattern.
|
||||
converted: The pattern converted from English.
|
||||
|
||||
Returns:
|
||||
A tuple of (is_valid, error_message).
|
||||
"""
|
||||
try:
|
||||
parse_regex(converted)
|
||||
return True, None
|
||||
@@ -175,10 +285,20 @@ def validate_roundtrip(original: str, converted: str) -> Tuple[bool, Optional[st
|
||||
return False, str(e)
|
||||
|
||||
|
||||
def convert_english_to_regex(description: str, flavor: str = "pcre", validate: bool = True) -> Dict:
|
||||
def convert_english_to_regex(description: str, flavor: str = "pcre", validate: bool = True) -> Dict[str, Any]:
|
||||
"""Convert English description to regex with full context.
|
||||
|
||||
Args:
|
||||
description: The English description of the pattern.
|
||||
flavor: The target regex flavor.
|
||||
validate: Whether to validate the result.
|
||||
|
||||
Returns:
|
||||
A dictionary with conversion results.
|
||||
"""
|
||||
pattern, warnings = english_to_regex(description, flavor)
|
||||
|
||||
result = {
|
||||
result: Dict[str, Any] = {
|
||||
"input": description,
|
||||
"output": pattern,
|
||||
"flavor": flavor,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"""Generate concrete match examples for regex patterns."""
|
||||
|
||||
import random
|
||||
import re
|
||||
import string
|
||||
from typing import List
|
||||
from typing import List, Set
|
||||
|
||||
from ..parser import (
|
||||
Alternation,
|
||||
@@ -25,14 +27,16 @@ PUNCTUATION = "!@#$%^&*()_+-=[]{}|;:,.<>?"
|
||||
|
||||
|
||||
def generate_literal_example(node: Literal) -> str:
|
||||
"""Generate an example for a literal."""
|
||||
return node.value
|
||||
|
||||
|
||||
def generate_character_class_example(node: CharacterClass) -> str:
|
||||
"""Generate an example for a character class."""
|
||||
options = []
|
||||
|
||||
for char in node.characters:
|
||||
if char in r"-\] ":
|
||||
if char in r"\-\]":
|
||||
options.append("\\" + char)
|
||||
elif char == "\t":
|
||||
options.append("\\t")
|
||||
@@ -56,6 +60,7 @@ def generate_character_class_example(node: CharacterClass) -> str:
|
||||
|
||||
|
||||
def generate_special_sequence_example(node: SpecialSequence) -> str:
|
||||
"""Generate an example for a special sequence."""
|
||||
sequences = {
|
||||
".": random.choice(string.ascii_letters + string.digits + "!@#$"),
|
||||
r"\d": random.choice(DIGITS),
|
||||
@@ -73,10 +78,12 @@ def generate_special_sequence_example(node: SpecialSequence) -> str:
|
||||
|
||||
|
||||
def generate_anchor_example(node: Anchor) -> str:
|
||||
"""Generate an example for an anchor."""
|
||||
return ""
|
||||
|
||||
|
||||
def generate_quantifier_example(node: Quantifier) -> str:
|
||||
"""Generate an example for a quantifier."""
|
||||
if not hasattr(node, 'child') or not node.child:
|
||||
return "*"
|
||||
|
||||
@@ -131,10 +138,12 @@ def generate_quantifier_example(node: Quantifier) -> str:
|
||||
|
||||
|
||||
def generate_group_example(node: Group) -> str:
|
||||
"""Generate an example for a group."""
|
||||
return "".join(generate_node_example(child) for child in node.content)
|
||||
|
||||
|
||||
def generate_alternation_example(node: Alternation) -> str:
|
||||
"""Generate an example for an alternation."""
|
||||
if not node.options:
|
||||
return ""
|
||||
|
||||
@@ -147,10 +156,12 @@ def generate_alternation_example(node: Alternation) -> str:
|
||||
|
||||
|
||||
def generate_backreference_example(node: Backreference) -> str:
|
||||
"""Generate an example for a backreference."""
|
||||
return "[reference]"
|
||||
|
||||
|
||||
def generate_node_example(node: ASTNode) -> str:
|
||||
"""Generate an example for any AST node."""
|
||||
if isinstance(node, Literal):
|
||||
return generate_literal_example(node)
|
||||
elif isinstance(node, CharacterClass):
|
||||
@@ -172,9 +183,19 @@ def generate_node_example(node: ASTNode) -> str:
|
||||
|
||||
|
||||
def generate_examples(pattern: str, count: int = 5, flavor: str = "pcre") -> List[str]:
|
||||
"""Generate example strings that match the given pattern.
|
||||
|
||||
Args:
|
||||
pattern: The regex pattern.
|
||||
count: Number of examples to generate.
|
||||
flavor: The regex flavor.
|
||||
|
||||
Returns:
|
||||
A list of example strings that match the pattern.
|
||||
"""
|
||||
try:
|
||||
ast = parse_regex(pattern)
|
||||
examples = set()
|
||||
examples: Set[str] = set()
|
||||
|
||||
for _ in range(count * 3):
|
||||
if len(examples) >= count:
|
||||
@@ -217,6 +238,17 @@ def generate_examples(pattern: str, count: int = 5, flavor: str = "pcre") -> Lis
|
||||
|
||||
|
||||
def generate_match_examples(pattern: str, test_string: str, count: int = 5, flavor: str = "pcre") -> List[str]:
|
||||
"""Generate examples from a test string that match the pattern.
|
||||
|
||||
Args:
|
||||
pattern: The regex pattern.
|
||||
test_string: The string to search for matches.
|
||||
count: Maximum number of examples to return.
|
||||
flavor: The regex flavor.
|
||||
|
||||
Returns:
|
||||
A list of matching substrings from the test string.
|
||||
"""
|
||||
try:
|
||||
compiled = re.compile(pattern)
|
||||
matches = compiled.findall(test_string)
|
||||
|
||||
336
regex_humanizer/parser/parser.py
Normal file
336
regex_humanizer/parser/parser.py
Normal file
@@ -0,0 +1,336 @@
|
||||
"""Parse tokens into an AST."""
|
||||
|
||||
import re
|
||||
from typing import List, Optional
|
||||
|
||||
from .ast import (
|
||||
ASTNode,
|
||||
Alternation,
|
||||
Anchor,
|
||||
Backreference,
|
||||
CharacterClass,
|
||||
Group,
|
||||
Literal,
|
||||
Quantifier,
|
||||
SpecialSequence,
|
||||
)
|
||||
from .tokenizer import Token, tokenize
|
||||
|
||||
|
||||
class ParseError(Exception):
|
||||
"""Exception raised when parsing fails."""
|
||||
|
||||
def __init__(self, message: str, position: int = 0):
|
||||
self.message = message
|
||||
self.position = position
|
||||
super().__init__(f"{message} at position {position}")
|
||||
|
||||
|
||||
def parse_quantifier(tokens: List[Token], index: int) -> tuple[Optional[Quantifier], int]:
|
||||
"""Parse a quantifier from tokens starting at index."""
|
||||
if index >= len(tokens):
|
||||
return None, index
|
||||
|
||||
token = tokens[index]
|
||||
min_count = 0
|
||||
max_count = Quantifier.MAX_UNBOUNDED
|
||||
lazy = False
|
||||
possessive = False
|
||||
|
||||
if token.type in ("PLUS", "PLUS_LAZY", "PLUS_POSSESSIVE"):
|
||||
min_count = 1
|
||||
max_count = Quantifier.MAX_UNBOUNDED
|
||||
lazy = token.type == "PLUS_LAZY"
|
||||
possessive = token.type == "PLUS_POSSESSIVE"
|
||||
return Quantifier(min=min_count, max=max_count, lazy=lazy, possessive=possessive, position=token.position), index + 1
|
||||
|
||||
elif token.type in ("STAR", "STAR_LAZY", "STAR_POSSESSIVE"):
|
||||
min_count = 0
|
||||
max_count = Quantifier.MAX_UNBOUNDED
|
||||
lazy = token.type == "STAR_LAZY"
|
||||
possessive = token.type == "STAR_POSSESSIVE"
|
||||
return Quantifier(min=min_count, max=max_count, lazy=lazy, possessive=possessive, position=token.position), index + 1
|
||||
|
||||
elif token.type in ("QUESTION", "QUESTION_LAZY", "QUESTION_POSSESSIVE"):
|
||||
min_count = 0
|
||||
max_count = 1
|
||||
lazy = token.type == "QUESTION_LAZY"
|
||||
possessive = token.type == "QUESTION_POSSESSIVE"
|
||||
return Quantifier(min=min_count, max=max_count, lazy=lazy, possessive=possessive, position=token.position), index + 1
|
||||
|
||||
elif token.type == "OPEN_BRACE":
|
||||
brace_content = ""
|
||||
brace_end = index
|
||||
|
||||
for i in range(index + 1, len(tokens)):
|
||||
if tokens[i].type == "CLOSE_BRACE":
|
||||
brace_end = i
|
||||
brace_content = "".join(t.value for t in tokens[index + 1:i])
|
||||
break
|
||||
|
||||
if not brace_content:
|
||||
raise ParseError("Invalid quantifier format", tokens[index].position)
|
||||
|
||||
brace_match = re.match(r"^(\d+)(?:,(\d*))?$", brace_content)
|
||||
if not brace_match:
|
||||
raise ParseError("Invalid quantifier format", tokens[index].position)
|
||||
|
||||
min_count = int(brace_match.group(1))
|
||||
max_count_str = brace_match.group(2)
|
||||
max_count = int(max_count_str) if max_count_str else Quantifier.MAX_UNBOUNDED
|
||||
|
||||
next_index = brace_end + 1
|
||||
if next_index < len(tokens) and tokens[next_index].value == "?":
|
||||
lazy = True
|
||||
next_index += 1
|
||||
|
||||
return Quantifier(min=min_count, max=max_count, lazy=lazy, position=tokens[index].position), next_index
|
||||
|
||||
return None, index
|
||||
|
||||
|
||||
def parse_character_class(tokens: List[Token], index: int) -> tuple[CharacterClass, int]:
|
||||
"""Parse a character class from tokens starting at index."""
|
||||
if index >= len(tokens) or tokens[index].type != "OPEN_BRACKET":
|
||||
raise ParseError("Expected character class", tokens[index].position if index < len(tokens) else 0)
|
||||
|
||||
bracket_token = tokens[index]
|
||||
inverted = False
|
||||
characters = []
|
||||
ranges = []
|
||||
|
||||
i = index + 1
|
||||
|
||||
if i < len(tokens) and tokens[i].type == "LITERAL" and tokens[i].value == "^":
|
||||
inverted = True
|
||||
i += 1
|
||||
|
||||
while i < len(tokens) and tokens[i].type != "CLOSE_BRACKET":
|
||||
token = tokens[i]
|
||||
|
||||
if token.type == "ESCAPED":
|
||||
char = token.value[1]
|
||||
if i + 2 < len(tokens) and tokens[i + 1].type == "MINUS":
|
||||
end_char = tokens[i + 2].value
|
||||
if end_char == "ESCAPED":
|
||||
end_char = end_char[1]
|
||||
ranges.append((char, end_char))
|
||||
i += 3
|
||||
else:
|
||||
characters.append(char)
|
||||
i += 1
|
||||
elif token.type == "MINUS":
|
||||
i += 1
|
||||
elif token.type == "DIGIT":
|
||||
characters.append(token.value)
|
||||
i += 1
|
||||
elif token.type == "LITERAL":
|
||||
if i + 2 < len(tokens) and tokens[i + 1].type == "MINUS":
|
||||
end_char = tokens[i + 2].value
|
||||
ranges.append((token.value, end_char))
|
||||
i += 3
|
||||
else:
|
||||
characters.append(token.value)
|
||||
i += 1
|
||||
else:
|
||||
characters.append(token.value)
|
||||
i += 1
|
||||
|
||||
if i >= len(tokens):
|
||||
raise ParseError("Unclosed character class", bracket_token.position)
|
||||
|
||||
return CharacterClass(
|
||||
inverted=inverted,
|
||||
characters=characters,
|
||||
ranges=ranges,
|
||||
position=bracket_token.position
|
||||
), i + 1
|
||||
|
||||
|
||||
def parse_group(tokens: List[Token], index: int) -> tuple[Group, int]:
|
||||
"""Parse a group from tokens starting at index."""
|
||||
if index >= len(tokens):
|
||||
raise ParseError("Expected group start", 0)
|
||||
|
||||
group_token = tokens[index]
|
||||
|
||||
if tokens[index].type == "NON_CAPTURING":
|
||||
content, next_index = parse_sequence(tokens, index + 1)
|
||||
if next_index >= len(tokens) or tokens[next_index].type != "CLOSE_GROUP":
|
||||
raise ParseError("Unclosed non-capturing group", group_token.position)
|
||||
next_index += 1
|
||||
return Group(content=content, capturing=False, position=group_token.position), next_index
|
||||
|
||||
if tokens[index].type == "NAMED_GROUP":
|
||||
name = tokens[index].extra
|
||||
content, next_index = parse_sequence(tokens, index + 1)
|
||||
if next_index >= len(tokens) or tokens[next_index].type != "CLOSE_GROUP":
|
||||
raise ParseError("Unclosed named group", group_token.position)
|
||||
next_index += 1
|
||||
return Group(content=content, capturing=True, name=name, position=group_token.position), next_index
|
||||
|
||||
if tokens[index].type in ("POSITIVE_LOOKAHEAD", "NEGATIVE_LOOKAHEAD",
|
||||
"POSITIVE_LOOKBEHIND", "NEGATIVE_LOOKBEHIND",
|
||||
"COMMENT"):
|
||||
content, next_index = parse_sequence(tokens, index + 1)
|
||||
if next_index >= len(tokens) or tokens[next_index].type != "CLOSE_GROUP":
|
||||
raise ParseError("Unclosed group", group_token.position)
|
||||
next_index += 1
|
||||
return Group(content=content, capturing=False, position=group_token.position), next_index
|
||||
|
||||
if tokens[index].type == "OPEN_GROUP":
|
||||
i = index + 1
|
||||
if i >= len(tokens):
|
||||
raise ParseError("Empty group", group_token.position)
|
||||
options: List[List[ASTNode]] = []
|
||||
current_option: List[ASTNode] = []
|
||||
first_alternation_index: Optional[int] = None
|
||||
while i < len(tokens):
|
||||
token = tokens[i]
|
||||
if token.type == "ALTERNATION":
|
||||
options.append(current_option)
|
||||
current_option = []
|
||||
first_alternation_index = i
|
||||
i += 1
|
||||
elif token.type == "CLOSE_GROUP":
|
||||
if current_option or first_alternation_index is not None:
|
||||
options.append(current_option)
|
||||
if len(options) > 1:
|
||||
alternation = Alternation(options=options, position=tokens[first_alternation_index].position) # type: ignore[index]
|
||||
return Group(content=[alternation], capturing=True, position=group_token.position), i + 1
|
||||
else:
|
||||
return Group(content=current_option, capturing=True, position=group_token.position), i + 1
|
||||
else:
|
||||
nodes, next_i = parse_sequence(tokens, i)
|
||||
current_option.extend(nodes)
|
||||
i = next_i
|
||||
raise ParseError("Unclosed group", group_token.position)
|
||||
|
||||
raise ParseError("Expected group start", tokens[index].position if index < len(tokens) else 0)
|
||||
|
||||
|
||||
def parse_sequence(tokens: List[Token], index: int) -> tuple[List[ASTNode], int]:
|
||||
"""Parse a sequence of tokens until end of group or pattern."""
|
||||
nodes: List[ASTNode] = []
|
||||
i = index
|
||||
|
||||
while i < len(tokens):
|
||||
token = tokens[i]
|
||||
|
||||
if token.type in ("CLOSE_GROUP", "CLOSE_BRACKET", "ALTERNATION"):
|
||||
break
|
||||
|
||||
if token.type == "ANCHOR_START":
|
||||
nodes.append(Anchor(kind="^", position=token.position))
|
||||
i += 1
|
||||
elif token.type == "ANCHOR_END":
|
||||
nodes.append(Anchor(kind="$", position=token.position))
|
||||
i += 1
|
||||
elif token.type == "WORD_BOUNDARY":
|
||||
nodes.append(Anchor(kind=r"\b", position=token.position))
|
||||
i += 1
|
||||
elif token.type == "NON_WORD_BOUNDARY":
|
||||
nodes.append(Anchor(kind=r"\B", position=token.position))
|
||||
i += 1
|
||||
elif token.type in ("DIGIT", "NON_DIGIT", "WHITESPACE", "NON_WHITESPACE",
|
||||
"WORD_CHAR", "NON_WORD_CHAR"):
|
||||
nodes.append(SpecialSequence(sequence=token.value, position=token.position))
|
||||
i += 1
|
||||
elif token.type == "ANY_CHAR":
|
||||
nodes.append(SpecialSequence(sequence=".", position=token.position))
|
||||
i += 1
|
||||
elif token.type == "OPEN_BRACKET":
|
||||
char_class, next_i = parse_character_class(tokens, i)
|
||||
nodes.append(char_class)
|
||||
i = next_i
|
||||
elif token.type == "OPEN_GROUP":
|
||||
group, next_i = parse_group(tokens, i)
|
||||
nodes.append(group)
|
||||
i = next_i
|
||||
elif token.type == "NON_CAPTURING":
|
||||
group, next_i = parse_group(tokens, i)
|
||||
nodes.append(group)
|
||||
i = next_i
|
||||
elif token.type == "BACKREFERENCE":
|
||||
ref = int(token.extra) if token.extra else 1
|
||||
nodes.append(Backreference(reference=ref, position=token.position))
|
||||
i += 1
|
||||
elif token.type == "NAMED_BACKREFERENCE":
|
||||
nodes.append(Backreference(reference=token.extra or "", position=token.position))
|
||||
i += 1
|
||||
elif token.type == "ESCAPED":
|
||||
char = token.value[1]
|
||||
nodes.append(Literal(value=char, escaped=True, position=token.position))
|
||||
i += 1
|
||||
elif token.type == "LITERAL":
|
||||
literal_value = token.value
|
||||
literal_position = token.position
|
||||
i += 1
|
||||
while i < len(tokens) and tokens[i].type == "LITERAL":
|
||||
literal_value += tokens[i].value
|
||||
i += 1
|
||||
nodes.append(Literal(value=literal_value, escaped=False, position=literal_position))
|
||||
elif token.type == "ALTERNATION":
|
||||
break
|
||||
else:
|
||||
nodes.append(Literal(value=token.value, position=token.position))
|
||||
i += 1
|
||||
|
||||
if i < len(tokens):
|
||||
quant_node, next_i = parse_quantifier(tokens, i)
|
||||
if quant_node and nodes:
|
||||
nodes[-1] = quantifier_wrap(nodes[-1], quant_node)
|
||||
i = next_i
|
||||
|
||||
return nodes, i
|
||||
|
||||
|
||||
def quantifier_wrap(node: ASTNode, quantifier: Quantifier) -> Quantifier:
|
||||
"""Wrap a node with a quantifier."""
|
||||
quantifier.child = node
|
||||
return quantifier
|
||||
|
||||
|
||||
def parse_alternation(tokens: List[Token], index: int) -> tuple[Alternation, int]:
|
||||
"""Parse an alternation from tokens."""
|
||||
options: List[List[ASTNode]] = []
|
||||
current_option: List[ASTNode] = []
|
||||
i = index
|
||||
|
||||
while i < len(tokens):
|
||||
token = tokens[i]
|
||||
|
||||
if token.type == "ALTERNATION":
|
||||
options.append(current_option)
|
||||
current_option = []
|
||||
i += 1
|
||||
elif token.type == "CLOSE_GROUP":
|
||||
if current_option:
|
||||
options.append(current_option)
|
||||
alternation = Alternation(options=options, position=tokens[index].position)
|
||||
return alternation, i
|
||||
else:
|
||||
node, next_i = parse_sequence(tokens, i)
|
||||
current_option.extend(node)
|
||||
i = next_i
|
||||
|
||||
if current_option:
|
||||
options.append(current_option)
|
||||
|
||||
return Alternation(options=options, position=tokens[index].position), i
|
||||
|
||||
|
||||
def parse_regex(pattern: str) -> List[ASTNode]:
|
||||
"""Parse a regex pattern into an AST."""
|
||||
tokens = tokenize(pattern)
|
||||
nodes, index = parse_sequence(tokens, 0)
|
||||
|
||||
if index < len(tokens) and tokens[index].type == "ALTERNATION":
|
||||
alternation, next_index = parse_alternation(tokens, index)
|
||||
return [alternation]
|
||||
|
||||
if index < len(tokens):
|
||||
remaining = "".join(t.value for t in tokens[index:])
|
||||
raise ParseError(f"Unexpected token at position {index}: {remaining!r}", tokens[index].position)
|
||||
|
||||
return nodes
|
||||
108
regex_humanizer/parser/tokenizer.py
Normal file
108
regex_humanizer/parser/tokenizer.py
Normal file
@@ -0,0 +1,108 @@
|
||||
Tokenize regex patterns into tokens.
|
||||
|
||||
From datclasses import dataclass
|
||||
From typing Index List, Optional
|
||||
import re
|
||||
|
||||
TOKEN_SPECIFICATION_VALUE_STATECORE_VALUETED_SPECIFICATION_VALUETED_SPECIFICATION_VALUETED_MAKETAPIS_VALUE', r"\\\.'"),
|
||||
(\"LITAR\", r\"[a-zA-0-9]+\"),
|
||||
(\"ESCAPED\", r\"\\\\.\"),
|
||||
(\"OWN_GROUP\", r\"\\(\"),
|
||||
(\"CLASE_GROUP\", r\"\)\"),
|
||||
(\"OPEN_BRACE\", r\"\\{\"),
|
||||
(\"CLASE_BRACE\", r\"\\}\"),
|
||||
(\"OPEN_BRACKET\", r\"\\[\"),
|
||||
(\"CLASE_BRACKET\", r\"\\]\"),
|
||||
(\"ANOHOR_START\", r\"\\^\"),
|
||||
(\"ANOHOR_END\", r\"\\$\"),
|
||||
(\"DOT\", r\"\\.\"),
|
||||
(\"ALTERNATION\", r\"\\\\\|\"),
|
||||
(\"COMMA\"), r\"\,\"),
|
||||
(\"HYPHEN\", r\"\\-\"),
|
||||
(\"PLUS\", r\"\\\+\"),
|
||||
(\"STAR\", r\"\\*\"),
|
||||
(\"QUESTION\", r\"\\?\"),
|
||||
(\"WHESIPACE\", r\"\\s+\", True),
|
||||
(\"MIMMATCH\", r\".\"),
|
||||
]
|
||||
@Dataclass
|
||||
class Token:
|
||||
"utilance a token in a regex pattern.""
|
||||
type: str
|
||||
value: str
|
||||
position: int
|
||||
|
||||
class TokenizerException(Exception:
|
||||
"utileanced when tokenization fails."
|
||||
pass
|
||||
|
||||
def tokenize(pattern: str) -> List[Token]:
|
||||
"utilanize a regex pattern into a list of tokens.
|
||||
|
||||
Args:
|
||||
pattern: The regex pattern to tokenize.
|
||||
|
||||
Returns:
|
||||
A list of Token objects.
|
||||
|
||||
tokens = []
|
||||
position = 0
|
||||
length = len(patternl)
|
||||
|
||||
|
||||
while position < length:
|
||||
match = None
|
||||
for token_type, spec, *str in TOKEN_SPECIFICATION_VALUE-
|
||||
is_skipped = str and str[0]
|
||||
regex = re.compile(spec)
|
||||
match = regex.match(pattern, position)
|
||||
if match:
|
||||
value = match.group(0)
|
||||
if is_skipped:
|
||||
position = match.end 0)
|
||||
other:
|
||||
tokens.append(Token(type=token_type, value=value, position=position))
|
||||
position = match.end(1)
|
||||
break
|
||||
if not match:
|
||||
aise TokenizerError(f"unexpected character at position {position}: {pattern[position]!r}")
|
||||
|
||||
tokens = _combine_tokens(tokens)
|
||||
return tokens
|
||||
|
||||
def _combine_tokens(tokens: List[Token]) -> List[Token]:
|
||||
"combine tokkens that should be treated as single tokens."
|
||||
|
||||
result = []
|
||||
i = 0
|
||||
while i < len(tokens):
|
||||
token = tokens[i]
|
||||
|
||||
if token.type == "OWN_GROUP\" and i + 2 < len(tokens):
|
||||
q_token = tokens[i + 1]
|
||||
colon_token = tokens[i + 2]
|
||||
if q_token.type == \"QUESTION\" and colon_token.type == LITABL and colon_token.value == \":\":
|
||||
result.append(Token(type=\"NON_CAPURING_GROUP\", value=\"(?::\", position=token.position))
|
||||
i += 3
|
||||
continue
|
||||
|
||||
if token.type == "OPEN_BRACKET\" and i + 1 < len(tokens):
|
||||
next_token = tokens[i + 1]
|
||||
if next_token.type == \"ANOHOR_START\":
|
||||
result.append(Token(type=\"INVERTED_BRACKET\", value=\"[\\"\", position=token.position))
|
||||
i += 2
|
||||
continue
|
||||
|
||||
if token.type in (\"PLUS\", \"STAR\", \"QUESTION\") and i + 1 < len(tokens):
|
||||
next_token = tokens[i + 1]
|
||||
if next_token.type == \"QUESTION\":
|
||||
combined_type = f\"token.type+'LAZY\"}
|
||||
result.append(Token(type=combined_type, value=token.value + next_token.value, position=token.position))
|
||||
i += 2
|
||||
continue
|
||||
|
||||
result.append(token)
|
||||
i += 1
|
||||
|
||||
|
||||
return result
|
||||
81
regex_humanizer/wizard/__init__.py
Normal file
81
regex_humanizer/wizard/__init__.py
Normal file
@@ -0,0 +1,81 @@
|
||||
"""Interactive wizard module for building regex patterns step by step."""
|
||||
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from ..converter import convert_to_english
|
||||
|
||||
WIZARD_STEPS = [
|
||||
{
|
||||
"id": "pattern_type",
|
||||
"name": "Pattern Type",
|
||||
"description": "What type of pattern are you building?",
|
||||
"options": [
|
||||
("literal", "Match specific text"),
|
||||
("character_class", "Match a character set"),
|
||||
("template", "Use a template"),
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "quantifier",
|
||||
"name": "Quantifier",
|
||||
"description": "How many times should the pattern repeat?",
|
||||
"options": [
|
||||
("once", "Exactly once (default)"),
|
||||
("optional", "Zero or one time (?)"),
|
||||
("zero_or_more", "Zero or more times (*)"),
|
||||
("one_or_more", "One or more times (+)"),
|
||||
("custom", "Custom count"),
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def get_step_prompt(step_id: str) -> Optional[dict]:
|
||||
"""Get the prompt for a wizard step."""
|
||||
for step in WIZARD_STEPS:
|
||||
if step["id"] == step_id:
|
||||
return step
|
||||
return None
|
||||
|
||||
|
||||
def get_step_options(step_id: str) -> Any:
|
||||
"""Get the options for a wizard step."""
|
||||
step = get_step_prompt(step_id)
|
||||
if step:
|
||||
return step.get("options", [])
|
||||
return []
|
||||
|
||||
|
||||
def format_pattern_preview(parts: List[dict]) -> str:
|
||||
"""Format the current pattern as a preview string."""
|
||||
pattern_parts = []
|
||||
for part in parts:
|
||||
if part["type"] == "literal":
|
||||
pattern_parts.append(part["value"])
|
||||
elif part["type"] == "character_class":
|
||||
chars = "".join(part["characters"])
|
||||
pattern_parts.append(f"[{chars}]")
|
||||
elif part["type"] == "quantifier":
|
||||
if pattern_parts:
|
||||
pattern_parts[-1] = pattern_parts[-1] + part["value"]
|
||||
return "".join(pattern_parts)
|
||||
|
||||
|
||||
def get_pattern_description(parts: List[dict]) -> str:
|
||||
"""Get a human-readable description of the current pattern."""
|
||||
if not parts:
|
||||
return "No pattern defined yet"
|
||||
|
||||
pattern = format_pattern_preview(parts)
|
||||
return convert_to_english(pattern) if pattern else "No pattern defined yet"
|
||||
|
||||
|
||||
def validate_pattern_part(part: dict) -> tuple[bool, Optional[str]]:
|
||||
"""Validate a pattern part."""
|
||||
if part["type"] == "literal":
|
||||
if not part.get("value"):
|
||||
return False, "Literal value cannot be empty"
|
||||
elif part["type"] == "character_class":
|
||||
if not part.get("characters"):
|
||||
return False, "Character class must have at least one character"
|
||||
return True, None
|
||||
@@ -1,20 +1,22 @@
|
||||
"""Tests for the CLI module."""
|
||||
|
||||
import json
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from regex_humanizer.cli import main
|
||||
|
||||
|
||||
class TestCLIMain:
|
||||
"""Tests for the main CLI command."""
|
||||
|
||||
def test_main_help(self):
|
||||
"""Test that --help works."""
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--help"])
|
||||
assert result.exit_code == 0
|
||||
assert "Regex Humanizer" in result.output
|
||||
|
||||
def test_main_version(self):
|
||||
"""Test that --version works."""
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--version"])
|
||||
assert result.exit_code == 0
|
||||
@@ -22,44 +24,62 @@ class TestCLIMain:
|
||||
|
||||
|
||||
class TestExplainCommand:
|
||||
"""Tests for the explain command."""
|
||||
|
||||
def test_explain_literal(self):
|
||||
"""Test explaining a literal pattern."""
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["explain", "hello"])
|
||||
assert result.exit_code == 0
|
||||
assert "hello" in result.output.lower() or "letter" in result.output.lower()
|
||||
|
||||
def test_explain_with_flavor(self):
|
||||
"""Test explaining with a specific flavor."""
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["explain", "hello", "--flavor", "python"])
|
||||
assert result.exit_code == 0
|
||||
assert "hello" in result.output.lower()
|
||||
|
||||
def test_explain_verbose(self):
|
||||
"""Test explaining in verbose mode."""
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["explain", "hello", "--verbose"])
|
||||
assert result.exit_code == 0
|
||||
assert "Pattern" in result.output
|
||||
|
||||
def test_explain_json(self):
|
||||
"""Test explaining in JSON format."""
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["explain", "hello", "--json"])
|
||||
assert result.exit_code == 0
|
||||
assert "{" in result.output
|
||||
|
||||
def test_explain_invalid_pattern(self):
|
||||
"""Test explaining an invalid pattern."""
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["explain", "[unclosed"])
|
||||
assert result.exit_code != 0
|
||||
assert "Error" in result.output
|
||||
|
||||
|
||||
class TestGenerateCommand:
|
||||
"""Tests for the generate command."""
|
||||
|
||||
def test_generate_literal(self):
|
||||
"""Test generating examples for a literal."""
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["generate", "hello"])
|
||||
assert result.exit_code == 0
|
||||
assert "hello" in result.output
|
||||
|
||||
def test_generate_with_count(self):
|
||||
"""Test generating with a specific count."""
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["generate", "a", "--count", "3"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
def test_generate_json(self):
|
||||
"""Test generating in JSON format."""
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["generate", "hello", "--json"])
|
||||
assert result.exit_code == 0
|
||||
@@ -67,28 +87,47 @@ class TestGenerateCommand:
|
||||
|
||||
|
||||
class TestFromEnglishCommand:
|
||||
"""Tests for the from-english command."""
|
||||
|
||||
def test_from_english_basic(self):
|
||||
"""Test converting basic English to regex."""
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["from-english", "the letter a"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
def test_from_english_with_flavor(self):
|
||||
"""Test converting with a specific flavor."""
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["from-english", "a digit", "--flavor", "python"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
def test_from_english_json(self):
|
||||
"""Test converting in JSON format."""
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["from-english", "a digit", "--json"])
|
||||
assert result.exit_code == 0
|
||||
assert "{" in result.output
|
||||
|
||||
|
||||
class TestFlavorsCommand:
|
||||
"""Tests for the flavors command."""
|
||||
|
||||
def test_flavors_list(self):
|
||||
"""Test listing supported flavors."""
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["flavors"])
|
||||
assert result.exit_code == 0
|
||||
assert "pcre" in result.output
|
||||
assert "javascript" in result.output
|
||||
assert "python" in result.output
|
||||
assert "go" in result.output
|
||||
|
||||
|
||||
class TestDetectCommand:
|
||||
"""Tests for the detect command."""
|
||||
|
||||
def test_detect_pattern(self):
|
||||
"""Test detecting pattern flavor."""
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["detect", r"\d+"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
@@ -1,55 +1,103 @@
|
||||
"""Tests for the converter module."""
|
||||
|
||||
import pytest
|
||||
|
||||
from regex_humanizer.converter import convert_to_english, generate_description
|
||||
|
||||
|
||||
class TestConvertToEnglish:
|
||||
"""Tests for the convert_to_english function."""
|
||||
|
||||
def test_convert_literal(self):
|
||||
"""Test converting a literal pattern."""
|
||||
result = convert_to_english("hello")
|
||||
assert "hello" in result.lower() or "letter" in result.lower()
|
||||
|
||||
def test_convert_character_class(self):
|
||||
"""Test converting a character class."""
|
||||
result = convert_to_english("[abc]")
|
||||
assert "any" in result.lower() or "character" in result.lower()
|
||||
|
||||
def test_convert_inverted_class(self):
|
||||
"""Test converting an inverted character class."""
|
||||
result = convert_to_english("[^abc]")
|
||||
assert "except" in result.lower()
|
||||
|
||||
def test_convert_quantifier_star(self):
|
||||
"""Test converting the * quantifier."""
|
||||
result = convert_to_english("a*")
|
||||
assert "zero" in result.lower() or "more" in result.lower()
|
||||
|
||||
def test_convert_quantifier_plus(self):
|
||||
"""Test converting the + quantifier."""
|
||||
result = convert_to_english("a+")
|
||||
assert "one" in result.lower() or "more" in result.lower()
|
||||
|
||||
def test_convert_quantifier_question(self):
|
||||
"""Test converting the ? quantifier."""
|
||||
result = convert_to_english("a?")
|
||||
assert "optionally" in result.lower() or "zero" in result.lower()
|
||||
|
||||
def test_convert_anchors(self):
|
||||
"""Test converting anchors."""
|
||||
result = convert_to_english("^start$")
|
||||
assert "start" in result.lower() and "end" in result.lower()
|
||||
|
||||
def test_convert_alternation(self):
|
||||
"""Test converting alternation."""
|
||||
result = convert_to_english("a|b")
|
||||
assert "or" in result.lower()
|
||||
|
||||
def test_convert_group(self):
|
||||
"""Test converting a group."""
|
||||
result = convert_to_english("(abc)")
|
||||
assert "group" in result.lower()
|
||||
|
||||
def test_convert_non_capturing_group(self):
|
||||
"""Test converting a non-capturing group."""
|
||||
result = convert_to_english("(?:abc)")
|
||||
assert "non-capturing" in result.lower() or "group" in result.lower()
|
||||
|
||||
def test_convert_special_sequence_digit(self):
|
||||
"""Test converting digit sequence."""
|
||||
result = convert_to_english(r"\d")
|
||||
assert "digit" in result.lower()
|
||||
|
||||
def test_convert_special_sequence_word(self):
|
||||
"""Test converting word character sequence."""
|
||||
result = convert_to_english(r"\w")
|
||||
assert "word" in result.lower()
|
||||
|
||||
def test_convert_email_pattern(self):
|
||||
result = convert_to_english(r"^\w+@[a-z]+\.[a]+$")
|
||||
"""Test converting an email pattern."""
|
||||
result = convert_to_english(r"^\w+@[a-z]+\.[a-z]+$")
|
||||
assert "start" in result.lower() and "end" in result.lower()
|
||||
|
||||
def test_convert_phone_pattern(self):
|
||||
"""Test converting a phone pattern."""
|
||||
result = convert_to_english(r"\d{3}-\d{3}-\d{4}")
|
||||
assert "digit" in result.lower()
|
||||
|
||||
def test_convert_empty_pattern(self):
|
||||
"""Test converting an empty pattern."""
|
||||
result = convert_to_english("")
|
||||
assert result
|
||||
|
||||
def test_convert_complex_pattern(self):
|
||||
"""Test converting a complex pattern."""
|
||||
pattern = r"^(https?|ftp)://[^\s/$.?#].[^\s]*$"
|
||||
result = convert_to_english(pattern)
|
||||
assert "start" in result.lower() and "end" in result.lower()
|
||||
|
||||
|
||||
class TestGenerateDescription:
|
||||
"""Tests for the generate_description function."""
|
||||
|
||||
def test_generate_description_empty(self):
|
||||
"""Test generating description for empty list."""
|
||||
result = generate_description([])
|
||||
assert "empty" in result.lower()
|
||||
|
||||
def test_generate_description_literal(self):
|
||||
"""Test generating description for a literal."""
|
||||
from regex_humanizer.parser import Literal
|
||||
result = generate_description([Literal(value="a")])
|
||||
assert "letter" in result.lower() or "a" in result.lower()
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
"""Tests for the examples module."""
|
||||
|
||||
import pytest
|
||||
|
||||
from regex_humanizer.examples import generate_examples, generate_match_examples
|
||||
|
||||
|
||||
class TestGenerateExamples:
|
||||
"""Tests for the generate_examples function."""
|
||||
|
||||
def test_generate_literal_examples(self):
|
||||
"""Test generating examples for a literal pattern."""
|
||||
examples = generate_examples("hello", count=3)
|
||||
assert len(examples) >= 1
|
||||
assert "hello" in examples
|
||||
|
||||
def test_generate_character_class_examples(self):
|
||||
"""Test generating examples for a character class."""
|
||||
examples = generate_examples("[abc]", count=5)
|
||||
assert len(examples) > 0
|
||||
for example in examples:
|
||||
@@ -19,35 +21,79 @@ class TestGenerateExamples:
|
||||
assert example in "abc"
|
||||
|
||||
def test_generate_quantifier_examples(self):
|
||||
"""Test generating examples for a quantifier pattern."""
|
||||
examples = generate_examples("a*", count=3)
|
||||
assert len(examples) >= 1
|
||||
for example in examples:
|
||||
assert all(c == "a" for c in example)
|
||||
|
||||
def test_generate_digit_examples(self):
|
||||
"""Test generating examples for digit pattern."""
|
||||
examples = generate_examples(r"\d+", count=3)
|
||||
assert len(examples) >= 1
|
||||
for example in examples:
|
||||
assert example.isdigit()
|
||||
|
||||
def test_generate_word_examples(self):
|
||||
"""Test generating examples for word character pattern."""
|
||||
examples = generate_examples(r"\w+", count=3)
|
||||
assert len(examples) >= 1
|
||||
for example in examples:
|
||||
assert example.replace("_", "").isalnum()
|
||||
|
||||
def test_generate_alternation_examples(self):
|
||||
"""Test generating examples for alternation."""
|
||||
examples = generate_examples("foo|bar", count=3)
|
||||
assert len(examples) >= 1
|
||||
for example in examples:
|
||||
assert example in ("foo", "bar")
|
||||
|
||||
def test_generate_complex_pattern_examples(self):
|
||||
"""Test generating examples for a complex pattern."""
|
||||
examples = generate_examples(r"\d{3}-\d{4}", count=3)
|
||||
assert len(examples) >= 1
|
||||
for example in examples:
|
||||
assert "-" in example
|
||||
|
||||
def test_generate_with_count(self):
|
||||
"""Test that the count parameter works."""
|
||||
examples = generate_examples("a", count=5)
|
||||
assert len(examples) <= 5
|
||||
|
||||
def test_generate_invalid_pattern(self):
|
||||
"""Test generating examples for an invalid pattern."""
|
||||
examples = generate_examples("[unclosed", count=3)
|
||||
assert examples == []
|
||||
|
||||
def test_generate_email_examples(self):
|
||||
"""Test generating examples for an email pattern."""
|
||||
examples = generate_examples(r"\w+@\w+\.\w+", count=3)
|
||||
assert len(examples) >= 1
|
||||
for example in examples:
|
||||
assert "@" in example
|
||||
assert "." in example.split("@")[1]
|
||||
|
||||
|
||||
class TestGenerateMatchExamples:
|
||||
"""Tests for the generate_match_examples function."""
|
||||
|
||||
def test_generate_matches_from_string(self):
|
||||
"""Test generating matches from a test string."""
|
||||
examples = generate_match_examples(r"\d+", "abc123def456ghi", count=3)
|
||||
assert len(examples) >= 1
|
||||
assert "123" in examples or "456" in examples
|
||||
|
||||
def test_generate_matches_no_match(self):
|
||||
"""Test generating matches when no match found."""
|
||||
examples = generate_match_examples(r"\d+", "abcdef", count=3)
|
||||
assert examples == []
|
||||
|
||||
def test_generate_matches_count(self):
|
||||
"""Test that count limits results."""
|
||||
examples = generate_match_examples(r"\w+", "one two three four five", count=2)
|
||||
assert len(examples) <= 2
|
||||
|
||||
def test_generate_matches_complex(self):
|
||||
"""Test generating matches for complex pattern."""
|
||||
examples = generate_match_examples(r"\b\w+@[\w.]+", "contact: test@example.com, support@company.org", count=3)
|
||||
assert len(examples) >= 1
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
"""Tests for the flavors module."""
|
||||
|
||||
import pytest
|
||||
|
||||
from regex_humanizer.flavors import (
|
||||
get_flavor,
|
||||
get_supported_flavors,
|
||||
@@ -13,7 +11,10 @@ from regex_humanizer.flavors import (
|
||||
|
||||
|
||||
class TestFlavorRegistry:
|
||||
"""Tests for the FlavorRegistry class."""
|
||||
|
||||
def test_list_flavors(self):
|
||||
"""Test listing all supported flavors."""
|
||||
flavors = get_supported_flavors()
|
||||
assert "pcre" in flavors
|
||||
assert "javascript" in flavors
|
||||
@@ -21,48 +22,89 @@ class TestFlavorRegistry:
|
||||
assert "go" in flavors
|
||||
|
||||
def test_get_flavor(self):
|
||||
"""Test getting a flavor by name."""
|
||||
flavor = get_flavor("pcre")
|
||||
assert flavor is not None
|
||||
assert flavor.name == "pcre"
|
||||
|
||||
def test_get_invalid_flavor(self):
|
||||
"""Test getting an invalid flavor returns None."""
|
||||
flavor = get_flavor("invalid")
|
||||
assert flavor is None
|
||||
|
||||
def test_validate_flavor_valid(self):
|
||||
"""Test validating a valid flavor."""
|
||||
assert validate_flavor("pcre") is True
|
||||
assert validate_flavor("javascript") is True
|
||||
|
||||
def test_validate_flavor_invalid(self):
|
||||
"""Test validating an invalid flavor."""
|
||||
assert validate_flavor("invalid") is False
|
||||
|
||||
def test_flavor_has_features(self):
|
||||
"""Test that flavors have feature support information."""
|
||||
flavor = get_flavor("pcre")
|
||||
assert flavor is not None
|
||||
assert len(flavor.supported_features) > 0
|
||||
|
||||
|
||||
class TestDetectFlavor:
|
||||
"""Tests for the detect_flavor function."""
|
||||
|
||||
def test_detect_pcre_features(self):
|
||||
"""Test detecting PCRE-specific features."""
|
||||
flavor = detect_flavor(r"(?P<name>pattern)\k<name>")
|
||||
assert flavor == "pcre"
|
||||
|
||||
def test_detect_js_lookahead(self):
|
||||
"""Test detecting JavaScript patterns."""
|
||||
flavor = detect_flavor(r"(?=pattern)")
|
||||
assert flavor in ("javascript", "pcre")
|
||||
|
||||
def test_detect_go_backslash_k(self):
|
||||
"""Test detecting Go patterns."""
|
||||
flavor = detect_flavor(r"\k<name>")
|
||||
assert flavor in ("go", "python", "pcre")
|
||||
|
||||
def test_detect_possessive_quantifiers(self):
|
||||
"""Test detecting possessive quantifiers."""
|
||||
flavor = detect_flavor(r"a++")
|
||||
assert flavor == "pcre"
|
||||
|
||||
|
||||
class TestFeatureSupport:
|
||||
"""Tests for checking feature support."""
|
||||
|
||||
def test_check_js_lookbehind(self):
|
||||
"""Test that JavaScript doesn't support lookbehind."""
|
||||
pattern = r"(?<=pattern)"
|
||||
unsupported = check_feature_support(pattern, "javascript")
|
||||
assert "lookbehind" in unsupported
|
||||
|
||||
def test_check_go_lookbehind(self):
|
||||
"""Test that Go doesn't support lookbehind."""
|
||||
pattern = r"(?<=pattern)"
|
||||
unsupported = check_feature_support(pattern, "go")
|
||||
assert "lookbehind" in unsupported
|
||||
|
||||
def test_check_js_possessive(self):
|
||||
"""Test that JavaScript doesn't support possessive quantifiers."""
|
||||
pattern = r"a++"
|
||||
unsupported = check_feature_support(pattern, "javascript")
|
||||
assert "possessive_quantifiers" in unsupported
|
||||
|
||||
def test_pcre_supports_lookbehind(self):
|
||||
"""Test that PCRE supports lookbehind."""
|
||||
pattern = r"(?<=pattern)"
|
||||
unsupported = check_feature_support(pattern, "pcre")
|
||||
assert "lookbehind" not in unsupported
|
||||
|
||||
|
||||
class TestCompatibilityWarnings:
|
||||
"""Tests for generating compatibility warnings."""
|
||||
|
||||
def test_js_lookbehind_warning(self):
|
||||
"""Test warning for JavaScript lookbehind."""
|
||||
pattern = r"(?<=pattern)"
|
||||
warnings = get_compatibility_warnings(pattern, "javascript")
|
||||
assert len(warnings) > 0
|
||||
@@ -70,12 +112,43 @@ class TestCompatibilityWarnings:
|
||||
assert "lookbehind" in warning_types
|
||||
|
||||
def test_go_backreference_warning(self):
|
||||
"""Test warning for Go named backreferences."""
|
||||
pattern = r"\k<name>"
|
||||
warnings = get_compatibility_warnings(pattern, "go")
|
||||
warning_types = [w.feature for w in warnings]
|
||||
assert "named_groups" in warning_types or "backreferences_general" in warning_types
|
||||
assert "named_groups" in warning_types or "backreferences_general" in warning_types or "named_backreferences" in warning_types
|
||||
|
||||
def test_pcre_no_warnings(self):
|
||||
"""Test that PCRE has no warnings for basic patterns."""
|
||||
pattern = r"\w+"
|
||||
warnings = get_compatibility_warnings(pattern, "pcre")
|
||||
assert len(warnings) == 0
|
||||
|
||||
def test_warning_severity(self):
|
||||
"""Test that warnings have proper severity levels."""
|
||||
pattern = r"(?<=pattern)"
|
||||
warnings = get_compatibility_warnings(pattern, "javascript")
|
||||
assert len(warnings) > 0
|
||||
for w in warnings:
|
||||
assert w.severity in ("warning", "error")
|
||||
|
||||
|
||||
class TestFlavorAttributes:
|
||||
"""Tests for flavor attributes."""
|
||||
|
||||
def test_flavor_display_name(self):
|
||||
"""Test that flavors have display names."""
|
||||
flavor = get_flavor("pcre")
|
||||
assert flavor.display_name == "PCRE"
|
||||
flavor = get_flavor("javascript")
|
||||
assert flavor.display_name == "JavaScript"
|
||||
|
||||
def test_flavor_description(self):
|
||||
"""Test that flavors have descriptions."""
|
||||
flavor = get_flavor("python")
|
||||
assert len(flavor.description) > 0
|
||||
|
||||
def test_flavor_quirks(self):
|
||||
"""Test that flavors have quirk information."""
|
||||
flavor = get_flavor("go")
|
||||
assert len(flavor.quirks) > 0
|
||||
|
||||
@@ -12,18 +12,21 @@ from regex_humanizer.parser import (
|
||||
Group,
|
||||
Alternation,
|
||||
Anchor,
|
||||
SpecialSequence,
|
||||
)
|
||||
|
||||
|
||||
class TestTokenizer:
|
||||
"""Tests for the tokenize function."""
|
||||
|
||||
def test_tokenize_literal(self):
|
||||
"""Test tokenizing a literal string."""
|
||||
tokens = tokenize("abc")
|
||||
assert len(tokens) == 1
|
||||
assert tokens[0].type == "LITERAL"
|
||||
assert tokens[0].value == "abc"
|
||||
|
||||
def test_tokenize_anchors(self):
|
||||
"""Test tokenizing anchor characters."""
|
||||
tokens = tokenize("^test$")
|
||||
assert len(tokens) == 3
|
||||
assert tokens[0].type == "ANCHOR_START"
|
||||
@@ -31,18 +34,21 @@ class TestTokenizer:
|
||||
assert tokens[2].type == "ANCHOR_END"
|
||||
|
||||
def test_tokenize_quantifiers(self):
|
||||
"""Test tokenizing quantifiers."""
|
||||
tokens = tokenize("a*")
|
||||
assert len(tokens) == 2
|
||||
assert tokens[0].type == "LITERAL"
|
||||
assert tokens[1].type == "STAR"
|
||||
|
||||
def test_tokenize_character_class(self):
|
||||
"""Test tokenizing character classes."""
|
||||
tokens = tokenize("[abc]")
|
||||
assert len(tokens) >= 2
|
||||
assert tokens[0].type == "OPEN_BRACKET"
|
||||
assert tokens[-1].type == "CLOSE_BRACKET"
|
||||
|
||||
def test_tokenize_groups(self):
|
||||
"""Test tokenizing groups."""
|
||||
tokens = tokenize("(abc)")
|
||||
assert len(tokens) == 3
|
||||
assert tokens[0].type == "OPEN_GROUP"
|
||||
@@ -50,84 +56,117 @@ class TestTokenizer:
|
||||
assert tokens[2].type == "CLOSE_GROUP"
|
||||
|
||||
def test_tokenize_alternation(self):
|
||||
"""Test tokenizing alternation."""
|
||||
tokens = tokenize("a|b")
|
||||
assert len(tokens) == 3
|
||||
assert tokens[0].type == "LITERAL"
|
||||
assert tokens[1].type == "ALTERNATION"
|
||||
assert tokens[2].type == "LITERAL"
|
||||
|
||||
def test_tokenize_escape(self):
|
||||
"""Test tokenizing escaped characters."""
|
||||
tokens = tokenize(r"\.")
|
||||
assert len(tokens) == 1
|
||||
assert tokens[0].type == "ESCAPED"
|
||||
|
||||
def test_tokenize_special_sequences(self):
|
||||
"""Test tokenizing special sequences."""
|
||||
tokens = tokenize(r"\d+\w*\s?")
|
||||
assert len(tokens) >= 4
|
||||
|
||||
|
||||
class TestParser:
|
||||
"""Tests for the parse_regex function."""
|
||||
|
||||
def test_parse_literal(self):
|
||||
"""Test parsing a literal pattern."""
|
||||
ast = parse_regex("hello")
|
||||
assert len(ast) == 1
|
||||
assert isinstance(ast[0], Literal)
|
||||
assert ast[0].value == "hello"
|
||||
|
||||
def test_parse_character_class(self):
|
||||
"""Test parsing a character class."""
|
||||
ast = parse_regex("[abc]")
|
||||
assert len(ast) == 1
|
||||
assert isinstance(ast[0], CharacterClass)
|
||||
|
||||
def test_parse_inverted_class(self):
|
||||
"""Test parsing an inverted class."""
|
||||
ast = parse_regex("[^abc]")
|
||||
assert len(ast) == 1
|
||||
assert isinstance(ast[0], CharacterClass)
|
||||
assert ast[0].inverted is True
|
||||
|
||||
def test_parse_quantifier_star(self):
|
||||
"""Test parsing the * quantifier."""
|
||||
ast = parse_regex("a*")
|
||||
assert len(ast) == 1
|
||||
assert isinstance(ast[0], Quantifier)
|
||||
|
||||
def test_parse_quantifier_plus(self):
|
||||
"""Test parsing the + quantifier."""
|
||||
ast = parse_regex("a+")
|
||||
assert len(ast) == 1
|
||||
assert isinstance(ast[0], Quantifier)
|
||||
|
||||
def test_parse_quantifier_question(self):
|
||||
"""Test parsing the ? quantifier."""
|
||||
ast = parse_regex("a?")
|
||||
assert len(ast) == 1
|
||||
assert isinstance(ast[0], Quantifier)
|
||||
|
||||
def test_parse_group(self):
|
||||
"""Test parsing a group."""
|
||||
ast = parse_regex("(abc)")
|
||||
assert len(ast) == 1
|
||||
assert isinstance(ast[0], Group)
|
||||
assert len(ast[0].content) == 1
|
||||
assert isinstance(ast[0].content[0], Literal)
|
||||
assert ast[0].content[0].value == "abc"
|
||||
|
||||
def test_parse_non_capturing_group(self):
|
||||
"""Test parsing a non-capturing group."""
|
||||
ast = parse_regex("(?:abc)")
|
||||
assert len(ast) == 1
|
||||
assert isinstance(ast[0], Group)
|
||||
assert ast[0].capturing is False
|
||||
|
||||
def test_parse_alternation(self):
|
||||
"""Test parsing alternation."""
|
||||
ast = parse_regex("a|b")
|
||||
assert len(ast) == 1
|
||||
assert isinstance(ast[0], Alternation)
|
||||
assert len(ast[0].options) == 2
|
||||
|
||||
def test_parse_anchors(self):
|
||||
"""Test parsing anchors."""
|
||||
ast = parse_regex("^start$")
|
||||
assert len(ast) == 3
|
||||
assert isinstance(ast[0], Anchor)
|
||||
assert isinstance(ast[1], Literal)
|
||||
assert isinstance(ast[2], Anchor)
|
||||
assert ast[1].value == "start"
|
||||
|
||||
def test_parse_special_sequences(self):
|
||||
"""Test parsing special sequences."""
|
||||
ast = parse_regex(r"\d+\w+")
|
||||
assert len(ast) == 2
|
||||
assert isinstance(ast[0], Quantifier)
|
||||
assert isinstance(ast[1], Quantifier)
|
||||
|
||||
def test_parse_complex_pattern(self):
|
||||
"""Test parsing a complex pattern."""
|
||||
pattern = r"^\w+@[a-z]+\.[a-z]+$"
|
||||
ast = parse_regex(pattern)
|
||||
assert len(ast) > 0
|
||||
|
||||
def test_parse_error_unclosed_bracket(self):
|
||||
"""Test parsing error for unclosed bracket."""
|
||||
with pytest.raises(ParseError):
|
||||
parse_regex("[abc")
|
||||
|
||||
def test_parse_error_unclosed_group(self):
|
||||
"""Test parsing error for unclosed group."""
|
||||
with pytest.raises(ParseError):
|
||||
parse_regex("(abc")
|
||||
|
||||
Reference in New Issue
Block a user