Source code for parce.mutablestring

# -*- coding: utf-8 -*-
#
# This file is part of the parce Python package.
#
# Copyright © 2019-2020 by Wilbert Berendsen <info@wilbertberendsen.nl>
#
# This module is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This module is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <https://www.gnu.org/licenses/>.


"""
The AbstractMutableString is the base of a parce Document.

It is a text string that is mutable via item and slice methods, the += operator
and some methods like ``insert()`` and ``append()``.

If you make modifications while inside a context (using the Python context
manager protocol), the modifications (that may not overlap then) are only
applied when the context exits for the last time.

"""


import collections
import reprlib


[docs]class AbstractMutableString: """Abstract base class of a MutableString. Defines the interface for interacting with the mutable string. The only thing to implement is where the contents are actually stored, which is an implementation detail. To make a mutable string object work, you should at least implement: * ``text()`` which should return the full text as a string. * ``_update_text(changes)`` to update the text according to the changes. Those changes are a sorted iterable of (start, end, text) tuples, and will never overlap. All start/end positions refer to the original state of the text. For efficiency reasons, you might want to reimplement: * ``set_text()`` to replace all text in one go * ``__len__()`` to get the length of the text * ``_get_text()`` called by ``__getitem__`` to get a slice or single character """ def __init__(self, text=""): self._edit_context = 0 self._changes = collections.defaultdict(list)
[docs] def text(self): """Should return the text contents.""" raise NotImplementedError
[docs] def set_text(self, text): """Set the text contents.""" if self._edit_context != 0: raise RuntimeError("can't use set_text() in edit context.") self[:] = text
def __repr__(self): text = reprlib.repr(self.text()) return "<{} {}>".format(type(self).__name__, text)
[docs] def __str__(self): """Return the text contents.""" return self.text()
def __len__(self): """Return the length of the text.""" return len(self.text()) def __enter__(self): """Start the context for modifying the document.""" self._edit_context += 1 return self def __exit__(self, exc_type, exc_val, exc_tb): """Exit the context for modifying.""" if exc_type is not None: # cancel all edits when an exception occurred self._edit_context = 0 self._changes.clear() elif self._edit_context > 1: self._edit_context -= 1 else: self._edit_context = 0 self._apply_changes() def __iadd__(self, text): """Implement the += operator.""" self.append(text) return self def __add__(self, text): """Implement the + operator. Returns a new, plain str instance.""" return self.text() + text def __radd__(self, text): """Implement the + operator. Returns a new, plain str instance.""" return text + self.text()
[docs] def __setitem__(self, key, text): """Replace the position or slice with text.""" start, end = self._parse_key(key) if ((text or start != end) and (end - start != len(text) or self[start:end] != text)): self._changes[start].append((end, text)) if not self._edit_context: self._apply_changes()
[docs] def __delitem__(self, key): """Delete the chracter or slice of text.""" self[key] = ""
[docs] def __getitem__(self, key): """Get a character or a slice of text.""" start, end = self._parse_key(key) if start == end: return "" elif start == 0 and end == len(self): return self.text() return self._get_text(start, end)
def _parse_key(self, key): """Get start and end values from key. Called by __[gs]etitem__.""" total = len(self) if isinstance(key, slice): start, end, _ = key.indices(total) else: # single integer if key < 0: key += total if 0 <= key < total: return key, key + 1 raise IndexError("index out of range") if end < start: end = start return start, end def _get_text(self, start, end): """Return the selected range of the text. Called by __getitem__(), only if a fragment was requested. """ return self.text()[start:end] def _apply_changes(self): """(Internal.) Check, sort and apply the changes.""" if self._changes: changes = list(self._get_changes()) head = old = changes[0][0] added = 0 for start, end, text in changes: added += start - old + len(text) old = end self._update_text(changes) self._changes.clear() self.text_changed(head, end - head, added) def _get_changes(self): """(Internal.) Yield the changes. Every change is a three-tuple(start, end, text). Overlapping changes are signalled and raise a RuntimeError. """ positions = sorted(self._changes) end = positions[0] for start in positions: c = self._changes[start] text = ''.join(text for end, text in c) if start < end: raise RuntimeError("overlapping changes: {} before {}; text={}".format(start, end, reprlib.repr(text))) end = max(end for end, text in c) yield start, end, text
[docs] def _update_text(self, changes): """Called to apply the changes to the text. The changes is a sorted list of (start, end, text) tuples. """ raise NotImplementedError
[docs] def append(self, text): """Append text at the end of the document.""" self.insert(len(self), text)
[docs] def insert(self, pos, text): """Insert text at pos.""" self[pos:pos] = text
[docs] def text_changed(self, position, removed, added): """Called after ``_update_text()``. The default implementation does nothing.""" pass
[docs]class MutableString(AbstractMutableString): """A Mutable string, storing the string contents in an internal attribute.""" def __init__(self, text=""): super().__init__() self._text = text
[docs] def text(self): """Return the text.""" return self._text
[docs] def _update_text(self, changes): """Apply the changes to the text.""" def generate_text(): tail = 0 for start, end, text in changes: if start > tail: yield self._text[tail:start] if text: yield text tail = end yield self._text[tail:] self._text = "".join(generate_text())