Fix ANSIString parsing on partial slice from start/end of string. Resolve #2205.

This commit is contained in:
Griatch 2020-10-26 21:35:15 +01:00
parent 9bb0c77a74
commit 5fa7c62687
3 changed files with 111 additions and 39 deletions

View file

@ -1,20 +1,19 @@
""" """
ANSI - Gives colour to text. ANSI - Gives colour to text.
Use the codes defined in ANSIPARSER in your text to apply colour to text Use the codes defined in ANSIPARSER in your text
according to the ANSI standard. to apply colour to text according to the ANSI standard.
::
This is |rRed text|n and this is normal again. Examples:
Mostly you should not need to call `parse_ansi()` explicitly; it is run by ```python
Evennia just before returning data to/from the user. Depreciated/decativated "This is |rRed text|n and this is normal again."
example forms are available in contribs by extending the ansi mapping ```
This module also contains the `ANSIString` custom string-type, which correctly Mostly you should not need to call `parse_ansi()` explicitly;
wraps/manipulates and tracks lengths of strings containing ANSI-markup. it is run by Evennia just before returning data to/from the
user. Depreciated example forms are available by extending
---- the ansi mapping.
""" """
import functools import functools
@ -82,14 +81,16 @@ _COLOR_NO_DEFAULT = settings.COLOR_NO_DEFAULT
class ANSIParser(object): class ANSIParser(object):
""" """
A class that parses ANSI markup to ANSI command sequences A class that parses ANSI markup
to ANSI command sequences
We also allow to escape colour codes by prepending with We also allow to escape colour codes
an extra `|`, so `||r` will literally print `|r`. by prepending with a \ for xterm256,
an extra | for Merc-style codes
""" """
# Mapping using |r, |n etc # Mapping using {r {n etc
ansi_map = [ ansi_map = [
# alternative |-format # alternative |-format
@ -525,6 +526,13 @@ def raw(string):
return string.replace("{", "{{").replace("|", "||") return string.replace("{", "{{").replace("|", "||")
# ------------------------------------------------------------
#
# ANSIString - ANSI-aware string class
#
# ------------------------------------------------------------
def _spacing_preflight(func): def _spacing_preflight(func):
""" """
This wrapper function is used to do some preflight checks on This wrapper function is used to do some preflight checks on
@ -588,9 +596,10 @@ def _on_raw(func_name):
def _transform(func_name): def _transform(func_name):
""" """
Some string functions, like those manipulating capital letters, return a Some string functions, like those manipulating capital letters,
string the same length as the original. This function allows us to do the return a string the same length as the original. This function
same, replacing all the non-coded characters with the resulting string. allows us to do the same, replacing all the non-coded characters
with the resulting string.
""" """
@ -823,7 +832,7 @@ class ANSIString(str, metaclass=ANSIMeta):
""" """
if not offset: if not offset:
return [] return iterable
return [i + offset for i in iterable] return [i + offset for i in iterable]
@classmethod @classmethod
@ -894,9 +903,23 @@ class ANSIString(str, metaclass=ANSIMeta):
replayed. replayed.
""" """
slice_indexes = self._char_indexes[slc] char_indexes = self._char_indexes
slice_indexes = char_indexes[slc]
# If it's the end of the string, we need to append final color codes. # If it's the end of the string, we need to append final color codes.
if not slice_indexes: if not slice_indexes:
# if we find no characters it may be because we are just outside
# of the interval, using an open-ended slice. We must replay all
# of the escape characters until/after this point.
if char_indexes:
if slc.start is None and slc.stop is None:
# a [:] slice of only escape characters
return ANSIString(self._raw_string[slc])
if slc.start is None:
# this is a [:x] slice
return ANSIString(self._raw_string[:char_indexes[0]])
if slc.stop is None:
# a [x:] slice
return ANSIString(self._raw_string[char_indexes[-1] + 1:])
return ANSIString("") return ANSIString("")
try: try:
string = self[slc.start or 0]._raw_string string = self[slc.start or 0]._raw_string
@ -916,7 +939,7 @@ class ANSIString(str, metaclass=ANSIMeta):
# raw_string not long enough # raw_string not long enough
pass pass
if i is not None: if i is not None:
append_tail = self._get_interleving(self._char_indexes.index(i) + 1) append_tail = self._get_interleving(char_indexes.index(i) + 1)
else: else:
append_tail = "" append_tail = ""
return ANSIString(string + append_tail, decoded=True) return ANSIString(string + append_tail, decoded=True)
@ -985,12 +1008,9 @@ class ANSIString(str, metaclass=ANSIMeta):
occurrence of the separator rather than the first. occurrence of the separator rather than the first.
Returns: Returns:
result (tuple): ANSIString: The part of the string before the separator
- prefix (ANSIString): The part of the string before the ANSIString: The separator itself
separator ANSIString: The part of the string after the separator.
- sep (ANSIString): The separator itself
- postfix (ANSIString): The part of the string after the
separator.
""" """
if hasattr(sep, "_clean_string"): if hasattr(sep, "_clean_string"):
@ -1289,27 +1309,23 @@ class ANSIString(str, metaclass=ANSIMeta):
Joins together strings in an iterable, using this string between each Joins together strings in an iterable, using this string between each
one. one.
NOTE: This should always be used for joining strings when ANSIStrings
are involved. Otherwise color information will be discarded by python,
due to details in the C implementation of strings.
Args: Args:
iterable (list of strings): A list of strings to join together iterable (list of strings): A list of strings to join together
Returns: Returns:
result (ANSIString): A single string with all of the iterable's ANSIString: A single string with all of the iterable's
contents concatenated, with this string between each. contents concatenated, with this string between each.
Examples: Examples:
::
ANSIString(', ').join(['up', 'right', 'left', 'down'])
Would return
:: ::
>>> ANSIString(', ').join(['up', 'right', 'left', 'down'])
ANSIString('up, right, left, down') ANSIString('up, right, left, down')
Notes:
This should always be used for joining strings when ANSIStrings are
involved. Otherwise color information will be discarded by python,
due to details in the C implementation of strings.
""" """
result = ANSIString("") result = ANSIString("")
last_item = None last_item = None

View file

@ -163,9 +163,12 @@ def _to_rect(lines):
def _to_ansi(obj, regexable=False): def _to_ansi(obj, regexable=False):
"convert to ANSIString" "convert to ANSIString"
if isinstance(obj, str): if isinstance(obj, ANSIString):
return obj
elif isinstance(obj, str):
# since ansi will be parsed twice (here and in the normal ansi send), we have to # since ansi will be parsed twice (here and in the normal ansi send), we have to
# escape the |-structure twice. # escape the |-structure twice. TODO: This is tied to the default color-tag syntax
# which is not ideal for those wanting to replace/extend it ...
obj = _ANSI_ESCAPE.sub(r"||||", obj) obj = _ANSI_ESCAPE.sub(r"||||", obj)
if isinstance(obj, dict): if isinstance(obj, dict):
return dict((key, _to_ansi(value, regexable=regexable)) for key, value in obj.items()) return dict((key, _to_ansi(value, regexable=regexable)) for key, value in obj.items())

View file

@ -177,6 +177,59 @@ class ANSIStringTestCase(TestCase):
self.assertEqual(a.rstrip(), ANSIString(" |r Test of stuff |b with spaces|n")) self.assertEqual(a.rstrip(), ANSIString(" |r Test of stuff |b with spaces|n"))
self.assertEqual(b.strip(), b) self.assertEqual(b.strip(), b)
def test_regex_search(self):
"""
Test regex-search in ANSIString - the found position should ignore any ansi-markers
"""
string = ANSIString(" |r|[b Test ")
match = re.search(r"Test", string)
self.assertTrue(match)
self.assertEqual(match.span(), (3, 7))
def test_regex_replace(self):
"""
Inserting text into an ansistring at an index position should ignore
the ansi markers but not remove them!
"""
string = ANSIString("A |rTest|n string")
match = re.search(r"Test", string)
ix1, ix2 = match.span()
self.assertEqual((ix1, ix2), (2, 6))
result = string[:ix1] + "Replacement" + string[ix2:]
expected = ANSIString("A |rReplacement|n string")
self.assertEqual(expected, result)
def test_slice_insert(self):
"""
Inserting a slice should not remove ansi markup (issue #2205)
"""
string = ANSIString("|rTest|n")
split_string = string[:0] + "Test" + string[4:]
self.assertEqual(string.raw(), split_string.raw())
def test_slice_insert_longer(self):
"""
The ANSIString replays the color code before the split in order to
produce a *visually* identical result. The result is a longer string in
raw characters, but one which correctly represents the color output.
"""
string = ANSIString("A bigger |rTest|n of things |bwith more color|n")
# from evennia import set_trace;set_trace()
split_string = string[:9] + "Test" + string[13:]
self.assertEqual(
repr((ANSIString("A bigger ")
+ ANSIString("|rTest") # note that the |r|n is replayed together on next line
+ ANSIString("|r|n of things |bwith more color|n")).raw()),
repr(split_string.raw()))
def test_slice_full(self):
string = ANSIString("A bigger |rTest|n of things |bwith more color|n")
split_string = string[:]
self.assertEqual(string.raw(), split_string.raw())
class TestTextToHTMLparser(TestCase): class TestTextToHTMLparser(TestCase):
def setUp(self): def setUp(self):