Skip to content

Commit 01e467d

Browse files
committed
Keep spec-derived TUI text out of the Textual markup parser
Substate lines interpolated arbitrary text (acceptance-test text from the spec, module names) into a markup string. Textual treats every unescaped "[" as a tag opener, so a spec line such as "ZLEXCOUNT z [b + should reply 3." swallowed the timer's "[#888888]" tag, the closing "[/#888888]" had no match, and the whole TUI crashed with MarkupError. Terminated brackets ("LPOP key [count]") were silently dropped from the display as well. Textual's escape() does not cover unterminated brackets, so build the line as Content and style only the timer span instead. Also render the other widgets that show paths or spec text (progress descriptions, module/functionality rows, test-script rows, render id, success/error status) with markup disabled or as Content.
1 parent 21ae4ce commit 01e467d

3 files changed

Lines changed: 112 additions & 13 deletions

File tree

‎tests/test_tui_components.py‎

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
"""Regression tests: spec-derived text with square brackets must never be parsed as Textual markup."""
2+
3+
import asyncio
4+
5+
from textual.widgets import Static
6+
7+
from event_bus import EventBus
8+
from plain2code_state import RunState
9+
from tui.components import ProgressItem, SubstateLine, TUIComponents
10+
from tui.models import Substate
11+
from tui.plain2code_tui import Plain2CodeTUI
12+
from tui.widget_helpers import display_error_message, display_success_message, update_progress_item_substates
13+
14+
# Exact acceptance-test text that crashed the TUI with
15+
# "MarkupError: closing tag '[/#888888]' does not match any open tag":
16+
# the unterminated "[b" swallowed the timer's opening tag.
17+
UNTERMINATED_BRACKET_TEXT = (
18+
"Rendering acceptance test: With a=1, b=2, c=3, d=4: ZRANK z c should reply 2, "
19+
'ZREVRANK z c WITHSCORE should reply [1, "3"], ZRANK z zz should reply null, '
20+
"ZCOUNT z (1 3 should reply 2, and ZLEXCOUNT z [b + should reply 3."
21+
)
22+
# Terminated brackets used to be silently eaten as a markup tag ("LPOP key ").
23+
TERMINATED_BRACKET_TEXT = "LPOP key [count] and RPOP key [count] reply one element"
24+
25+
26+
def _make_app(run_state: RunState, event_bus: EventBus) -> Plain2CodeTUI:
27+
return Plain2CodeTUI(
28+
event_bus=event_bus,
29+
run_state=run_state,
30+
on_ready=lambda: None,
31+
render_id="test-render-id",
32+
unittests_script=None,
33+
conformance_tests_script="run_conformance_tests.sh",
34+
prepare_environment_script=None,
35+
state_machine_version="0.0.0",
36+
css_path="styles.css",
37+
)
38+
39+
40+
def test_substate_line_keeps_bracket_text_verbatim():
41+
for text in (UNTERMINATED_BRACKET_TEXT, TERMINATED_BRACKET_TEXT):
42+
line = SubstateLine(text, " ", ProgressItem.PROCESSING)._format_line()
43+
assert text in line.plain
44+
assert line.plain.endswith("(0s)")
45+
# Only the timer carries the muted style; the text itself is unstyled.
46+
assert len(line.spans) == 1
47+
assert line.plain[line.spans[0].start : line.spans[0].end] == "(0s)"
48+
49+
50+
def test_substate_with_brackets_renders_without_markup_error():
51+
async def scenario():
52+
event_bus = EventBus()
53+
run_state = RunState(spec_filename="x.plain")
54+
app = _make_app(run_state, event_bus)
55+
async with app.run_test() as pilot:
56+
widget_id = TUIComponents.FRID_PROGRESS_CONFORMANCE_TEST.value
57+
update_progress_item_substates(
58+
app, widget_id, [Substate(UNTERMINATED_BRACKET_TEXT), Substate(TERMINATED_BRACKET_TEXT)]
59+
)
60+
await pilot.pause()
61+
await pilot.pause()
62+
63+
lines = app.query(".substate-line-text").results(Static)
64+
rendered = [str(line.content) for line in lines]
65+
assert any(UNTERMINATED_BRACKET_TEXT in text for text in rendered)
66+
assert any(TERMINATED_BRACKET_TEXT in text for text in rendered)
67+
68+
asyncio.run(scenario())
69+
70+
71+
def test_status_messages_keep_brackets():
72+
async def scenario():
73+
event_bus = EventBus()
74+
run_state = RunState(spec_filename="x.plain")
75+
app = _make_app(run_state, event_bus)
76+
async with app.run_test() as pilot:
77+
status = app.query_one(f"#{TUIComponents.RENDER_STATUS_WIDGET.value}", Static)
78+
79+
display_success_message(app, "out/[weird] path")
80+
await pilot.pause()
81+
assert "rendering completed!" in str(status.content)
82+
assert "generated code folder: out/[weird] path" in str(status.content)
83+
84+
display_error_message(app, "Error: [Errno 2] No such file: run_tests.sh [b")
85+
await pilot.pause()
86+
assert "[Errno 2] No such file: run_tests.sh [b" in str(status.content)
87+
88+
asyncio.run(scenario())

‎tui/components.py‎

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
from rich.markup import escape
55
from textual.containers import Horizontal, Vertical, VerticalScroll
6+
from textual.content import Content
67
from textual.message import Message
78
from textual.timer import Timer
89
from textual.widgets import Button, Static
@@ -71,7 +72,7 @@ def compose(self):
7172
self._footer_text_widget = Static(footer_rendering_text, classes="custom-footer-text")
7273
yield self._footer_text_widget
7374
if self.render_id:
74-
yield Static(f"render id: {self.render_id} ", classes="custom-footer-render-id")
75+
yield Static(f"render id: {self.render_id} ", classes="custom-footer-render-id", markup=False)
7576

7677
def update_footer_state(self, state: Literal["rendering", "pausing", "paused", "finished"]) -> None:
7778
self.remove_class("footer-state-default")
@@ -201,9 +202,12 @@ def _add_second(self) -> None:
201202
def _format_timer(self) -> str:
202203
return format_duration_hms(self._seconds_elapsed)
203204

204-
def _format_line(self) -> str:
205+
def _format_line(self) -> Content:
206+
# The substate text is arbitrary (acceptance-test text from the spec, module names, ...)
207+
# and may contain square brackets, so it must never go through the markup parser.
208+
# Build the line as Content and style only the timer part.
205209
timer = self._format_timer()
206-
return f"{self.indent} └ {self.text} [#888888]({timer})[/#888888]"
210+
return Content.assemble(f"{self.indent} └ {self.text} ", (f"({timer})", "#888888"))
207211

208212
def _refresh_timer(self) -> None:
209213
try:
@@ -232,7 +236,7 @@ def compose(self):
232236
# Main row with status and description
233237
with Horizontal(id=f"{self.id}-main-row", classes="progress-main-row"):
234238
yield Static(self._get_status_text(self.PENDING), classes=f"status {self.PENDING}")
235-
yield Static(self.initial_text, classes="description")
239+
yield Static(self.initial_text, classes="description", markup=False)
236240
# Substates container (full width, aligned to left)
237241
yield Vertical(id=f"{self.id}-substates", classes="substates-container")
238242

@@ -367,8 +371,8 @@ def on_mount(self) -> None:
367371
self._refresh_content()
368372

369373
def compose(self):
370-
self.module_widget = Static(self.module_text, classes="rendering-info-row")
371-
self.functionality_widget = Static(self.functionality_text, classes="rendering-info-row")
374+
self.module_widget = Static(self.module_text, classes="rendering-info-row", markup=False)
375+
self.functionality_widget = Static(self.functionality_text, classes="rendering-info-row", markup=False)
372376
yield Static("module status", classes="rendering-info-title")
373377
with Vertical(classes="rendering-info-box"):
374378
yield self.module_widget
@@ -430,9 +434,9 @@ def on_mount(self) -> None:
430434
def compose(self):
431435
yield Static("testing status", classes="test-scripts-title")
432436
with Vertical(classes="test-scripts-box"):
433-
self.unit_widget = Static(self.unit_test_text, classes="test-script-row")
434-
self.conformance_widget = Static(self.conformance_test_text, classes="test-script-row")
435-
self.testing_widget = Static(self.testing_env_text, classes="test-script-row")
437+
self.unit_widget = Static(self.unit_test_text, classes="test-script-row", markup=False)
438+
self.conformance_widget = Static(self.conformance_test_text, classes="test-script-row", markup=False)
439+
self.testing_widget = Static(self.testing_env_text, classes="test-script-row", markup=False)
436440
yield self.unit_widget
437441
yield self.conformance_widget
438442
yield self.testing_widget

‎tui/widget_helpers.py‎

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from datetime import datetime
44

5+
from textual.content import Content
56
from textual.css.query import NoMatches
67
from textual.widgets import Static
78

@@ -89,9 +90,14 @@ def display_success_message(tui, rendered_code_path: str):
8990
rendered_code_path: The path to the rendered code
9091
"""
9192

92-
message = (
93-
f"[#79FC96]✓ rendering completed![/#79FC96] [#888888](press enter to exit)[/#888888]\n"
94-
f"[#888888]generated code folder: {rendered_code_path}[/#888888] "
93+
# rendered_code_path is user-controlled and may contain square brackets, so build the
94+
# message as Content instead of interpolating it into a markup string.
95+
message = Content.assemble(
96+
("✓ rendering completed!", "#79FC96"),
97+
" ",
98+
("(press enter to exit)", "#888888"),
99+
"\n",
100+
(f"generated code folder: {rendered_code_path} ", "#888888"),
95101
)
96102

97103
widget: Static = tui.query_one(f"#{TUIComponents.RENDER_STATUS_WIDGET.value}", Static)
@@ -120,7 +126,8 @@ def transition_frid_progress(tui, from_status: str | None, to_status: str):
120126
def display_error_message(tui, error_message: str):
121127
widget: Static = tui.query_one(f"#{TUIComponents.RENDER_STATUS_WIDGET.value}", Static)
122128
widget.add_class("error")
123-
widget.update(error_message)
129+
# Error text may contain square brackets (paths, tracebacks); keep it out of the markup parser.
130+
widget.update(Content(error_message))
124131

125132

126133
def display_usage_summary(tui, functionalities: int, render_time_seconds: float) -> None:

0 commit comments

Comments
 (0)