yaboli/yaboli/command.py
2019-04-12 00:28:08 +00:00

292 lines
8.2 KiB
Python

import abc
import re
from typing import (Awaitable, Callable, Dict, List, NamedTuple, Optional,
Pattern, Tuple)
from .message import LiveMessage
from .room import Room
from .util import similar
# Different ways of parsing commands:
#
# - raw string
#
# - split into arguments by whitespace
# - parsed into positional, optional, flags
#
# - The above two with or without bash-style escaping
#
# All of the above can be done with any argstr, even with an empty one.
__all__ = ["FancyArgs", "ArgumentData", "SpecificArgumentData", "CommandData",
"Command", "GeneralCommandFunction", "GeneralCommand",
"SpecificCommandFunction", "SpecificCommand"]
class FancyArgs(NamedTuple):
positional: List[str]
optional: Dict[str, Optional[str]]
flags: Dict[str, int]
class ArgumentData:
def __init__(self, raw: str) -> None:
self._raw = raw
self._basic: Optional[List[str]] = None
self._basic_escaped: Optional[List[str]] = None
self._fancy: Optional[FancyArgs] = None
self._fancy_escaped: Optional[FancyArgs] = None
def _split_escaped(self, text: str) -> List[str]:
"""
Splits the string into individual arguments, while allowing
bash-inspired quoting/escaping.
A single backslash escapes the immediately following character.
Double quotes allow backslash escapes, but escape all other characters.
Single quotes escape all characters.
The remaining string is split at all unescaped while space characters
(using str.isspace), similar to str.split without any arguments.
"""
words: List[str] = []
word: List[str] = []
backslash = False
quotes: Optional[str] = None
for char in text:
if backslash:
backslash = False
word.append(char)
elif quotes is not None:
if quotes == "\"" and char == "\\":
backslash = True
elif char == quotes:
quotes = None
else:
word.append(char)
elif char == "\\":
backslash = True
elif char in ["\"", "'"]:
quotes = char
elif char.isspace():
if word:
words.append("".join(word))
word = []
else:
word.append(char)
# ignoring any left-over backslashes or open quotes at the end
if word:
words.append("".join(word))
return words
def _split(self, text: str, escaped: bool) -> List[str]:
if escaped:
return self._split_escaped(text)
else:
return text.split()
def _parse_fancy(self, args: List[str]) -> FancyArgs:
raise NotImplementedError # TODO
@property
def raw(self) -> str:
return self._raw
def basic(self, escaped: bool = True) -> List[str]:
if escaped:
if self._basic_escaped is None:
self._basic_escaped = self._split(self._raw, escaped)
return self._basic_escaped
else:
if self._basic is None:
self._basic = self._split(self._raw, escaped)
return self._basic
def fancy(self, escaped: bool = True) -> FancyArgs:
if escaped:
if self._fancy_escaped is None:
basic = self._split(self._raw, escaped)
self._fancy_escaped = self._parse_fancy(basic)
return self._fancy_escaped
else:
if self._fancy is None:
basic = self._split(self._raw, escaped)
self._fancy = self._parse_fancy(basic)
return self._fancy
def has_args(self) -> bool:
return bool(self.basic()) # The list of arguments is empty
class SpecificArgumentData(ArgumentData):
def __init__(self, nick: str, raw: str) -> None:
super().__init__(raw)
self._nick = nick
@property
def nick(self) -> str:
return self._nick
class CommandData:
_NAME_RE = re.compile(r"^!(\S+)")
_MENTION_RE = re.compile(r"^\s+@(\S+)")
def __init__(self,
name: str,
general: ArgumentData,
specific: Optional[SpecificArgumentData]
) -> None:
self._name = name
self._general = general
self._specific = specific
@property
def name(self) -> str:
return self._name
@property
def general(self) -> ArgumentData:
return self._general
@property
def specific(self) -> Optional[SpecificArgumentData]:
return self._specific
@staticmethod
def _take(pattern: Pattern, text: str) -> Optional[Tuple[str, str]]:
"""
Returns the pattern's first group and the rest of the string that
didn't get matched by the pattern.
Anchoring the pattern to the beginning of the string is the
responsibility of the pattern writer.
"""
match = pattern.match(text)
if not match:
return None
group = match.group(1)
rest = text[match.end():]
return group, rest
@classmethod
def from_string(cls, string: str) -> "Optional[CommandData]":
# If it looks like it should work in the euphoria UI, it should work.
# Since euphoria strips whitespace chars from the beginning and end of
# messages, we do too.
string = string.strip()
name_part = cls._take(cls._NAME_RE, string)
if name_part is None: return None
name, name_rest = name_part
general = ArgumentData(name_rest)
specific: Optional[SpecificArgumentData]
mention_part = cls._take(cls._MENTION_RE, name_rest)
if mention_part is None:
specific = None
else:
mention, rest = mention_part
specific = SpecificArgumentData(mention, rest)
return cls(name, general, specific)
class Command(abc.ABC):
def __init__(self, name: str) -> None:
self._name = name
async def run(self,
room: Room,
message: LiveMessage,
nicks: List[str],
data: CommandData,
) -> None:
if data.name == self._name:
await self._run(room, message, nicks, data)
@abc.abstractmethod
async def _run(self,
room: Room,
message: LiveMessage,
nicks: List[str],
data: CommandData,
) -> None:
pass
# General command
GeneralCommandFunction = Callable[[Room, LiveMessage, ArgumentData],
Awaitable[None]]
class GeneralCommand(Command):
def __init__(self,
name: str,
cmdfunc: GeneralCommandFunction,
args: bool
) -> None:
super().__init__(name)
self._cmdfunc = cmdfunc
self._args = args
async def _run(self,
room: Room,
message: LiveMessage,
nicks: List[str],
data: CommandData,
) -> None:
# Do we have arguments if we shouldn't?
if not self._args and data.general.has_args():
return
await self._cmdfunc(room, message, data.general)
# Specific command
SpecificCommandFunction = Callable[[Room, LiveMessage, SpecificArgumentData],
Awaitable[None]]
class SpecificCommand(Command):
def __init__(self,
name: str,
cmdfunc: SpecificCommandFunction,
args: bool
) -> None:
super().__init__(name)
self._cmdfunc = cmdfunc
self._args = args
async def _run(self,
room: Room,
message: LiveMessage,
nicks: List[str],
data: CommandData,
) -> None:
# Is this a specific command?
if data.specific is None:
return
# Are we being mentioned?
for nick in nicks:
if similar(nick, data.specific.nick):
break
else:
return # Yay, a rare occurrence of this structure!
# Do we have arguments if we shouldn't?
if not self._args and data.specific.has_args():
return
await self._cmdfunc(room, message, data.specific)