Module refinery.lib.scripts.xlm.handlers.functions

The numeric and logic commands of the macro language: the arithmetic of numbers, the truth of conditions, the counting of arguments, and the Roman numerals of _xlfn.ARABIC.

Expand source code Browse git
"""
The numeric and logic commands of the macro language: the arithmetic of numbers, the truth of
conditions, the counting of arguments, and the Roman numerals of `_xlfn.ARABIC`.
"""
from __future__ import annotations

import datetime
import decimal
import math
import operator

from typing import TYPE_CHECKING, Callable

from refinery.lib.excel import synthesize_formula
from refinery.lib.excel.common import datetime_to_serial
from refinery.lib.excel.formula.model import XlA1Reference, XlMissingArgument, XlR1C1Reference
from refinery.lib.scripts.xlm.evaluate import evaluate_expression
from refinery.lib.scripts.xlm.guess import guess_day
from refinery.lib.scripts.xlm.trace import XlmStatus
from refinery.lib.scripts.xlm.values import (
    XlmOutcome,
    XlmValue,
    condition,
    error_outcome,
    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

#: How many seconds the answer of `NOW` advances from one call to the next, so that a program
#: which waits for a moment of the day moves on instead of reading the same moment forever.
_NOW_STEP = 2

#: How many times the answer of `ISERROR` may repeat at one cell before the emulator flips it,
#: to free a program stuck oscillating between two cells over a condition that never changes.
_ISERROR_REPEAT_LIMIT = 10

_ROMAN_VALUES = (
    ('M', 1000),
    ('CM', 900),
    ('D', 500),
    ('CD', 400),
    ('C', 100),
    ('XC', 90),
    ('L', 50),
    ('XL', 40),
    ('X', 10),
    ('IX', 9),
    ('V', 5),
    ('IV', 4),
    ('I', 1),
)


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


def _from_roman(spelled: str) -> int:
    result = 0
    position = 0
    for numeral, value in _ROMAN_VALUES:
        while spelled[position:position + len(numeral)] == numeral:
            result += value
            position += len(numeral)
    if position != len(spelled):
        raise ValueError(spelled)
    return result


def _numeric(
    engine: XlmEngine,
    call: XlFunctionCall,
    cursor: XlmCursor,
    index: int = 0,
) -> XlmValue | None:
    """
    One numeric operand of a command, or `None` when the command cannot compute: a call that
    names no operand for the position, an operand the program never finished, or one that spells
    no number leaves the command unevaluated.
    """
    if len(call.arguments) <= index:
        return None
    argument = evaluate_expression(engine, call.arguments[index], cursor)
    if argument.partial or not is_number(argument.value):
        return None
    return argument


def _rounded(number: float, digits: int, rounding: str) -> float:
    """
    The number rounded to the given count of decimal digits in the direction a rounding mode of
    `decimal` names; a negative count rounds to the left of the decimal point. The rounding
    applies to the shortest decimal spelling of the number, the spelling Excel rounds, so that
    a tie that spelling shows is a tie.
    """
    spelled = decimal.Decimal(repr(number))
    exponent = spelled.as_tuple().exponent
    if isinstance(exponent, int) and exponent >= -digits:
        return number
    return float(spelled.quantize(decimal.Decimal(1).scaleb(-digits), rounding=rounding))


def _round_to_digits(
    engine: XlmEngine,
    call: XlFunctionCall,
    cursor: XlmCursor,
    rounding: str,
) -> XlmOutcome:
    """
    The rounding commands: the number of the first argument rounded to the count of decimal
    digits the second argument names, zero when the call names none.
    """
    spelled = synthesize_formula(call)
    number = _numeric(engine, call, cursor)
    if number is None:
        return _partial(spelled)
    digits = 0
    if len(call.arguments) > 1 and not isinstance(call.arguments[1], XlMissingArgument):
        count = _numeric(engine, call, cursor, 1)
        if count is None:
            return _partial(spelled)
        digits = int(float(count.value))
    try:
        result = _rounded(float(number.value), digits, rounding)
    except decimal.DecimalException:
        return _partial(spelled)
    return XlmOutcome(value=XlmValue(value=result))


def _abs(engine: XlmEngine, call: XlFunctionCall, cursor: XlmCursor) -> XlmOutcome:
    argument = _numeric(engine, call, cursor)
    if argument is None:
        return _partial(synthesize_formula(call))
    if isinstance(argument.value, bool):
        return XlmOutcome(value=XlmValue(value=int(argument.value)))
    return XlmOutcome(value=XlmValue(value=abs(float(argument.value))))


def _int(engine: XlmEngine, call: XlFunctionCall, cursor: XlmCursor) -> XlmOutcome:
    """
    The greatest integer that does not exceed the number, as `INT` rounds down.
    """
    argument = _numeric(engine, call, cursor)
    if argument is None:
        return _partial(synthesize_formula(call))
    return XlmOutcome(value=XlmValue(value=math.floor(float(argument.value))))


def _sqrt(engine: XlmEngine, call: XlFunctionCall, cursor: XlmCursor) -> XlmOutcome:
    argument = _numeric(engine, call, cursor)
    if argument is None:
        return _partial(synthesize_formula(call))
    try:
        result = math.sqrt(float(argument.value))
    except ValueError:
        return _partial(synthesize_formula(call))
    return XlmOutcome(value=XlmValue(value=result))


def _trunc(engine: XlmEngine, call: XlFunctionCall, cursor: XlmCursor) -> XlmOutcome:
    """
    The number cut toward zero to the count of decimal digits the call names.
    """
    return _round_to_digits(engine, call, cursor, decimal.ROUND_DOWN)


def _value(engine: XlmEngine, call: XlFunctionCall, cursor: XlmCursor) -> XlmOutcome:
    spelled = synthesize_formula(call)
    argument = evaluate_expression(engine, call.arguments[0], cursor)
    if not argument.partial and is_number(argument.value):
        number = float(argument.value)
        if number.is_integer():
            return XlmOutcome(value=XlmValue(value=int(number)))
        return XlmOutcome(value=XlmValue(value=number))
    if argument.partial:
        return _partial(spelled)
    return XlmOutcome(value=XlmValue(value=0, text=spelled), status=XlmStatus.Error)


def _pair(
    engine: XlmEngine,
    call: XlFunctionCall,
    cursor: XlmCursor,
) -> tuple[XlmValue, XlmValue] | None:
    """
    The two numeric operands of a command, or `None` when the command cannot compute: a call
    that names fewer than two operands leaves it unevaluated as well.
    """
    if len(call.arguments) < 2:
        return None
    left = evaluate_expression(engine, call.arguments[0], cursor)
    right = evaluate_expression(engine, call.arguments[1], cursor)
    if (
        left.partial
        or right.partial
        or not is_number(left.value)
        or not is_number(right.value)
    ):
        return None
    return left, right


def _mod(engine: XlmEngine, call: XlFunctionCall, cursor: XlmCursor) -> XlmOutcome:
    spelled = synthesize_formula(call)
    operands = _pair(engine, call, cursor)
    if operands is None:
        return _partial(spelled)
    left, right = operands
    try:
        result = float(left.value) % float(right.value)
    except ZeroDivisionError:
        return _partial(spelled)
    return XlmOutcome(value=XlmValue(value=result))


def _round(engine: XlmEngine, call: XlFunctionCall, cursor: XlmCursor) -> XlmOutcome:
    """
    The number rounded to the count of decimal digits the call names, a tie away from zero.
    """
    return _round_to_digits(engine, call, cursor, decimal.ROUND_HALF_UP)


def _round_up(engine: XlmEngine, call: XlFunctionCall, cursor: XlmCursor) -> XlmOutcome:
    """
    The number rounded away from zero to the count of decimal digits the call names.
    """
    return _round_to_digits(engine, call, cursor, decimal.ROUND_UP)


def _fact(engine: XlmEngine, call: XlFunctionCall, cursor: XlmCursor) -> XlmOutcome:
    """
    The factorial of the number cut to its integer part: a negative number — negative before
    the cut, so `FACT(-0.5)` leaves the command unevaluated as well — and a factorial no finite
    number holds leave the command unevaluated.
    """
    spelled = synthesize_formula(call)
    argument = _numeric(engine, call, cursor)
    if argument is None:
        return _partial(spelled)
    number = float(argument.value)
    if number < 0:
        return _partial(spelled)
    whole = math.trunc(number)
    if whole > 170:
        return _partial(spelled)
    return XlmOutcome(value=XlmValue(value=float(math.factorial(whole))))


def _rand(engine: XlmEngine, call: XlFunctionCall, cursor: XlmCursor) -> XlmOutcome:
    """
    A number of the chance of the engine, which the fixed seed of its generator makes the same
    for every run of the same program; the call's arguments name nothing it reads.
    """
    return XlmOutcome(value=XlmValue(value=engine.random.random()))


def _fold_numbers(
    engine: XlmEngine,
    call: XlFunctionCall,
    cursor: XlmCursor,
    start: float,
    combine: Callable[[float, float], float],
) -> XlmOutcome:
    """
    The fold of the number of every argument of the call into one number, which starts at the
    given number and combines it with the number of every argument by the given operation; an
    argument the program never finished, one that spells no number, a call that names no
    argument, and a result no finite number holds leave the command unevaluated.
    """
    spelled = synthesize_formula(call)
    total = start
    for node in call.arguments:
        argument = evaluate_expression(engine, node, cursor)
        if argument.partial or not is_number(argument.value):
            return _partial(spelled)
        total = combine(total, float(argument.value))
    if not call.arguments or not math.isfinite(total):
        return _partial(spelled)
    return XlmOutcome(value=XlmValue(value=total))


def _sumproduct(engine: XlmEngine, call: XlFunctionCall, cursor: XlmCursor) -> XlmOutcome:
    return _fold_numbers(engine, call, cursor, 1.0, operator.mul)


def _sumxmy2(engine: XlmEngine, call: XlFunctionCall, cursor: XlmCursor) -> XlmOutcome:
    """
    The squared difference of the two operands; a result no finite number holds leaves the
    command unevaluated.
    """
    spelled = synthesize_formula(call)
    operands = _pair(engine, call, cursor)
    if operands is None:
        return _partial(spelled)
    left, right = operands
    try:
        result = (float(left.value) - float(right.value)) ** 2
    except OverflowError:
        return _partial(spelled)
    if not math.isfinite(result):
        return _partial(spelled)
    return XlmOutcome(value=XlmValue(value=result))


def _randbetween(engine: XlmEngine, call: XlFunctionCall, cursor: XlmCursor) -> XlmOutcome:
    spelled = synthesize_formula(call)
    operands = _pair(engine, call, cursor)
    if operands is None:
        return _partial(spelled)
    left, right = operands
    try:
        result = engine.random.randint(int(float(left.value)), int(float(right.value)))
    except ValueError:
        return _partial(spelled)
    return XlmOutcome(value=XlmValue(value=result))


def _quotient(engine: XlmEngine, call: XlFunctionCall, cursor: XlmCursor) -> XlmOutcome:
    """
    The integer part of the division of the two operands, the remainder discarded toward zero.
    """
    spelled = synthesize_formula(call)
    operands = _pair(engine, call, cursor)
    if operands is None:
        return _partial(spelled)
    left, right = operands
    try:
        result = math.trunc(float(left.value) / float(right.value))
    except (ZeroDivisionError, OverflowError):
        return _partial(spelled)
    return XlmOutcome(value=XlmValue(value=result))


def _extreme(engine: XlmEngine, call: XlFunctionCall, cursor: XlmCursor, choose) -> XlmOutcome:
    spelled = synthesize_formula(call)
    numbers: list[float] = []
    for node in call.arguments:
        argument = evaluate_expression(engine, node, cursor)
        if argument.partial or not is_number(argument.value):
            return _partial(spelled)
        numbers.append(float(argument.value))
    if not numbers:
        return _partial(spelled)
    return XlmOutcome(value=XlmValue(value=choose(numbers)))


def _max(engine: XlmEngine, call: XlFunctionCall, cursor: XlmCursor) -> XlmOutcome:
    return _extreme(engine, call, cursor, max)


def _min(engine: XlmEngine, call: XlFunctionCall, cursor: XlmCursor) -> XlmOutcome:
    return _extreme(engine, call, cursor, min)


def _product(engine: XlmEngine, call: XlFunctionCall, cursor: XlmCursor) -> XlmOutcome:
    return _fold_numbers(engine, call, cursor, 1.0, operator.mul)


def _sum(engine: XlmEngine, call: XlFunctionCall, cursor: XlmCursor) -> XlmOutcome:
    return _fold_numbers(engine, call, cursor, 0.0, operator.add)


def _and(engine: XlmEngine, call: XlFunctionCall, cursor: XlmCursor) -> XlmOutcome:
    value = True
    failure = None
    for node in call.arguments:
        argument = evaluate_expression(engine, node, cursor)
        if argument.partial:
            return XlmOutcome(value=XlmValue(value=False, partial=True))
        truth = condition(argument)
        if isinstance(truth, XlmValue):
            failure = failure or truth
        elif not truth:
            value = False
    if failure is not None:
        return XlmOutcome(value=failure)
    return XlmOutcome(value=XlmValue(value=value))


def _or(engine: XlmEngine, call: XlFunctionCall, cursor: XlmCursor) -> XlmOutcome:
    value = False
    failure = None
    for node in call.arguments:
        argument = evaluate_expression(engine, node, cursor)
        if argument.partial:
            return XlmOutcome(value=XlmValue(value=False, partial=True))
        truth = condition(argument)
        if isinstance(truth, XlmValue):
            failure = failure or truth
        elif truth:
            value = True
    if failure is not None:
        return XlmOutcome(value=failure)
    return XlmOutcome(value=XlmValue(value=value))


def _not(engine: XlmEngine, call: XlFunctionCall, cursor: XlmCursor) -> XlmOutcome:
    argument = evaluate_expression(engine, call.arguments[0], cursor)
    if argument.partial:
        return XlmOutcome(value=XlmValue(value=True, partial=True))
    truth = condition(argument)
    if isinstance(truth, XlmValue):
        return error_outcome(truth)
    return XlmOutcome(value=XlmValue(value=not truth))


def _isnumber(engine: XlmEngine, call: XlFunctionCall, cursor: XlmCursor) -> XlmOutcome:
    """
    Whether the argument holds a number, by its type rather than its spelling: a number —
    the serial a date cell holds included — answers `TRUE`, and a text, a boolean, an error
    value, and an empty cell answer `FALSE`.
    """
    argument = evaluate_expression(engine, call.arguments[0], cursor)
    if argument.partial:
        return XlmOutcome(value=XlmValue(
            value=True,
            text=F'ISNUMBER({argument.text})',
            partial=True,
        ))
    return XlmOutcome(value=XlmValue(value=(
        isinstance(argument.value, (int, float)) and not isinstance(argument.value, bool)
    )))


def _iserror(engine: XlmEngine, call: XlFunctionCall, cursor: XlmCursor) -> XlmOutcome:
    """
    Whether the argument is an error value, or a value that computes nothing. The answer flips
    once it repeated at one cell often enough, to free a program stuck waiting on a condition
    that never changes.
    """
    argument = evaluate_expression(engine, call.arguments[0], cursor)
    result = argument.value is None or argument.error
    if engine.iserror_at is None:
        engine.iserror_flag = result
        engine.iserror_at = cursor
        engine.iserror_repeats = 1
    elif engine.iserror_at == cursor:
        if engine.iserror_flag != result:
            engine.iserror_flag = result
            engine.iserror_repeats = 1
        elif engine.iserror_repeats < _ISERROR_REPEAT_LIMIT:
            engine.iserror_repeats += 1
        else:
            result = not result
            engine.iserror_at = None
    return XlmOutcome(value=XlmValue(
        value=result,
        text=F'ISERROR({wrap_literal(argument.unwrap())})',
    ))


def _count(engine: XlmEngine, call: XlFunctionCall, cursor: XlmCursor) -> XlmOutcome:
    """
    How many of the arguments are numbers: a reference or a range counts the cells that hold a
    number, and any other argument counts when it is a number, a truth value, or a text that
    spells a number. A value the program never finished leaves the count unevaluated.
    """
    spelled = synthesize_formula(call)
    count = 0
    for node in call.arguments:
        corners = engine.range_corners(node, cursor)
        if corners is None and isinstance(node, (XlA1Reference, XlR1C1Reference)):
            reference = engine.node_reference(node, cursor)
            if reference is not None:
                corners = (reference, reference)
        if corners is not None:
            for reference in engine.range_cells(corners):
                value = engine.read_reference(reference, cursor)
                if value.partial:
                    return _partial(spelled)
                if (
                    not value.error
                    and not isinstance(value.value, (str, bool))
                    and is_number(value.value)
                ):
                    count += 1
            continue
        if isinstance(node, XlMissingArgument):
            continue
        argument = evaluate_expression(engine, node, cursor)
        if argument.partial:
            return _partial(spelled)
        if not argument.error and is_number(argument.value):
            count += 1
    return XlmOutcome(value=XlmValue(value=count))


def _day(engine: XlmEngine, call: XlFunctionCall, cursor: XlmCursor) -> XlmOutcome:
    spelled = synthesize_formula(call)
    if engine.day >= 0:
        text = str(engine.day)
        return XlmOutcome(value=XlmValue(value=text, text=text))
    argument = evaluate_expression(engine, call.arguments[0], cursor)
    if argument.partial:
        return _partial(spelled)
    if argument.date:
        day = guess_day(engine)
        engine.day = day
        text = str(day)
        return XlmOutcome(value=XlmValue(value=text, text=text))
    if is_number(argument.value):
        return XlmOutcome(
            value=XlmValue(value=0, text='DAY(Serial Date)'),
            status=XlmStatus.NotImplemented,
        )
    return _partial(spelled)


def _now(engine: XlmEngine, call: XlFunctionCall, cursor: XlmCursor) -> XlmOutcome:
    moment = datetime.datetime.now() + datetime.timedelta(
        seconds=engine.now_count * _NOW_STEP,
    )
    engine.now_count += 1
    return XlmOutcome(value=XlmValue(value=datetime_to_serial(moment, False)))


def _arabic(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)
    try:
        result = _from_roman(argument.unwrap().upper())
    except ValueError:
        return _partial(spelled)
    return XlmOutcome(value=XlmValue(value=result))


FUNCTION_HANDLERS = {
    'ABS': _abs,
    'AND': _and,
    'COUNT': _count,
    'DAY': _day,
    'FACT': _fact,
    'INT': _int,
    'ISERROR': _iserror,
    'ISNUMBER': _isnumber,
    'MAX': _max,
    'MIN': _min,
    'MOD': _mod,
    'NOT': _not,
    'NOW': _now,
    'OR': _or,
    'PRODUCT': _product,
    'QUOTIENT': _quotient,
    'RAND': _rand,
    'RANDBETWEEN': _randbetween,
    'ROUND': _round,
    'ROUNDUP': _round_up,
    'SQRT': _sqrt,
    'SUM': _sum,
    'SUMPRODUCT': _sumproduct,
    'SUMXMY2': _sumxmy2,
    'TRUNC': _trunc,
    'VALUE': _value,
    '_xlfn.ARABIC': _arabic,
}