diff --git a/regex_humanizer/cli.py b/regex_humanizer/cli.py new file mode 100644 index 0000000..e161842 --- /dev/null +++ b/regex_humanizer/cli.py @@ -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()