diff --git a/setup-tests.sh b/setup-tests.sh new file mode 100644 index 0000000..c57b927 --- /dev/null +++ b/setup-tests.sh @@ -0,0 +1,3 @@ +#!/bin/bash +set -e +chmod +x test.sh diff --git a/src/rich_cli/__main__.py b/src/rich_cli/__main__.py index 98df6bb..9f0cbd2 100644 --- a/src/rich_cli/__main__.py +++ b/src/rich_cli/__main__.py @@ -395,6 +395,12 @@ class OptionHighlighter(RegexHighlighter): @click.option( "--export-svg", metavar="PATH", default="", help="Write SVG to [b]PATH[/b]." ) +@click.option( + "--preprocess-ansi", + is_flag=True, + help="Interpret and convert ANSI escape sequences before exporting HTML or SVG.", +) + @click.option("--pager", is_flag=True, help="Display in an interactive pager.") @click.option("--version", "-v", is_flag=True, help="Print version and exit.") def main( @@ -440,6 +446,7 @@ def main( force_terminal: bool = False, export_html: str = "", export_svg: str = "", + preprocess_ansi: bool = False, pager: bool = False, ): """Rich toolbox for console output.""" @@ -720,17 +727,61 @@ def print_usage() -> None: except Exception as error: on_error("failed to print resource", error) - if export_html: + from rich.ansi import AnsiDecoder + from rich.console import Group + + def preprocess_ansi_text(text: str) -> "RenderableType": + """Convert ANSI escape sequences into Rich renderables.""" + from rich.text import Text + try: - console.save_html(export_html, clear=False) - except Exception as error: - on_error("failed to save HTML", error) + decoder = AnsiDecoder() + renderables = [] + for part in decoder.decode(text): + if isinstance(part, str): + renderables.append(Text(part)) + else: + renderables.append(part) + if not renderables: + return Text(text) + return Group(*renderables) + except Exception: + return Text(text) - if export_svg: + # --- Export handling with optional ANSI preprocessing --- + if export_html or export_svg: try: - console.save_svg(export_svg, clear=False) + if preprocess_ansi: + # Capture what was printed so far + # Capture everything the console has rendered so far as ANSI text + ansi_data = console.export_text(clear=False) + decoder = AnsiDecoder() + renderables = list(decoder.decode(ansi_data)) + + # Make sure everything is a valid Rich renderable + final_renderables = [] + for item in renderables: + if isinstance(item, str): + final_renderables.append(Text(item)) + else: + final_renderables.append(item) + + # Combine and re-render + temp_console = Console(record=True) + temp_console.print(Group(*final_renderables)) + + + if export_html: + temp_console.save_html(export_html, clear=False) + if export_svg: + temp_console.save_svg(export_svg, clear=False) + else: + if export_html: + console.save_html(export_html, clear=False) + if export_svg: + console.save_svg(export_svg, clear=False) except Exception as error: - on_error("failed to save SVG", error) + on_error("failed to save export", error) def render_csv( diff --git a/test.patch b/test.patch new file mode 100644 index 0000000..73f9260 --- /dev/null +++ b/test.patch @@ -0,0 +1 @@ +# (Delete the file entirely; no replacement lines) diff --git a/test.sh b/test.sh new file mode 100755 index 0000000..d2701b2 --- /dev/null +++ b/test.sh @@ -0,0 +1,16 @@ +#!/bin/bash + +set -e + +if [ "$1" == "base" ]; then + echo "Running base tests..." + python -m pytest tests/ -k "not ansi_preprocessing" +elif [ "$1" == "new" ]; then + echo "Running new tests..." + python -m pytest tests/test_ansi_preprocessing.py -v +else + echo "Usage: ./test.sh [base|new]" + echo " base - Run existing tests (excluding ANSI preprocessing tests)" + echo " new - Run ANSI preprocessing tests" + exit 1 +fi diff --git a/tests/test_ansi_preprocessing.py b/tests/test_ansi_preprocessing.py new file mode 100644 index 0000000..4c8b70c --- /dev/null +++ b/tests/test_ansi_preprocessing.py @@ -0,0 +1,251 @@ +import re +from pathlib import Path +from click.testing import CliRunner +import xml.etree.ElementTree as ET +import pytest + +from rich_cli.__main__ import main + +ANSI_ESCAPE_RE = re.compile(r'\x1b\[[0-9;?]*[A-Za-z]') + + +def contains_ansi(s: str) -> bool: + return bool(ANSI_ESCAPE_RE.search(s)) + + +def validate_svg_structure(svg_content: str) -> bool: + """Validate SVG structure using XML parsing.""" + try: + ET.fromstring(svg_content) + + if ']*>', svg_content) is not None + has_closing_svg_tag = '' in svg_content + + return has_svg_tag and has_closing_svg_tag + except ET.ParseError: + return False + + +def validate_html_structure(html_content: str) -> bool: + """Validate HTML structure using robust parsing that handles real-world HTML output.""" + try: + lowered = html_content.lower() + + has_doctype = '' in lowered + has_html_tag = ']*>', '', html_content, flags=re.IGNORECASE) + ET.fromstring(clean_html) + return True + except ET.ParseError: + return validate_html_tag_balance(html_content) + else: + return validate_html_tag_balance(html_content) + + except Exception: + return basic_html_sanity_check(html_content) + + +def basic_html_sanity_check(html_content: str) -> bool: + """Basic sanity check for HTML content without strict parsing.""" + patterns = [ + r'<[a-zA-Z][^>]*>', + r'', + ] + + has_tags = any(re.search(pattern, html_content) for pattern in patterns) + + open_tags = len(re.findall(r'<([a-zA-Z]+)(?:\s[^>]*)?>', html_content)) + close_tags = len(re.findall(r'', html_content)) + + reasonable_balance = abs(open_tags - close_tags) < 5 + + return has_tags and reasonable_balance + + +def validate_html_tag_balance(html_content: str) -> bool: + """Validate that HTML tags are properly balanced.""" + try: + open_tags = [] + tag_pattern = re.compile(r']*>') + + for match in tag_pattern.finditer(html_content): + tag = match.group(1).lower() + full_tag = match.group(0) + + if full_tag.startswith(''): + if tag not in ('br', 'hr', 'img', 'input', 'meta', 'link', '!doctype'): + open_tags.append(tag) + + return len(open_tags) == 0 + except Exception: + return False + + +@pytest.fixture +def runner(): + return CliRunner() + + +@pytest.fixture +def temp_svg_file(tmp_path): + return str(tmp_path / "test.svg") + + +@pytest.fixture +def temp_html_file(tmp_path): + return str(tmp_path / "test.html") + + +def assert_styled_and_no_ansi(content: str, *, is_svg: bool = False): + """Assert that content contains styling and no raw ANSI.""" + assert not contains_ansi(content), "Found raw ANSI escape sequences in output" + if is_svg: + assert ('style=' in content or '