Module refinery.lib.scripts.xlm.handlers.strings

The text commands of the macro language: the characters, lengths, slices, and searches of the strings the macro program assembles.

Expand source code Browse git
"""
The text commands of the macro language: the characters, lengths, slices, and searches of the
strings the macro program assembles.
"""
from __future__ import annotations

import re

from typing import TYPE_CHECKING

from refinery.lib.excel import synthesize_formula
from refinery.lib.excel.formula.model import XlMissingArgument
from refinery.lib.scripts.xlm.evaluate import evaluate_expression
from refinery.lib.scripts.xlm.values import (
    XlmOutcome,
    XlmValue,
    ansi_bytes,
    error_value,
    is_number,
    wrap_literal,
)

if TYPE_CHECKING:
    from refinery.lib.excel.formula.model import XlFunctionCall
    from refinery.lib.scripts.xlm.engine import XlmEngine
    from refinery.lib.scripts.xlm.references import XlmCursor


#: The wildcards of a search pattern, and the tilde that makes the one after it literal.
_WILDCARDS = re.compile(r'~([?*~])|([?*])')


def _partial(spelled: str) -> XlmOutcome:
    return XlmOutcome(value=XlmValue(value=spelled, partial=True))


def _wildcard_pattern(pattern: str) -> str:
    """
    The regular expression a search pattern spells: a question mark matches any one character,
    an asterisk any run of characters, and a tilde makes the wildcard or tilde after it literal.
    """
    parts = []
    position = 0
    for match in _WILDCARDS.finditer(pattern):
        parts.append(re.escape(pattern[position:match.start()]))
        literal, wildcard = match.groups()
        if literal:
            parts.append(re.escape(literal))
        elif wildcard == '?':
            parts.append('.')
        else:
            parts.append('.*')
        position = match.end()
    parts.append(re.escape(pattern[position:]))
    return ''.join(parts)


def _char(engine: XlmEngine, call: XlFunctionCall, cursor: XlmCursor) -> XlmOutcome:
    spelled = synthesize_formula(call)
    argument = evaluate_expression(engine, call.arguments[0], cursor)
    if argument.partial:
        return XlmOutcome(value=XlmValue(
            value=F'CHAR({argument.text})',
            partial=True,
        ))
    if not is_number(argument.value):
        return _partial(spelled)
    number = float(argument.value)
    if not 1 <= number <= 255:
        engine.char_errors += 1
        return XlmOutcome(value=error_value('#VALUE!'))
    character = chr(int(number))
    return XlmOutcome(value=XlmValue(value=character, text=character))


def _code(engine: XlmEngine, call: XlFunctionCall, cursor: XlmCursor) -> XlmOutcome:
    spelled = synthesize_formula(call)
    argument = evaluate_expression(engine, call.arguments[0], cursor)
    if argument.partial:
        return _partial(spelled)
    unwrapped = argument.unwrap()
    if not unwrapped:
        return XlmOutcome(value=XlmValue(value=0))
    return XlmOutcome(value=XlmValue(value=ansi_bytes(unwrapped[0])[0]))


def _concatenate(engine: XlmEngine, call: XlFunctionCall, cursor: XlmCursor) -> XlmOutcome:
    joined = ''.join(
        evaluate_expression(engine, node, cursor).unwrap()
        for node in call.arguments
    )
    return XlmOutcome(value=XlmValue(value=joined))


def _len(engine: XlmEngine, call: XlFunctionCall, cursor: XlmCursor) -> XlmOutcome:
    spelled = synthesize_formula(call)
    argument = evaluate_expression(engine, call.arguments[0], cursor)
    if argument.partial:
        return _partial(spelled)
    return XlmOutcome(value=XlmValue(value=len(argument.unwrap())))


def _mid(engine: XlmEngine, call: XlFunctionCall, cursor: XlmCursor) -> XlmOutcome:
    text = evaluate_expression(engine, call.arguments[0], cursor)
    base = evaluate_expression(engine, call.arguments[1], cursor)
    length = evaluate_expression(engine, call.arguments[2], cursor)
    if (
        not text.partial
        and not base.partial
        and not length.partial
        and is_number(base.value)
        and is_number(length.value)
    ):
        start = int(float(base.value)) - 1
        count = int(float(length.value))
        if start < 0 or count < 0:
            return XlmOutcome(value=error_value('#VALUE!'))
        result = text.unwrap()[start:start + count]
        return XlmOutcome(value=XlmValue(value=result, text=result))
    fragments = ','.join(synthesize_formula(node) for node in call.arguments)
    return XlmOutcome(value=XlmValue(value=F'MID({fragments})', partial=True))


def _search(engine: XlmEngine, call: XlFunctionCall, cursor: XlmCursor) -> XlmOutcome:
    """
    The position of the first match of a pattern in a text, counted from one and ignoring case,
    from the position the third argument names on. A pattern spells wildcards, and a pattern
    the text does not hold, or a position outside the text, answers `#VALUE!`.
    """
    needle = evaluate_expression(engine, call.arguments[0], cursor)
    haystack = evaluate_expression(engine, call.arguments[1], cursor)
    start = XlmValue(value=1)
    if len(call.arguments) > 2 and not isinstance(call.arguments[2], XlMissingArgument):
        start = evaluate_expression(engine, call.arguments[2], cursor)
    if needle.partial or haystack.partial or start.partial:
        return XlmOutcome(value=XlmValue(
            value=0,
            text=F'SEARCH({needle.text},{haystack.text})',
            partial=True,
        ))
    for argument in (needle, haystack, start):
        if argument.error:
            return XlmOutcome(value=argument)
    within = haystack.unwrap()
    if not is_number(start.value) or not 1 <= float(start.value) <= len(within):
        return XlmOutcome(value=error_value('#VALUE!'))
    pattern = re.compile(_wildcard_pattern(needle.unwrap()), re.IGNORECASE | re.DOTALL)
    match = pattern.search(within, int(float(start.value)) - 1)
    if match is None:
        return XlmOutcome(value=error_value('#VALUE!'))
    return XlmOutcome(value=XlmValue(value=match.start() + 1))


def _t(engine: XlmEngine, call: XlFunctionCall, cursor: XlmCursor) -> XlmOutcome:
    spelled = synthesize_formula(call)
    argument = evaluate_expression(engine, call.arguments[0], cursor)
    if argument.partial:
        return _partial(spelled)
    if isinstance(argument.value, bool) or argument.value is None:
        value = ''
    else:
        value = str(argument.value)
    return XlmOutcome(value=XlmValue(
        value=value,
        text=wrap_literal(value, must_wrap=True),
    ))


def _text(engine: XlmEngine, call: XlFunctionCall, cursor: XlmCursor) -> XlmOutcome:
    spelled = synthesize_formula(call)
    value = evaluate_expression(engine, call.arguments[0], cursor)
    form = evaluate_expression(engine, call.arguments[1], cursor)
    if (
        not value.partial
        and not form.partial
        and is_number(value.value)
        and is_number(form.unwrap())
        and int(float(form.unwrap())) == 0
    ):
        return XlmOutcome(value=XlmValue(value=int(float(value.value))))
    return _partial(spelled)


STRING_HANDLERS = {
    'CHAR': _char,
    'CODE': _code,
    'CONCATENATE': _concatenate,
    'LEN': _len,
    'MID': _mid,
    'SEARCH': _search,
    'T': _t,
    'TEXT': _text,
}