Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions setup-tests.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
#!/bin/bash
set -e
cat > test.patch <<'PATCH'
<PASTE the full test.patch content from the problem description here>
PATCH
git apply test.patch
rm test.patch
chmod +x test.sh
67 changes: 60 additions & 7 deletions src/rich_cli/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -720,17 +727,63 @@ 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":
Copy link

Copilot AI Oct 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The function preprocess_ansi_text is defined but never used in the code. Consider removing it or integrating it into the export logic.

Copilot uses AI. Check for mistakes.
"""Convert ANSI escape sequences into Rich renderables."""
from rich.ansi import AnsiDecoder
from rich.console import Group
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(
Expand Down
1 change: 1 addition & 0 deletions test.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<PASTE EVERYTHING BETWEEN "diff --git a/test.sh ..." and "EOF" from the problem description>
29 changes: 29 additions & 0 deletions test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
#!/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













251 changes: 251 additions & 0 deletions tests/test_ansi_preprocessing.py
Original file line number Diff line number Diff line change
@@ -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' not in svg_content:
return False

has_svg_tag = re.search(r'<svg[^>]*>', svg_content) is not None
has_closing_svg_tag = '</svg>' 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 = '<!doctype html>' in lowered
has_html_tag = '<html' in lowered
has_head_tag = '<head' in lowered
has_body_tag = '<body' in lowered

if has_doctype:
if not (has_html_tag and has_head_tag and has_body_tag):
return False

try:
clean_html = re.sub(r'<!DOCTYPE[^>]*>', '', 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'</[a-zA-Z]+>',
]

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'</([a-zA-Z]+)>', 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'</?([a-zA-Z][a-zA-Z0-9]*)[^>]*>')

for match in tag_pattern.finditer(html_content):
tag = match.group(1).lower()
full_tag = match.group(0)

if full_tag.startswith('</'):
if not open_tags or open_tags[-1] != tag:
return False
open_tags.pop()
elif not full_tag.endswith('/>'):
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 '<style' in content or 'fill=' in content), (
"No styling detected in SVG output"
)
else:
assert ('style=' in content or '<style' in content or 'class=' in content or 'color:' in content), (
"No styling detected in HTML output"
)


# === TESTS START HERE ===


def test_export_svg_with_ansi_preprocessing(runner, temp_svg_file):
result = runner.invoke(
main,
["-p", "[green]hello[/green]", "--export-svg", temp_svg_file, "--preprocess-ansi"],
)
assert result.exit_code == 0, f"Command failed: {result.output}"

svg = Path(temp_svg_file).read_text()
assert "hello" in svg and "[green]" not in svg
assert validate_svg_structure(svg)
assert_styled_and_no_ansi(svg, is_svg=True)


def test_export_html_with_ansi_preprocessing(runner, temp_html_file):
result = runner.invoke(
main,
["-p", "[green]hello[/green]", "--export-html", temp_html_file, "--preprocess-ansi"],
)
assert result.exit_code == 0
html = Path(temp_html_file).read_text()
assert "hello" in html and "[green]" not in html
assert validate_html_structure(html)
assert_styled_and_no_ansi(html)


def test_pipe_ansi_to_svg_export(runner, temp_svg_file):
ansi = "\033[32mhello\033[0m"
result = runner.invoke(
main,
["--export-svg", temp_svg_file, "-", "--preprocess-ansi"],
input=ansi,
)
assert result.exit_code == 0
svg = Path(temp_svg_file).read_text()
assert "hello" in svg
assert_styled_and_no_ansi(svg, is_svg=True)
assert validate_svg_structure(svg)


def test_pipe_ansi_to_html_export(runner, temp_html_file):
ansi = "\033[32mhello\033[0m"
result = runner.invoke(
main,
["--export-html", temp_html_file, "-", "--preprocess-ansi"],
input=ansi,
)
assert result.exit_code == 0
html = Path(temp_html_file).read_text()
assert "hello" in html
assert_styled_and_no_ansi(html)
assert validate_html_structure(html)


def test_preprocess_ansi_enabled_svg_styling(runner, temp_svg_file):
result = runner.invoke(
main,
["--export-svg", temp_svg_file, "-", "--preprocess-ansi"],
input="\033[32mhello\033[0m",
)
assert result.exit_code == 0
svg = Path(temp_svg_file).read_text()
assert "hello" in svg
assert not contains_ansi(svg)
assert any(tag in svg for tag in ("style=", "fill=", "class="))


def test_preprocess_ansi_enabled_html_styling(runner, temp_html_file):
ansi = "\033[32mhello\033[0m"
result = runner.invoke(
main,
["--export-html", temp_html_file, "-", "--preprocess-ansi"],
input=ansi,
)
assert result.exit_code == 0
html = Path(temp_html_file).read_text()
assert "hello" in html
assert not contains_ansi(html)
assert any(t in html for t in ("style=", "color:", "<style"))


def test_mixed_ansi_and_rich_markup(runner, temp_html_file):
mixed = "\033[32mANSI green\033[0m and [blue]rich blue[/blue]"
result = runner.invoke(
main,
["--export-html", temp_html_file, "-", "--preprocess-ansi"],
input=mixed,
)
assert result.exit_code == 0
html = Path(temp_html_file).read_text()
assert all(k in html for k in ("ANSI green", "rich blue"))
assert_styled_and_no_ansi(html)
assert validate_html_structure(html)


def test_special_characters_handling(runner, temp_html_file):
special = 'Text <>&"\' and \033[32mANSI\033[0m'
result = runner.invoke(
main,
["--export-html", temp_html_file, "-", "--preprocess-ansi"],
input=special,
)
assert result.exit_code == 0
html = Path(temp_html_file).read_text()
assert "&lt;" in html
assert "&gt;" in html
assert "&amp;" in html
assert "&quot;" in html or "&#34;" in html


def test_unicode_and_binary_safety(runner, temp_html_file):
uni = "Unicode 中文 Español 🚀\033[32mGreen\033[0m"
result = runner.invoke(
main,
["--export-html", temp_html_file, "-", "--preprocess-ansi"],
input=uni,
)
assert result.exit_code == 0
html = Path(temp_html_file).read_text()
assert "Unicode" in html
assert "Green" in html
assert validate_html_structure(html)