blob: 362fe5c0d2b646f9c1836659864985ea663f17e1 [file] [log] [blame]
# Copyright 2023 The Fuchsia Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
import io
import re
_UP_REGEX = re.compile(r"\[(\d+)A")
class Terminal:
"""Terminal simulator for testing.
This class can be set as stdout to simulate a terminal. It implements only
the bare minimum ANSI escape codes needed to test termout, as follows:
- Style commands; ignored.
- Cursor up, with arbitrary offset.
- Clear remainder of screen, available only when at the beginning of a line.
- Next line, valid only as \\n.
Attributes:
lines (List[str]): The list of lines that would show on a terminal.
"""
def __init__(self, width: int):
self._width = width
self.lines: list[str] = [""]
self._line_position: int = 0
self._current_write = io.StringIO()
def write(self, value: str) -> int:
"""Implementation of io write.
Args:
value (str): Value to write.
Returns:
int: Number of bytes written.
"""
return self._current_write.write(value)
def writelines(self, value: list[str]) -> None:
"""Implementation of io writelines.
Args:
value (List[str]): Value to write.
"""
self.write("".join(value))
def flush(self) -> None:
"""Implementation of io flush.
To ensure that the library flushes appropriately, the lines field
is updated only on flush.
"""
chars = [ch for ch in self._current_write.getvalue()]
chars.reverse()
# Determine if we know that we are at the beginning of a line.
at_front = True
while chars:
ch = chars.pop()
if ch == "\n":
self._line_position += 1
while len(self.lines) <= self._line_position:
self.lines.append("")
elif ch == "\r":
at_front = True
elif ch == "\x1B":
# Handle escape sequences.
next_ch = chars.pop()
code = ""
while next_ch and not next_ch.isalpha():
code += next_ch
next_ch = chars.pop()
if next_ch:
code += next_ch
if not code:
raise TerminalError("Empty escape code")
if code[-1] == "m":
# Color style, skip
pass
elif (m := _UP_REGEX.match(code)) is not None:
# Go up N lines
value = int(m.group(1))
if value == 0:
raise TerminalError(
"It is invalid to go up 0 lines in the terminal"
)
self._line_position -= value
elif code == "[0J":
if not at_front:
raise TerminalError(
"Can only erase to end of screen if we are at the front of a line."
)
# Clear to end of screen
for l in range(self._line_position, len(self.lines)):
self.lines[l] = ""
else:
raise TerminalError(f"Unknown escape code \\x1B{code}")
else:
at_front = False
if len(self.lines[self._line_position]) >= self._width:
# Simulate a newline to go to the next line.
chars.append(ch)
chars.append("\n")
else:
self.lines[self._line_position] += ch
self._current_write = io.StringIO()
class TerminalError(Exception):
"""There was an error simulating a terminal."""