Home > Net >  Text Manipulator: String position movement
Text Manipulator: String position movement

Time:02-15

The task is to build to a text manipulator: A program that simulates a set of text manipulation commands.Given an input piece of text and a string of commands, output the mutated input text and cursor position.

Starting simple:

Commands

 h: move cursor one character to the left
 l: move cursor one character to the right
 r<c>: replace character under cursor with <c>

Repeating commands

# All commands can be repeated N times by prefixing them with a number.
# 
# [N]h: move cursor N characters to the left
# [N]l: move cursor N characters to the right
# [N]r<c>: replace N characters, starting from the cursor, with <c> and move the cursor

Examples

# We'll use Hello World as our input text for all cases:
# 
#  Input: hhlhllhlhhll
# Output: Hello World
#           _
#           2
# 
#  Input: rhllllllrw
# Output: hello world
#               _
#               6
# 
#  Input: rh6l9l4hrw
# Output: hello world
#               _
#               6
# 
#  Input: 9lrL7h2rL
# Output: HeLLo WorLd
#            _
#            3
# 
#  Input: 999999999999999999999999999lr0
# Output: Hello Worl0
#                   _
#                  10
# 
#  Input: 999rsom
# Output: sssssssssss
#                   _
#                  10

I have written the following piece of code, but getting an error:

class Editor():
    def __init__(self, text):
        self.text = text
        self.pos = 0

    def f(self, step):
        self.pos  = int(step)

    def b(self, step):
        self.pos -= int(step)

    def r(self, char):
        s = list(self.text)
        s[self.pos] = char
        self.text = ''.join(s)

    def run(self, command):
        command = list(command)
        # if command not in ('F','B', 'R'):
        #
        while command:
            operation = command.pop(0).lower()
            if operation not in ('f','b','r'):
                raise ValueError('command not recognized.')
            method = getattr(self, operation)
            arg = command.pop(0)
            method(arg)

    def __str__(self):
        return self.text

# Normal run
text = 'abcdefghijklmn'
command = 'F2B1F5Rw'

ed = Editor(text)
ed.run(command)
print(ed)

I have used 'F' and 'B' in my code instead of 'h' and 'l', but the problem is I am missing a piece that allows me to define the optional 'N'. My code only works if there is a number defined after an operation. How can I fix the code above to meet all the requirements?

CodePudding user response:

@paddy gave you a good advice, but looking at the string you need to parse, it seems to me that a regular expression would do the job quite easily. For the part after parsing, a Command pattern fits pretty well. Afterall you have a list of operations (commands) that must be executed over an initial string.

In your case using this pattern brings mainly 3 advantages in my opinion:

  • Each Command represents an operation applied to the initial string. This also means that, for example, in case you want to add a shortcut for a sequence of operations, the number of final Commands stays the same and you only have to adjust the parsing step. Another benefit is that you can have a history of commands and in general the design is much more flexible.

  • All Commands share a common interface: a method execute() and, if necessary, a method unexecute() to undo the changes applied by the execute() method.

  • Commands decouple the operations execution from the parsing problem.


As for the implementation, first you define Commands, that do not contain any business logic except for the call to the receiver method.

from __future__ import annotations
import functools
import re
import abc
from typing import Iterable

class ICommand(abc.ABC):
    @abc.abstractmethod
    def __init__(self, target: TextManipulator):
        self._target = target

    @abc.abstractmethod
    def execute(self):
        pass

class MoveCursorLeftCommand(ICommand):
    def __init__(self, target: TextManipulator, counter):
        super().__init__(target)
        self._counter = counter

    def execute(self):
        self._target.move_cursor_left(self._counter)

class MoveCursorRightCommand(ICommand):
    def __init__(self, target: TextManipulator, counter):
        super().__init__(target)
        self._counter = counter

    def execute(self):
        self._target.move_cursor_right(self._counter)

class ReplaceCommand(ICommand):
    def __init__(self, target: TextManipulator, counter, replacement):
        super().__init__(target)
        self._replacement = replacement
        self._counter = counter

    def execute(self):
        self._target.replace_char(self._counter, self._replacement)

Then you have the receiver of commands, which is the TextManipulator and contains methods to change the text and the cursor position.

class TextManipulator:
    """
    >>> def apply_commands(s, commands_str): 
    ...     return TextManipulator(s).run_commands(CommandParser.parse(commands_str))
    >>> apply_commands('Hello World', 'hhlhllhlhhll')
    ('Hello World', 2)
    >>> apply_commands('Hello World', 'rhllllllrw')
    ('hello world', 6)
    >>> apply_commands('Hello World', 'rh6l9l4hrw')
    ('hello world', 6)
    >>> apply_commands('Hello World', '9lrL7h2rL')
    ('HeLLo WorLd', 3)
    >>> apply_commands('Hello World', '999999999999999999999999999lr0')
    ('Hello Worl0', 10)
    >>> apply_commands('Hello World', '999rsom')
    Traceback (most recent call last):
    ValueError: command 'o' not recognized.
    >>> apply_commands('Hello World', '7l5r1')
    ('Hello W1111', 10)
    >>> apply_commands('Hello World', '7l4r1')
    ('Hello W1111', 10)
    >>> apply_commands('Hello World', '7l3r1')
    ('Hello W111d', 9)
    """
    def __init__(self, text):
        self._text = text
        self._cursor_pos = 0

    def replace_char(self, counter, replacement):
        assert len(replacement) == 1
        assert counter >= 0
        self._text = self._text[0:self._cursor_pos]   \
            replacement * min(counter, len(self._text) - self._cursor_pos)   \
            self._text[self._cursor_pos   counter:]

        self.move_cursor_right(counter - 1)

    def move_cursor_left(self, counter):
        assert counter >= 0
        self._cursor_pos = max(0, self._cursor_pos - counter)

    def move_cursor_right(self, counter):
        assert counter >= 0
        self._cursor_pos = min(len(self._text) - 1, self._cursor_pos   counter)

    def run_commands(self, commands: Iterable[ICommand]):
        for cmd in map(lambda partial_cmd: partial_cmd(target=self), commands):
            cmd.execute()

        return (self._text, self._cursor_pos)

Nothing really hard to explain about this code except for the run_commands method, that accepts an iterable of partial commands. These partial commands are commands that have been initiated without the receiver object, that should be of type TextManipulator. Why should you do that? It is a possible way to decouple parsing from commands execution. I decided to do it with functools.partial but you have other valid options.


Eventually, the parsing part:

class CommandParser:
    @staticmethod
    def parse(commands_str: str):
        def invalid_command(match: re.Match):
            raise ValueError(f"command '{match.group(2)}' not recognized.")

        get_counter_from_match = lambda m: int(m.group(1) or 1)
        commands_map = { 
            'h': lambda match: functools.partial(MoveCursorLeftCommand, \
                counter=get_counter_from_match(match)), 
            'l': lambda match: functools.partial(MoveCursorRightCommand, \
                counter=get_counter_from_match(match)), 
            'r': lambda match: functools.partial(ReplaceCommand, \
                counter=get_counter_from_match(match), replacement=match.group(3))
        }
        parsed_commands_iter = re.finditer(r'(\d*)(h|l|r(\w)|.)', commands_str)
        commands = map(lambda match: \
            commands_map.get(match.group(2)[0], invalid_command)(match), parsed_commands_iter)
        
        return commands

if __name__ == '__main__':
    import doctest
    doctest.testmod()

As I said at the beginning, parsing can be done with regex in your case and command creation is based on the first letter of the second capturing group of each match. The reason is that for the char replacement, the second capturing group includes the char to be replaced too. The commands_map is accessed with match.group(2)[0] as key and return a partial Command. If the operation is not found in the map, it throws a ValueError exception. The parameters of each Command are inferred from the re.Match object.


Just put all these code snippet together and you have a working solution (and some tests provided by the docstring executed by doctest).

This could be an over complicated design in some scenarios, so I am not saying that is the correct way to do it (it is probably not if you are writing a simple tool for example). You can avoid the Commands part and just take the parsing solution, but I have found this to be an interesting (alternative) application of the pattern.

CodePudding user response:

The key to this problem is figuring out how to parse the command string. From your description, the command string contains an optional number, followed by either of the following three possibilities:

  • h
  • l
  • r, followed by a character

A regex to parse this would be (try online):

(\d*)(h|l|r.)

Explanation:

(\d*)            Capture zero or more digits, 
     (h|l|r.)    Capture either an h, or an l, or an r followed by any character 

Using re.findall() with this regex, you can get a list of matches, where each match is a tuple containing the captured groups. For example, "rh6l9l4hrw" gives the result

[('', 'rh'), ('6', 'l'), ('9', 'l'), ('4', 'h'), ('', 'rw')]

So the first element of the tuple is a string representing N (or an empty string if none exists), and the second element of the tuple is the command. If the command is r, it will contain the replacement character after it. Now all we need to do is iterate over this list, and apply the correct commands.

I made a few changes:

  1. Access self.pos through a property with a setter that handles the correct bounds checking
  2. explode the input text into a list when the object is created, because it's not possible to modify strings in-place like you can with a list. __str__() joins the list back to a string.
  3. Access self.text through a readonly property, which joins the self.__text list into a string.
class Editor():
    def __init__(self, text):
        self.__text = [char for char in text]
        self.__pos = 0
    
    @property
    def text(self):
        return "".join(self.__text)
    
    @property
    def pos(self):
        return self.__pos
    
    @pos.setter
    def pos(self, value):
        self.__pos = max(0, min(len(self.text)-1, value))

    def l(self, step):
        self.pos = self.pos   step

    def h(self, step):
        self.pos = self.pos - step

    def r(self, char, count=1):
        # If count causes the cursor to overshoot the text, 
        # modify count
        count = min(count, len(self.__text) - self.pos)
        self.__text[self.pos:self.pos count] = char * count
        self.pos = self.pos   count - 1 # Set position to last replaced character

    def run(self, command):
        commands = re.findall(r"(\d*)(h|l|r.)", command)
        
        for cmd in commands:
            self.validate(cmd)
            count = int(cmd[0] or "1") # If cmd[0] is blank, use count = 1
            if cmd[1] == "h":
                self.h(count)
            elif cmd[1] == "l":
                self.l(count)
            elif cmd[1][0] == "r":
                self.r(cmd[1][1], count)

    def validate(self, cmd):
        cmd_s = ''.join(cmd)
        if cmd[0] and not cmd[0].isnumeric():
            raise ValueError(f"Invalid numeric input {cmd[0]} for command {cmd_s}")
        elif cmd[1][0] not in "hlr":
            raise ValueError(f"Invalid command {cmd_s}: Must be either h or l or r")
        elif cmd[1] == 'r' and len(cmd) == 1:
            raise ValueError(f"Invalid command {cmd_s}: r command needs an argument")

    def __str__(self):
        return self.text

Running this with your given inputs:

commands = ["hhlhllhlhhll", "rhllllllrw", "rh6l9l4hrw", "9lrL7h2rL", "999999999999999999999999999lr0", "999rsom"]

for cmd in commands:
    e = Editor("Hello World")
    e.run(cmd)
    uline = "        "   " " * e.pos   "^"
    cline = "Cursor: "   " " * e.pos   str(e.pos)
    print(f"Input: {cmd}\nOutput: {str(e)}\n{uline}\n{cline}\n")
Input: hhlhllhlhhll
Output: Hello World
          ^
Cursor:   2

Input: rhllllllrw
Output: hello world
              ^
Cursor:       6

Input: rh6l9l4hrw
Output: hello world
              ^
Cursor:       6

Input: 9lrL7h2rL
Output: HeLLo WorLd
           ^
Cursor:    3

Input: 999999999999999999999999999lr0
Output: Hello Worl0
                  ^
Cursor:           10

Input: 999rsom
Output: sssssssssss
                  ^
Cursor:           10
  • Related