Initial upload: regex-humanizer-cli with CI/CD workflow
This commit is contained in:
280
regex_humanizer/cli.py
Normal file
280
regex_humanizer/cli.py
Normal file
@@ -0,0 +1,280 @@
|
||||
"""Command-line interface for Regex Humanizer."""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from typing import Optional
|
||||
import click
|
||||
from .parser import parse_regex
|
||||
from .translator import translate_regex
|
||||
from .test_generator import generate_test_cases
|
||||
from .flavors import get_flavor_manager, get_available_flavors
|
||||
from .interactive import start_interactive_mode
|
||||
|
||||
|
||||
@click.group()
|
||||
@click.option(
|
||||
"--flavor",
|
||||
type=click.Choice(["pcre", "javascript", "python"]),
|
||||
default="pcre",
|
||||
help="Regex flavor to use",
|
||||
)
|
||||
@click.pass_context
|
||||
def main(ctx: click.Context, flavor: str):
|
||||
"""Regex Humanizer CLI - Convert regex patterns to human-readable English and generate test cases."""
|
||||
ctx.ensure_object(dict)
|
||||
ctx.obj["flavor"] = flavor
|
||||
|
||||
|
||||
@main.command("explain")
|
||||
@click.argument("pattern", type=str)
|
||||
@click.option(
|
||||
"--output",
|
||||
"-o",
|
||||
type=click.Choice(["text", "json"]),
|
||||
default="text",
|
||||
help="Output format",
|
||||
)
|
||||
@click.option(
|
||||
"--verbose",
|
||||
"-v",
|
||||
is_flag=True,
|
||||
help="Show detailed breakdown",
|
||||
)
|
||||
@click.option(
|
||||
"--flavor",
|
||||
"-f",
|
||||
type=click.Choice(["pcre", "javascript", "python"]),
|
||||
default=None,
|
||||
help="Regex flavor to use",
|
||||
)
|
||||
@click.pass_context
|
||||
def explain(ctx: click.Context, pattern: str, output: str, verbose: bool, flavor: str):
|
||||
"""Explain a regex pattern in human-readable English."""
|
||||
if ctx.obj is None:
|
||||
ctx.obj = {}
|
||||
flavor = flavor or ctx.obj.get("flavor", "pcre")
|
||||
|
||||
try:
|
||||
ast = parse_regex(pattern, flavor)
|
||||
translation = translate_regex(pattern, flavor)
|
||||
|
||||
if output == "json":
|
||||
result = {
|
||||
"pattern": pattern,
|
||||
"flavor": flavor,
|
||||
"explanation": translation,
|
||||
"verbose": {
|
||||
"node_count": len(get_all_nodes(ast)),
|
||||
"features": identify_features(ast),
|
||||
} if verbose else None,
|
||||
}
|
||||
click.echo(json.dumps(result, indent=2))
|
||||
else:
|
||||
click.echo(f"\nPattern: {pattern}")
|
||||
click.echo(f"Flavor: {flavor}")
|
||||
click.echo("\nEnglish Explanation:")
|
||||
click.echo("-" * 50)
|
||||
click.echo(translation)
|
||||
click.echo()
|
||||
|
||||
if verbose:
|
||||
features = identify_features(ast)
|
||||
click.echo("\nFeatures detected:")
|
||||
for feature in features:
|
||||
click.echo(f" - {feature}")
|
||||
|
||||
except Exception as e:
|
||||
click.echo(f"Error: {e}", err=True)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
@main.command("test")
|
||||
@click.argument("pattern", type=str)
|
||||
@click.option(
|
||||
"--output",
|
||||
"-o",
|
||||
type=click.Choice(["text", "json"]),
|
||||
default="text",
|
||||
help="Output format",
|
||||
)
|
||||
@click.option(
|
||||
"--count",
|
||||
"-n",
|
||||
type=int,
|
||||
default=5,
|
||||
help="Number of test cases to generate",
|
||||
)
|
||||
@click.pass_context
|
||||
def test(ctx: click.Context, pattern: str, output: str, count: int):
|
||||
"""Generate test cases (matching and non-matching) for a regex pattern."""
|
||||
if ctx.obj is None:
|
||||
ctx.obj = {}
|
||||
flavor = ctx.obj.get("flavor", "pcre")
|
||||
|
||||
try:
|
||||
result = generate_test_cases(
|
||||
pattern,
|
||||
flavor,
|
||||
matching_count=count,
|
||||
non_matching_count=count
|
||||
)
|
||||
|
||||
if output == "json":
|
||||
click.echo(json.dumps(result, indent=2))
|
||||
else:
|
||||
click.echo(f"\nPattern: {pattern}")
|
||||
click.echo(f"Flavor: {flavor}")
|
||||
click.echo("\nMatching strings (should match the pattern):")
|
||||
click.echo("-" * 50)
|
||||
for i, s in enumerate(result["matching"], 1):
|
||||
click.echo(f" {i}. {s}")
|
||||
|
||||
click.echo("\nNon-matching strings (should NOT match the pattern):")
|
||||
click.echo("-" * 50)
|
||||
for i, s in enumerate(result["non_matching"], 1):
|
||||
click.echo(f" {i}. {s}")
|
||||
|
||||
click.echo()
|
||||
|
||||
except Exception as e:
|
||||
click.echo(f"Error: {e}", err=True)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
@main.command("interactive")
|
||||
@click.option(
|
||||
"--flavor",
|
||||
"-f",
|
||||
type=click.Choice(["pcre", "javascript", "python"]),
|
||||
default="pcre",
|
||||
help="Regex flavor to use",
|
||||
)
|
||||
@click.pass_context
|
||||
def interactive(ctx: click.Context, flavor: str):
|
||||
"""Start an interactive REPL for exploring regex patterns."""
|
||||
start_interactive_mode(flavor=flavor)
|
||||
|
||||
|
||||
@main.command("flavors")
|
||||
@click.pass_context
|
||||
def flavors(ctx: click.Context):
|
||||
"""List available regex flavors."""
|
||||
manager = get_flavor_manager()
|
||||
flavor_list = manager.list_flavors()
|
||||
|
||||
click.echo("\nAvailable Regex Flavors:")
|
||||
click.echo("-" * 50)
|
||||
for name, desc in flavor_list:
|
||||
click.echo(f"\n {name}:")
|
||||
click.echo(f" {desc}")
|
||||
click.echo()
|
||||
|
||||
|
||||
@main.command("validate")
|
||||
@click.argument("pattern", type=str)
|
||||
@click.option(
|
||||
"--flavor",
|
||||
"-f",
|
||||
type=click.Choice(["pcre", "javascript", "python"]),
|
||||
default=None,
|
||||
help="Specific flavor to validate against",
|
||||
)
|
||||
@click.pass_context
|
||||
def validate(ctx: click.Context, pattern: str, flavor: str):
|
||||
"""Validate a regex pattern."""
|
||||
if ctx.obj is None:
|
||||
ctx.obj = {}
|
||||
check_flavor = flavor or ctx.obj.get("flavor", "pcre")
|
||||
|
||||
try:
|
||||
ast = parse_regex(pattern, check_flavor)
|
||||
click.echo(f"\nPattern: {pattern}")
|
||||
click.echo(f"Flavor: {check_flavor}")
|
||||
click.echo("\nValidation: PASSED")
|
||||
click.echo(f"AST node count: {len(get_all_nodes(ast))}")
|
||||
except Exception as e:
|
||||
click.echo(f"\nPattern: {pattern}")
|
||||
click.echo(f"Validation: FAILED")
|
||||
click.echo(f"Error: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
@main.command("convert")
|
||||
@click.argument("pattern", type=str)
|
||||
@click.option(
|
||||
"--from-flavor",
|
||||
"-s",
|
||||
type=click.Choice(["pcre", "javascript", "python"]),
|
||||
default="pcre",
|
||||
help="Source flavor",
|
||||
)
|
||||
@click.option(
|
||||
"--to-flavor",
|
||||
"-t",
|
||||
type=click.Choice(["pcre", "javascript", "python"]),
|
||||
default="javascript",
|
||||
help="Target flavor",
|
||||
)
|
||||
@click.pass_context
|
||||
def convert(ctx: click.Context, pattern: str, from_flavor: str, to_flavor: str):
|
||||
"""Convert a regex pattern between flavors."""
|
||||
manager = get_flavor_manager()
|
||||
converted, warnings = manager.convert(pattern, from_flavor, to_flavor)
|
||||
|
||||
click.echo(f"\nOriginal ({from_flavor}): {pattern}")
|
||||
click.echo(f"Converted ({to_flavor}): {converted}")
|
||||
|
||||
if warnings:
|
||||
click.echo("\nWarnings:")
|
||||
for warning in warnings:
|
||||
click.echo(f" - {warning}")
|
||||
|
||||
|
||||
def get_all_nodes(ast) -> list:
|
||||
"""Get all nodes from AST."""
|
||||
nodes = [ast]
|
||||
for child in getattr(ast, 'children', []):
|
||||
nodes.extend(get_all_nodes(child))
|
||||
return nodes
|
||||
|
||||
|
||||
def identify_features(ast) -> list[str]:
|
||||
"""Identify features in a regex pattern."""
|
||||
features = []
|
||||
nodes = get_all_nodes(ast)
|
||||
|
||||
node_types = set(n.node_type.name for n in nodes)
|
||||
|
||||
if "LOOKAHEAD" in node_types or "NEGATIVE_LOOKAHEAD" in node_types:
|
||||
features.append("Lookahead assertions")
|
||||
if "LOOKBEHIND" in node_types or "NEGATIVE_LOOKBEHIND" in node_types:
|
||||
features.append("Lookbehind assertions")
|
||||
if "NAMED_GROUP" in node_types:
|
||||
features.append("Named groups")
|
||||
if "CAPTURING_GROUP" in node_types:
|
||||
features.append("Capturing groups")
|
||||
if "NON_CAPTURING_GROUP" in node_types:
|
||||
features.append("Non-capturing groups")
|
||||
if "QUANTIFIER" in node_types:
|
||||
features.append("Quantifiers")
|
||||
for n in nodes:
|
||||
if n.node_type.name == "QUANTIFIER" and n.is_lazy:
|
||||
features.append("Lazy quantifiers")
|
||||
break
|
||||
if n.node_type.name == "QUANTIFIER" and n.is_possessive:
|
||||
features.append("Possessive quantifiers")
|
||||
break
|
||||
if "POSITIVE_SET" in node_types or "NEGATIVE_SET" in node_types:
|
||||
features.append("Character classes")
|
||||
if "ANCHOR_START" in node_types or "ANCHOR_END" in node_types:
|
||||
features.append("Anchors")
|
||||
if "DIGIT" in node_types or "WORD_CHAR" in node_types or "WHITESPACE" in node_types:
|
||||
features.append("Shorthand character classes")
|
||||
if "BACKREFERENCE" in node_types:
|
||||
features.append("Backreferences")
|
||||
|
||||
return features
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user