Module refinery.lib.scripts.ps1.analysis.mutation

What a call leaves in the slot it writes through.

refinery.lib.scripts.ps1.analysis.arguments says which slots a call writes; this says what is in one afterwards. The two are separate because they are used separately: a caller refusing to install a value only needs the first, and every refusal it makes is safe whether or not the second answers.

Nothing here computes a value. [Array]::Reverse reverses a collection, and what a collection is and how one is spelled is refinery.lib.scripts.ps1.analysis.values' to say, so this reads the value in and writes the value out through that module and holds only the rule between them.

A rule answers only over the values the call is total on. [Array]::Reverse and [Array]::Clear throw when the range runs off the end, so each is answered only for a range that fits and refuses the rest rather than clamping it: a value where 5.1 raised is worse than no answer, because a refusal costs a fold and this would print something the script never printed. [Array]::Sort throws on an array whose elements do not compare, a sub-domain the values here do not settle, so it has no rule.

Expand source code Browse git
"""
What a call leaves in the slot it writes through.

`refinery.lib.scripts.ps1.analysis.arguments` says *which* slots a call writes; this says what is in
one afterwards. The two are separate because they are used separately: a caller refusing to install
a value only needs the first, and every refusal it makes is safe whether or not the second answers.

Nothing here computes a value. `[Array]::Reverse` reverses a collection, and what a collection is
and how one is spelled is `refinery.lib.scripts.ps1.analysis.values`' to say, so this reads the
value in and writes the value out through that module and holds only the rule between them.

**A rule answers only over the values the call is total on.** `[Array]::Reverse` and `[Array]::Clear`
throw when the range runs off the end, so each is answered only for a range that fits and refuses the
rest rather than clamping it: a value where 5.1 raised is worse than no answer, because a refusal
costs a fold and this would print something the script never printed. `[Array]::Sort` throws on an
array whose elements do not compare, a sub-domain the values here do not settle, so it has no rule.
"""
from __future__ import annotations

from typing import Sequence

from refinery.lib.scripts import Expression, _clone_node
from refinery.lib.scripts.ps1 import data
from refinery.lib.scripts.ps1.analysis.arguments import RECEIVER
from refinery.lib.scripts.ps1.analysis.model import written_call_slot
from refinery.lib.scripts.ps1.analysis.values import (
    integer_of,
    null_expression,
    read,
    unwrap_to_array_literal,
)
from refinery.lib.scripts.ps1.model import (
    Ps1AccessKind,
    Ps1ArrayLiteral,
    Ps1TypeExpression,
    Ps1Variable,
)

#: The members whose effect on their slot is written down here, each a canonical type key and a
#: lowercased member name.
_REVERSE = (data.required_type_key('array'), 'reverse')
_CLEAR = (data.required_type_key('array'), 'clear')


def value_after(occurrence: Ps1Variable, previous: Expression) -> Expression | None:
    """
    The value the name *occurrence* stands for holds once the call around it has run, given the
    value `previous` it held before it, or `None` where no rule names one.

    `None` is the answer to every kind of doubt: a member with no rule, an overload the arity
    does not settle, a call that writes more than the one slot, a conversion standing between
    the name and the slot, an operand this cannot read as a collection, and a range that would
    throw. A caller must fold nothing on it.
    """
    found = written_call_slot(occurrence)
    if found is None or not found.written.settled or found.written.slots != {found.slot}:
        return None
    if found.slot == RECEIVER:
        # No rule here is about a receiver yet, and the bounds below are the arguments *after* the
        # written slot, which for a receiver would be the whole list: `$x.SetValue(9, 0)` would be
        # read as reversing a range.
        return None
    if found.through_a_part:
        # `[Array]::Reverse($p[0])` turns around the array `$p`'s first element is, so what `$p`
        # holds afterwards is the outer array with that one element replaced. Naming it is Knobe
        # and Sarkar's element update, which needs the index to be constant and the element to be
        # unshared; until it is built, answering the *slot* here would report the inner array's new
        # order as the outer array's.
        return None
    if found.through_a_conversion:
        # A cast between the name and the slot may hand the callee a fresh value built from what the
        # name holds rather than the value itself — measured, `[Array]::Reverse([int[]]$x)` over an
        # `Object[]` reverses a temporary and leaves `$x` in its original order. Which of the two a
        # cast is depends on the operand's runtime type, so the pair is refused rather than read.
        return None
    call = found.call
    if call.access is not Ps1AccessKind.STATIC or not isinstance(call.member, str):
        return None
    named = call.object
    if not isinstance(named, Ps1TypeExpression):
        return None
    resolved = data.resolve_type(named.name)
    if resolved is None:
        return None
    member = (resolved.generic_definition, call.member.lower())
    bounds = call.arguments[found.slot + 1:]
    if member == _REVERSE:
        return _reversed(previous, bounds)
    if member == _CLEAR:
        return _cleared(previous, bounds)
    return None


def _reversed(previous: Expression, bounds: Sequence[Expression]) -> Expression | None:
    """
    The collection `previous` names with a run of it turned around, or `None` where this names none.

    `bounds` is what the call passes after the array: nothing for the whole of it, an index and a
    length for a part. A part that does not fit inside the collection is refused rather than
    clamped, because 5.1 does not reverse anything there at all — measured,
    `[Array]::Reverse($b, 0, 99)` over three elements throws `ArgumentException`, and answering with
    a value would put one where the script raised.

    Only a collection is answered for. A String bound to the `System.Array` parameter is converted
    to a one-element `object[]` holding the string, which the call turns around and discards, so the
    variable is left holding exactly what it held — measured, and a fact about the *conversion*
    rather than about reversal, which is why it is refused here rather than answered.

    The elements are copied before the answer is built out of them. A node adopts the children
    it is handed, so building the answer over the ones still standing in the tree would leave
    the array the script wrote with children naming a node that is nowhere in it — and every
    guard that asks what encloses a statement climbs out of the tree from there. This answer is
    a value, not a rewrite, and a value has to be free of the tree it was read from whether the
    caller installs it or not.
    """
    array = unwrap_to_array_literal(previous)
    if array is None:
        return None
    if bounds:
        if len(bounds) != 2:
            return None
        start = integer_of(read(bounds[0]))
        length = integer_of(read(bounds[1]))
        if start is None or length is None:
            return None
        if start < 0 or length < 0 or start + length > len(array.elements):
            return None
    else:
        start, length = 0, len(array.elements)
    stop = start + length
    elements = [_clone_node(element) for element in array.elements]
    return Ps1ArrayLiteral(
        elements=[*elements[:start], *elements[start:stop][::-1], *elements[stop:]])


def _cleared(previous: Expression, bounds: Sequence[Expression]) -> Expression | None:
    """
    The collection `previous` names with a run of it set to `$null`, or `None` where this names none.

    `[Array]::Clear` takes an index and a length and has no whole-collection form, so a call that
    does not pass exactly those two is refused. A range that runs off the end is refused rather than
    clamped, for the reason `_reversed` refuses one: 5.1 raises there, and a value would stand where
    the script did not.

    Only a bare array literal is answered for, and every one of those is an `Object[]` whose element
    default is `$null` — a typed array clears to that type's default instead, and a cast standing
    between the literal and the call is one `unwrap_to_array_literal` does not read through, so the
    element the answer writes is always `$null`.

    The surviving elements are copied before the answer is built, for the reason `_reversed` copies
    them: a node adopts the children it is handed, and a value read out of the tree has to be free
    of it.
    """
    array = unwrap_to_array_literal(previous)
    if array is None or len(bounds) != 2:
        return None
    start = integer_of(read(bounds[0]))
    length = integer_of(read(bounds[1]))
    if start is None or length is None:
        return None
    if start < 0 or length < 0 or start + length > len(array.elements):
        return None
    stop = start + length
    elements = [_clone_node(element) for element in array.elements]
    cleared = [null_expression() for _ in range(start, stop)]
    return Ps1ArrayLiteral(elements=[*elements[:start], *cleared, *elements[stop:]])

Functions

def value_after(occurrence, previous)

The value the name occurrence stands for holds once the call around it has run, given the value previous it held before it, or None where no rule names one.

None is the answer to every kind of doubt: a member with no rule, an overload the arity does not settle, a call that writes more than the one slot, a conversion standing between the name and the slot, an operand this cannot read as a collection, and a range that would throw. A caller must fold nothing on it.

Expand source code Browse git
def value_after(occurrence: Ps1Variable, previous: Expression) -> Expression | None:
    """
    The value the name *occurrence* stands for holds once the call around it has run, given the
    value `previous` it held before it, or `None` where no rule names one.

    `None` is the answer to every kind of doubt: a member with no rule, an overload the arity
    does not settle, a call that writes more than the one slot, a conversion standing between
    the name and the slot, an operand this cannot read as a collection, and a range that would
    throw. A caller must fold nothing on it.
    """
    found = written_call_slot(occurrence)
    if found is None or not found.written.settled or found.written.slots != {found.slot}:
        return None
    if found.slot == RECEIVER:
        # No rule here is about a receiver yet, and the bounds below are the arguments *after* the
        # written slot, which for a receiver would be the whole list: `$x.SetValue(9, 0)` would be
        # read as reversing a range.
        return None
    if found.through_a_part:
        # `[Array]::Reverse($p[0])` turns around the array `$p`'s first element is, so what `$p`
        # holds afterwards is the outer array with that one element replaced. Naming it is Knobe
        # and Sarkar's element update, which needs the index to be constant and the element to be
        # unshared; until it is built, answering the *slot* here would report the inner array's new
        # order as the outer array's.
        return None
    if found.through_a_conversion:
        # A cast between the name and the slot may hand the callee a fresh value built from what the
        # name holds rather than the value itself — measured, `[Array]::Reverse([int[]]$x)` over an
        # `Object[]` reverses a temporary and leaves `$x` in its original order. Which of the two a
        # cast is depends on the operand's runtime type, so the pair is refused rather than read.
        return None
    call = found.call
    if call.access is not Ps1AccessKind.STATIC or not isinstance(call.member, str):
        return None
    named = call.object
    if not isinstance(named, Ps1TypeExpression):
        return None
    resolved = data.resolve_type(named.name)
    if resolved is None:
        return None
    member = (resolved.generic_definition, call.member.lower())
    bounds = call.arguments[found.slot + 1:]
    if member == _REVERSE:
        return _reversed(previous, bounds)
    if member == _CLEAR:
        return _cleared(previous, bounds)
    return None