Module refinery.lib.scripts.ps1.synth

AST-to-source synthesizer for PowerShell.

Expand source code Browse git
"""
AST-to-source synthesizer for PowerShell.
"""
from __future__ import annotations

import contextlib
import io

from collections.abc import Callable

from refinery.lib.scripts import Block, Node, Synthesizer
from refinery.lib.scripts.ps1 import precedence
from refinery.lib.scripts.ps1.lexer import Ps1LexerMode, reads_as_one_numeral
from refinery.lib.scripts.ps1.model import (
    Expression,
    Ps1ArrayExpression,
    Ps1ArrayLiteral,
    Ps1AssignmentExpression,
    Ps1Attribute,
    Ps1BinaryExpression,
    Ps1BreakStatement,
    Ps1CastExpression,
    Ps1ClassDefinition,
    Ps1Code,
    Ps1CommandArgument,
    Ps1CommandArgumentKind,
    Ps1CommandInvocation,
    Ps1ContinueStatement,
    Ps1DataSection,
    Ps1DoLoop,
    Ps1EnumDefinition,
    Ps1EnumMember,
    Ps1ErrorNode,
    Ps1Exit,
    Ps1ExitStatement,
    Ps1ExpandableHereString,
    Ps1ExpandableString,
    Ps1ExpressionStatement,
    Ps1FileRedirection,
    Ps1ForEachLoop,
    Ps1ForLoop,
    Ps1FunctionDefinition,
    Ps1HashLiteral,
    Ps1HereString,
    Ps1IfStatement,
    Ps1IndexExpression,
    Ps1InputRedirection,
    Ps1IntegerLiteral,
    Ps1InvokeMember,
    Ps1Jump,
    Ps1MemberAccess,
    Ps1MemberModifier,
    Ps1MergingRedirection,
    Ps1MethodMember,
    Ps1ParamBlock,
    Ps1ParameterDeclaration,
    Ps1ParenExpression,
    Ps1Pipeline,
    Ps1PipelineElement,
    Ps1PropertyMember,
    Ps1RangeExpression,
    Ps1RealLiteral,
    Ps1RedirectionStream,
    Ps1ReturnStatement,
    Ps1ScopeModifier,
    Ps1Script,
    Ps1ScriptBlock,
    Ps1StringLiteral,
    Ps1SubExpression,
    Ps1SwitchStatement,
    Ps1ThrowStatement,
    Ps1TrapStatement,
    Ps1TryCatchFinally,
    Ps1TypeExpression,
    Ps1UnaryExpression,
    Ps1Variable,
    Ps1WhileLoop,
)
from refinery.lib.scripts.ps1.token import BACKTICK_ENCODE, KEYWORD_SPELLING


def _fuses_with_a_sign(spelling: str) -> bool:
    """
    Whether a `+` or `-` written straight against `spelling` would join with it into one token.
    A numeral does: `- 5` printed as `-5` is one Int32 literal where the source had unary minus over
    an Int32, and the two differ at the width boundary — `- 2147483648` is an Int64 and
    `-2147483648` an Int32. A spelling that already begins with a sign fuses the other way, since
    `- -5` printed as `--5` re-lexes as the decrement operator.

    The question is asked of the text rather than of the node, because the leading character is what
    decides and it can belong to a node several levels down: `- 1kb.GetType()` is a member of one
    kilobyte, and printed as `-1kb.GetType()` it becomes a member of *minus* one kilobyte instead.
    """
    head = spelling[:1]
    if head in ('+', '-'):
        return True
    if head == '.':
        return spelling[1:2].isdigit()
    return head.isdigit()


def _numeral_spelling(node: Expression) -> str | None:
    """
    How a numeral is written, or `None` for a node that is not one. This is the only kind of leaf
    whose spelling can be swallowed by what stands beside it, because it is the only one that ends
    where the next character says it does rather than at a delimiter of its own.
    """
    if isinstance(node, (Ps1IntegerLiteral, Ps1RealLiteral)):
        return node.raw
    return None


class Ps1Synthesizer(Synthesizer):
    """
    Three things decide how a node is written, and all three are properties of the slot it goes into
    rather than of the node. How tightly the slot binds decides whether a bracket is needed, and
    `refinery.lib.scripts.ps1.precedence` is that scale. Whether the slot reads a bare word as a
    value decides how a leaf is spelled, and that is `_word_slot`. How the text will be lexed back
    decides where a spelling runs on into what touches it, and that is `_mode`.

    A word with no quotes means a value where a command's name and arguments are read, and begins a
    command everywhere else. So `foo a, b` may keep its words while `foo (a, b)` may not — the
    bracket makes `a` a command name, and 5.1 then rejects the whole line. The parser's `raw` is
    only true of the slot it was read from, which is why replaying it is not enough.

    The arming and the mode are close relatives and are not the same thing. The arming is spent on
    one node, because only the leaf standing in the slot is spelled by it; the mode holds until a
    delimiter is written, because 5.1 goes on lexing a command's arguments in command mode until
    something opens a new one. `Write-Output $t.GetType()` is where they part: the member access
    takes the arming, and the numeral several levels under it is still in the argument's text.
    """

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self._word_slot_ahead = False
        self._word_slot = False
        self._mode = Ps1LexerMode.EXPRESSION

    def visit(self, node: Node) -> Node | None:
        """
        Take the arming set by the slot, so that it applies to this node and no other. A slot that
        arms nothing yields the quoted spelling, which is the reading that is valid everywhere;
        forgetting to arm one therefore costs a pair of quotes rather than the meaning of a script.
        """
        self._word_slot, self._word_slot_ahead = self._word_slot_ahead, False
        return super().visit(node)

    @contextlib.contextmanager
    def _reading(self, mode: Ps1LexerMode):
        """
        Write what follows as text that will be lexed in `mode`, and put back the mode that was
        running when it is done.
        """
        saved, self._mode = self._mode, mode
        try:
            yield
        finally:
            self._mode = saved

    def _emit_word(self, node: Expression, minimum: int):
        """
        Write `node` into a slot that reads a bare word as a value, which is the slot 5.1 lexes in
        command mode.
        """
        self._word_slot_ahead = True
        with self._reading(Ps1LexerMode.ARGUMENT):
            self._emit_operand(node, minimum)

    def _emit_block(self, block: Block):
        self._write('{')
        self._depth += 1
        for stmt in block.body:
            self._newline()
            with self._reading(Ps1LexerMode.EXPRESSION):
                self.visit(stmt)
        self._depth -= 1
        if block.body:
            self._newline()
        self._write('}')

    def _emit_statement_list(self, stmts: list):
        for i, stmt in enumerate(stmts):
            if i > 0:
                self._newline()
            with self._reading(Ps1LexerMode.EXPRESSION):
                self.visit(stmt)

    @staticmethod
    def _variable_scope_prefix(node: Ps1Variable) -> str:
        if node.scope == Ps1ScopeModifier.NONE:
            return ''
        if node.scope == Ps1ScopeModifier.DRIVE:
            return F'{node.drive}:'
        return F'{node.scope.value}:'

    def visit_Ps1Variable(self, node: Ps1Variable):
        prefix = '@' if node.splatted else '$'
        body = F'{self._variable_scope_prefix(node)}{node.name}'
        if node.braced:
            body = F'{{{body}}}'
        self._write(F'{prefix}{body}')

    def visit_Ps1IntegerLiteral(self, node: Ps1IntegerLiteral):
        """
        A numeral is written exactly as it is spelled, and a numeral the source spelled is never
        re-spelled. Where it stands in a command argument the text itself is passed on: 5.1 wraps a
        literal argument so that `PSObject.TokenText` keeps what was written, and the receiving
        command reads that back — `Write-Host 1.10` prints `1.10` and `notepad.exe 0x10` receives
        `0x10` rather than `16`. Holding only `raw` is what makes that true by construction, and a
        pass that normalized one would break it here without anything noticing.
        """
        self._write(node.raw)

    def visit_Ps1RealLiteral(self, node: Ps1RealLiteral):
        self._write(node.raw)

    def visit_Ps1StringLiteral(self, node: Ps1StringLiteral):
        if '\n' in node.raw:
            self._write(F'"{self._escape_for_dq(node.value)}"')
        elif node.is_bare_word and not self._word_slot:
            self._write(F"'{self._escape_for_sq(node.value)}'")
        else:
            self._write(node.raw)

    def visit_Ps1ExpandableString(self, node: Ps1ExpandableString):
        self._emit_expandable_parts(node.parts)

    def _emit_expandable_parts(self, parts):
        self._write('"')
        for part in parts:
            if isinstance(part, Ps1StringLiteral):
                self._write(self._escape_for_dq(part.value))
            elif isinstance(part, Ps1Variable):
                self._emit_variable_in_dq(part)
            else:
                self.visit(part)
        self._write('"')

    def _emit_variable_in_dq(self, node: Ps1Variable):
        prefix = '@' if node.splatted else '$'
        self._write(F'{prefix}{{{self._variable_scope_prefix(node)}{node.name}}}')

    @staticmethod
    def _escape_for_sq(value: str) -> str:
        return value.replace("'", "''")

    @staticmethod
    def _escape_for_dq(value: str) -> str:
        for c in '`"$':
            value = value.replace(c, F'`{c}')
        for ch, esc in BACKTICK_ENCODE.items():
            value = value.replace(ch, esc)
        return value

    def visit_Ps1HereString(self, node: Ps1HereString):
        if '\n' in node.value:
            self._write(F'"{self._escape_for_dq(node.value)}"')
        else:
            self._write(node.raw)

    def visit_Ps1ExpandableHereString(self, node: Ps1ExpandableHereString):
        # Emit from the (possibly transform-rewritten) parts rather than the stale `raw`, otherwise
        # an inlined variable/constant would be lost while its source assignment is removed. A
        # double-quoted expandable string is semantically equivalent to the here-string.
        self._emit_expandable_parts(node.parts)

    def _emit_operand(self, node: Expression, minimum: int):
        """
        Write `node` into a slot that binds at least as tightly as `minimum`, bracketing it when it
        does not. Every slot that can absorb what is printed beside it goes through here, naming
        what it requires; a tree built by a pass carries no parentheses of its own, so this is the
        only thing standing between `Binary(Binary(1, '+', 2), '*', 3)` and `1 + 2 * 3`.
        """
        if precedence.needs_brackets(node, minimum):
            self._emit_bracketed(node)
        else:
            self.visit(node)

    def _emit_bracketed(self, node: Expression):
        """
        Write `node` inside a bracket. What stands inside one is read as a pipeline, so the slot the
        bracket creates is never one that reads a bare word as a value, whatever the slot outside it
        was, and the text in it is lexed as an expression however it got here.
        """
        self._word_slot_ahead = False
        self._write('(')
        with self._reading(Ps1LexerMode.EXPRESSION):
            self.visit(node)
        self._write(')')

    def visit_Ps1BinaryExpression(self, node: Ps1BinaryExpression):
        # The left spine is walked rather than recursed through, because a folded concatenation is
        # thousands of operators deep and recursion would not reach the end of one. Walking stops
        # where a left operand binds more loosely than its parent, since that one needs a bracket
        # and so is not part of the same flat chain.
        spine: list[tuple[str, Expression | None, int]] = []
        current = node
        while True:
            power = precedence.of_operator(current.operator)
            spine.append((current.operator, current.right, power))
            left = current.left
            if (
                isinstance(left, Ps1BinaryExpression)
                and precedence.of_operator(left.operator) >= power
            ):
                current = left
                continue
            break
        if (head := current.left) is not None:
            self._emit_operand(head, spine[-1][2])
        for operator, right, power in reversed(spine):
            self._write(F' {operator} ')
            if right is not None:
                self._emit_operand(right, power + 1)

    def visit_Ps1UnaryExpression(self, node: Ps1UnaryExpression):
        operand = node.operand
        if not node.prefix:
            if operand is not None:
                self._emit_operand(operand, precedence.UNARY)
            self._write(node.operator)
            return
        spelling = '' if operand is None else self._operand_to_string(operand, precedence.UNARY)
        self._write(node.operator)
        if node.operator.startswith('-') and len(node.operator) > 1:
            self._write(' ')
        elif node.operator in ('+', '-') and _fuses_with_a_sign(spelling):
            self._write(' ')
        self._write(spelling)

    def visit_Ps1TypeExpression(self, node: Ps1TypeExpression):
        self._write(F'[{node.name}]')

    def visit_Ps1CastExpression(self, node: Ps1CastExpression):
        self._write(F'[{node.type_name}]')
        if node.operand:
            self._emit_operand(node.operand, precedence.UNARY)

    def _emit_receiver(self, node: Expression, access: str):
        """
        Write `node` as the thing `access` reads from. The receiver has to be a primary expression:
        `.` and `::` bind tighter than anything written with an operator, so `(Get-Variable Y).Tls`
        printed bare would read the member off the last argument of the command rather than off its
        result.

        A numeral needs more than that, because it does not end where the tree says it does: `3` in
        front of `.ToString` is the one word `3.ToString`, and in front of `[0]` or `::MaxValue`
        every numeral is, since neither bracket nor colon ends one. The lexer is asked, so that what
        is written here and what reads it back are the same rule. No space is ever written before
        the access instead: `(3) .ToString()` is a parse error, measured.
        """
        raw = _numeral_spelling(node)
        if raw is not None and not reads_as_one_numeral(raw, access, self._mode):
            self._emit_bracketed(node)
        else:
            self._emit_operand(node, precedence.ATOM)

    def _emit_member_prefix(self, node: Ps1MemberAccess | Ps1InvokeMember):
        if node.object:
            self._emit_receiver(node.object, node.access.value)
        self._write(node.access.value)
        if isinstance(node.member, Expression):
            self.visit(node.member)
        else:
            self._write(str(node.member))

    def visit_Ps1MemberAccess(self, node: Ps1MemberAccess):
        self._emit_member_prefix(node)

    def visit_Ps1IndexExpression(self, node: Ps1IndexExpression):
        if node.object:
            self._emit_receiver(node.object, '[')
        self._write('[')
        if node.index:
            with self._reading(Ps1LexerMode.EXPRESSION):
                self.visit(node.index)
        self._write(']')

    def visit_Ps1InvokeMember(self, node: Ps1InvokeMember):
        self._emit_member_prefix(node)
        self._write('(')
        with self._reading(Ps1LexerMode.EXPRESSION):
            for i, arg in enumerate(node.arguments):
                if i > 0:
                    self._write(', ')
                if precedence.needs_brackets_between_delimiters(arg):
                    self._write('(')
                    self.visit(arg)
                    self._write(')')
                else:
                    self.visit(arg)
        self._write(')')

    def visit_Ps1CommandInvocation(self, node: Ps1CommandInvocation):
        if node.invocation_operator:
            self._write(node.invocation_operator)
            self._write(' ')
        if node.name:
            # `& 'i' + 'ex'` invokes `i` and then concatenates, so a computed command name needs
            # brackets for the invocation operator to reach the whole expression.
            self._emit_word(node.name, precedence.ATOM)
        for arg in node.arguments:
            self._write(' ')
            self.visit(arg)
        for redir in node.redirections:
            self._write(' ')
            self.visit(redir)

    def visit_Ps1CommandArgument(self, node: Ps1CommandArgument):
        if node.kind == Ps1CommandArgumentKind.SWITCH:
            self._write(node.name)
        elif node.kind == Ps1CommandArgumentKind.NAMED:
            self._write(F'{node.name}:')
            if node.value:
                self._emit_argument_value(node.value)
        elif node.kind == Ps1CommandArgumentKind.POSITIONAL:
            if node.value:
                self._emit_argument_value(node.value)

    def _emit_argument_value(self, value: Expression):
        """
        An argument is read back by the rule that reads one bare, which reaches nothing that an
        operator holds together: an operator would reach across the arguments beside this one, a
        range re-lexes as a single bare word in argument mode, and a command swallows the rest of
        the line. The comma is bracketed too, even though it binds tighter than all of them,
        because it is what separates one argument from the next.

        Two spellings survive the precedence scale and are still read as something else here, and
        both are bracketed. A numeral whose spelling this slot does not end where the tree does is
        one: `Write-Output -1` passes the String `-1` where `Write-Output (-1)` passes an Int32, and
        `f +1` and `f -0.0` are words the same way. A cast is the other, measured: `f [byte] 5` is
        the one word `[byte]5`.

        Neither bracket can change what a source wrote, because neither spelling can reach here from
        one. A3a made the lexer read both the way 5.1 does — a dash or a bracket in an argument
        begins a word — so a numeral or a cast standing in this slot is one a pass put here, and
        bracketing it is what makes it mean what the pass meant.
        """
        raw = _numeral_spelling(value)
        misread = isinstance(value, (Ps1CastExpression, Ps1TypeExpression)) or (
            raw is not None and not reads_as_one_numeral(raw, '', Ps1LexerMode.ARGUMENT)
        )
        if misread:
            self._emit_bracketed(value)
        else:
            self._emit_word(value, precedence.COMMA + 1)

    def visit_Ps1AssignmentExpression(self, node: Ps1AssignmentExpression):
        if node.target:
            # The target is delimited by the operator that follows it, and a multi-assignment
            # writes through a comma-built list of targets, so this slot brackets nothing either.
            self.visit(node.target)
        self._write(F' {node.operator} ')
        if (value := node.value) is not None:
            # Nothing is bracketed here. The right side of an assignment runs to the end of the
            # statement, so there is nothing beside it for a command or an operator to reach into;
            # an assignment standing somewhere tighter is bracketed by that slot instead.
            self.visit(value)

    def visit_Ps1ArrayLiteral(self, node: Ps1ArrayLiteral):
        # A one-element array is written with the leading unary comma that builds it. Printing the
        # element alone yields the element, not an array of it, which is why
        # `New-Object IO.MemoryStream(,$bytes)` must keep its comma: without it the constructor is
        # handed the buffer's elements as separate arguments and throws.
        if len(node.elements) == 1:
            self._write(',')
        # An array standing where bare words are read as values holds its elements in that same
        # slot: `foo a, b` passes two words. Bracket the array and they are in a pipeline instead,
        # where the first would become a command name, so the arming is not passed on.
        word_slot = self._word_slot
        for i, elem in enumerate(node.elements):
            if i > 0:
                self._write(', ')
            self._word_slot_ahead = word_slot
            self._emit_operand(elem, precedence.COMMA + 1)

    def visit_Ps1ArrayExpression(self, node: Ps1ArrayExpression):
        self._write('@(')
        self._emit_statement_list(node.body)
        self._write(')')

    def visit_Ps1HashLiteral(self, node: Ps1HashLiteral):
        self._write('@{')
        if node.pairs:
            self._depth += 1
            for key, value in node.pairs:
                self._newline()
                # A key is read as a word rather than as a command: `@{ Name = 1 }` is a hash of
                # one entry, not a call to `Name`.
                self._emit_word(key, precedence.COMMA + 1)
                self._write(' = ')
                with self._reading(Ps1LexerMode.EXPRESSION):
                    self.visit(value)
            self._depth -= 1
            self._newline()
        self._write('}')

    def visit_Ps1SubExpression(self, node: Ps1SubExpression):
        self._write('$(')
        self._emit_statement_list(node.body)
        self._write(')')

    def visit_Ps1ParenExpression(self, node: Ps1ParenExpression):
        self._write('(')
        if node.expression:
            with self._reading(Ps1LexerMode.EXPRESSION):
                self.visit(node.expression)
        self._write(')')

    def _emit_script_body(self, node: Ps1Code, *, newline_after: bool):
        has_named = (
            node.begin_block or node.process_block
            or node.end_block or node.dynamicparam_block
        )
        if has_named:
            for keyword, block in (
                ('begin', node.begin_block),
                ('process', node.process_block),
                ('end', node.end_block),
                ('dynamicparam', node.dynamicparam_block),
            ):
                if block:
                    if not newline_after:
                        self._newline()
                    self._write(F'{keyword} ')
                    self._emit_block(block)
                    if newline_after:
                        self._newline()
        else:
            if newline_after:
                self._emit_statement_list(node.body)
            else:
                for stmt in node.body:
                    self._newline()
                    self.visit(stmt)

    def visit_Ps1ScriptBlock(self, node: Ps1ScriptBlock):
        self._write('{')
        self._depth += 1
        if node.param_block:
            self._newline()
            self.visit(node.param_block)
        self._emit_script_body(node, newline_after=False)
        self._depth -= 1
        has_content = (
            node.body or node.param_block
            or node.begin_block or node.process_block
            or node.end_block or node.dynamicparam_block
        )
        if has_content:
            self._newline()
        self._write('}')

    def visit_Ps1RangeExpression(self, node: Ps1RangeExpression):
        if node.start:
            self._emit_operand(node.start, precedence.RANGE)
        self._write('..')
        if node.end:
            self._emit_operand(node.end, precedence.RANGE + 1)

    def _captured(self, emit: Callable[[], object]) -> str:
        """
        What `emit` writes, handed back instead of written. The emission itself is the real one and
        runs once, so whatever it does to the arming a slot set is done exactly as it would be.
        """
        saved = self._parts
        self._parts = io.StringIO()
        try:
            emit()
            return self._parts.getvalue()
        finally:
            self._parts = saved

    def _render_to_string(self, node: Node) -> str:
        return self._captured(lambda: self.visit(node))

    def _operand_to_string(self, node: Expression, minimum: int) -> str:
        """
        What `_emit_operand` would write for `node`, including any bracket it decides on. A slot
        that has to know what its content begins with cannot ask the tree: the answer is the first
        character of a rendering, which no single node holds.
        """
        return self._captured(lambda: self._emit_operand(node, minimum))

    def visit_Ps1Attribute(self, node: Ps1Attribute):
        # The argument list is written even when it is empty, because it is what distinguishes an
        # attribute from a type constraint: `[CmdletBinding]` reads back as the type `CmdletBinding`
        # and is then dropped, and `class C { [ValidateNotNull()] [int] $P }` loses the `[int]` to
        # the same confusion.
        self._write(F'[{node.name}(')
        items: list[str] = []
        for arg in node.positional_args:
            items.append(self._render_to_string(arg))
        for key, val in node.named_args:
            items.append(F'{key}={self._render_to_string(val)}')
        self._write(', '.join(items))
        self._write(')]')

    def visit_Ps1ParameterDeclaration(self, node: Ps1ParameterDeclaration):
        for attr in node.attributes:
            self.visit(attr)
        if node.variable:
            self.visit(node.variable)
        if node.default_value:
            self._write(' = ')
            # The commas of the parameter list delimit this slot exactly as a command's arguments
            # delimit theirs, so a default that is a command or is built with a comma needs to say
            # where it ends.
            self._emit_operand(node.default_value, precedence.COMMA + 1)

    def visit_Ps1ParamBlock(self, node: Ps1ParamBlock):
        for attr in node.attributes:
            self.visit(attr)
            self._newline()
        self._write(KEYWORD_SPELLING.get('param', 'param'))
        self._write('(')
        for i, param in enumerate(node.parameters):
            if i > 0:
                self._write(', ')
            self.visit(param)
        self._write(')')

    def _emit_redirection_stream(self, stream: Ps1RedirectionStream) -> str:
        if stream == Ps1RedirectionStream.OUTPUT:
            return ''
        if stream == Ps1RedirectionStream.ALL:
            return '*'
        return str(stream.value)

    def visit_Ps1FileRedirection(self, node: Ps1FileRedirection):
        prefix = self._emit_redirection_stream(node.stream)
        op = '>>' if node.append else '>'
        self._write(F'{prefix}{op}')
        if node.target:
            # A redirection names its file the way a command names an argument, so a path may
            # stand there without quotes.
            self._write(' ')
            self._emit_word(node.target, precedence.COMMA + 1)

    def visit_Ps1InputRedirection(self, node: Ps1InputRedirection):
        self._write('<')
        if node.source:
            self._write(' ')
            self._emit_word(node.source, precedence.COMMA + 1)

    def visit_Ps1MergingRedirection(self, node: Ps1MergingRedirection):
        # A file redirection of the output stream is written bare, but a merge names its source
        # even then: dropping the `1` from `1>&2` produces `>&2`, which is a file redirection to
        # the target `&2` and does not read back as the statement that was written.
        if node.from_stream is Ps1RedirectionStream.ALL:
            prefix = '*'
        else:
            prefix = str(node.from_stream.value)
        self._write(F'{prefix}>&{node.to_stream.value}')

    def visit_Ps1PipelineElement(self, node: Ps1PipelineElement):
        if node.expression:
            self.visit(node.expression)
        for redir in node.redirections:
            self._write(' ')
            self.visit(redir)

    def visit_Ps1Pipeline(self, node: Ps1Pipeline):
        for i, elem in enumerate(node.elements):
            if i > 0:
                self._write(' | ')
            self.visit(elem)

    def visit_Ps1ExpressionStatement(self, node: Ps1ExpressionStatement):
        if node.expression:
            self.visit(node.expression)

    def visit_Ps1IfStatement(self, node: Ps1IfStatement):
        for i, (cond, body) in enumerate(node.clauses):
            if i == 0:
                self._write('if (')
            else:
                self._write(' elseif (')
            if cond:
                self.visit(cond)
            self._write(') ')
            self._emit_block(body)
        if node.else_block:
            self._write(' else ')
            self._emit_block(node.else_block)

    def visit_Ps1WhileLoop(self, node: Ps1WhileLoop):
        if node.label:
            self._write(F'{node.label} ')
        self._write('while (')
        if node.condition:
            self.visit(node.condition)
        self._write(') ')
        if node.body:
            self._emit_block(node.body)

    def visit_Ps1DoLoop(self, node: Ps1DoLoop):
        if node.label:
            self._write(F'{node.label} ')
        self._write('do ')
        if node.body:
            self._emit_block(node.body)
        keyword = 'until' if node.is_until else 'while'
        self._write(F' {keyword} (')
        if node.condition:
            self.visit(node.condition)
        self._write(')')

    def visit_Ps1ForLoop(self, node: Ps1ForLoop):
        if node.label:
            self._write(F'{node.label} ')
        self._write('for (')
        if node.initializer:
            self.visit(node.initializer)
        self._write('; ')
        if node.condition:
            self.visit(node.condition)
        self._write('; ')
        if node.iterator:
            self.visit(node.iterator)
        self._write(') ')
        if node.body:
            self._emit_block(node.body)

    def visit_Ps1ForEachLoop(self, node: Ps1ForEachLoop):
        if node.label:
            self._write(F'{node.label} ')
        self._write('foreach ')
        if node.parallel:
            self._write('-Parallel ')
        self._write('(')
        if node.variable:
            self.visit(node.variable)
        self._write(' in ')
        if node.iterable:
            self.visit(node.iterable)
        self._write(') ')
        if node.body:
            self._emit_block(node.body)

    def visit_Ps1SwitchStatement(self, node: Ps1SwitchStatement):
        if node.label:
            self._write(F'{node.label} ')
        self._write('switch ')
        if node.regex:
            self._write('-Regex ')
        if node.wildcard:
            self._write('-Wildcard ')
        if node.exact:
            self._write('-Exact ')
        if node.case_sensitive:
            self._write('-CaseSensitive ')
        if node.file:
            self._write('-File ')
            if node.value:
                self.visit(node.value)
            self._write(' {')
        else:
            self._write('(')
            if node.value:
                self.visit(node.value)
            self._write(') {')
        self._depth += 1
        for cond, body in node.clauses:
            self._newline()
            if cond is None:
                self._write('default ')
            else:
                # A clause is matched against a pattern, so a bare word here is the string it
                # spells rather than a command to run.
                self._emit_word(cond, precedence.COMMA + 1)
                self._write(' ')
            self._emit_block(body)
        self._depth -= 1
        self._newline()
        self._write('}')

    def visit_Ps1TryCatchFinally(self, node: Ps1TryCatchFinally):
        self._write('try ')
        if node.try_block:
            self._emit_block(node.try_block)
        for clause in node.catch_clauses:
            self._write(' catch')
            if clause.types:
                self._write(' ')
                self._write(', '.join(F'[{t}]' for t in clause.types))
            self._write(' ')
            if clause.body:
                self._emit_block(clause.body)
        if node.finally_block:
            self._write(' finally ')
            self._emit_block(node.finally_block)

    def visit_Ps1TrapStatement(self, node: Ps1TrapStatement):
        self._write('trap ')
        if node.type_name:
            self._write(F'[{node.type_name}] ')
        if node.body:
            self._emit_block(node.body)

    def visit_Ps1FunctionDefinition(self, node: Ps1FunctionDefinition):
        kw = 'filter' if node.is_filter else 'function'
        self._write(F'{kw} {node.name} ')
        if node.body:
            self.visit(node.body)

    def _emit_member_modifiers(self, modifiers: Ps1MemberModifier):
        if Ps1MemberModifier.STATIC in modifiers:
            self._write('static ')
        if Ps1MemberModifier.HIDDEN in modifiers:
            self._write('hidden ')

    def visit_Ps1PropertyMember(self, node: Ps1PropertyMember):
        for attr in node.attributes:
            self.visit(attr)
        self._emit_member_modifiers(node.modifiers)
        if node.type_constraint:
            self.visit(node.type_constraint)
        if node.variable:
            self.visit(node.variable)
        if node.initial_value:
            self._write(' = ')
            self.visit(node.initial_value)

    def visit_Ps1MethodMember(self, node: Ps1MethodMember):
        for attr in node.attributes:
            self.visit(attr)
        self._emit_member_modifiers(node.modifiers)
        if node.return_type:
            self.visit(node.return_type)
            self._write(' ')
        if node.definition:
            funcdef = node.definition
            self._write(F'{funcdef.name}(')
            if funcdef.body and funcdef.body.param_block:
                for i, param in enumerate(funcdef.body.param_block.parameters):
                    if i > 0:
                        self._write(', ')
                    self.visit(param)
            self._write(') {')
            self._depth += 1
            if funcdef.body:
                self._emit_script_body(funcdef.body, newline_after=False)
            self._depth -= 1
            has_content = funcdef.body and (
                funcdef.body.body
                or funcdef.body.begin_block
                or funcdef.body.process_block
                or funcdef.body.end_block
                or funcdef.body.dynamicparam_block
            )
            if has_content:
                self._newline()
            self._write('}')

    def visit_Ps1ClassDefinition(self, node: Ps1ClassDefinition):
        self._write(F'class {node.name}')
        if node.base_types:
            self._write(' : ')
            self._write(', '.join(node.base_types))
        self._write(' {')
        self._depth += 1
        for member in node.members:
            self._newline()
            self.visit(member)
        self._depth -= 1
        if node.members:
            self._newline()
        self._write('}')

    def visit_Ps1EnumMember(self, node: Ps1EnumMember):
        self._write(node.name)
        if node.value is not None:
            self._write(' = ')
            self.visit(node.value)

    def visit_Ps1EnumDefinition(self, node: Ps1EnumDefinition):
        self._write(F'enum {node.name}')
        if node.base_type:
            self._write(F' : {node.base_type}')
        self._write(' {')
        self._depth += 1
        for member in node.members:
            self._newline()
            self.visit(member)
        self._depth -= 1
        if node.members:
            self._newline()
        self._write('}')

    def _visit_jump(self, node: Ps1Jump, name: str):
        self._write(name)
        if suffix := node.label:
            # The label is read the way an argument is, and the colon in `break :outer` is part of
            # that spelling rather than of the name: quoted, it would name a label called `:outer`.
            self._write(' ')
            self._emit_word(suffix, precedence.COMMA + 1)

    def _visit_exit(self, node: Ps1Exit, name: str):
        self._write(name)
        if suffix := node.pipeline:
            self._write(' ')
            self.visit(suffix)

    def visit_Ps1ReturnStatement(self, node: Ps1ReturnStatement):
        self._visit_exit(node, 'return')

    def visit_Ps1ExitStatement(self, node: Ps1ExitStatement):
        self._visit_exit(node, 'exit')

    def visit_Ps1ThrowStatement(self, node: Ps1ThrowStatement):
        self._visit_exit(node, 'throw')

    def visit_Ps1BreakStatement(self, node: Ps1BreakStatement):
        self._visit_jump(node, 'break')

    def visit_Ps1ContinueStatement(self, node: Ps1ContinueStatement):
        self._visit_jump(node, 'continue')

    def visit_Ps1DataSection(self, node: Ps1DataSection):
        self._write('data ')
        if node.name:
            self._write(F'{node.name} ')
        if node.commands:
            self._write('-SupportedCommand ')
            for i, cmd in enumerate(node.commands):
                if i > 0:
                    self._write(', ')
                self.visit(cmd)
            self._write(' ')
        if node.body:
            self._emit_block(node.body)

    def visit_Ps1ErrorNode(self, node: Ps1ErrorNode):
        self._write(node.text)

    def visit_Ps1Script(self, node: Ps1Script):
        if node.param_block:
            self.visit(node.param_block)
            self._newline()
        self._emit_script_body(node, newline_after=True)

    def visit_Block(self, node: Block):
        self._emit_block(node)

Classes

class Ps1Synthesizer (*args, **kwargs)

Three things decide how a node is written, and all three are properties of the slot it goes into rather than of the node. How tightly the slot binds decides whether a bracket is needed, and refinery.lib.scripts.ps1.precedence is that scale. Whether the slot reads a bare word as a value decides how a leaf is spelled, and that is _word_slot. How the text will be lexed back decides where a spelling runs on into what touches it, and that is _mode.

A word with no quotes means a value where a command's name and arguments are read, and begins a command everywhere else. So foo a, b may keep its words while foo (a, b) may not — the bracket makes a a command name, and 5.1 then rejects the whole line. The parser's raw is only true of the slot it was read from, which is why replaying it is not enough.

The arming and the mode are close relatives and are not the same thing. The arming is spent on one node, because only the leaf standing in the slot is spelled by it; the mode holds until a delimiter is written, because 5.1 goes on lexing a command's arguments in command mode until something opens a new one. Write-Output $t.GetType() is where they part: the member access takes the arming, and the numeral several levels under it is still in the argument's text.

Expand source code Browse git
class Ps1Synthesizer(Synthesizer):
    """
    Three things decide how a node is written, and all three are properties of the slot it goes into
    rather than of the node. How tightly the slot binds decides whether a bracket is needed, and
    `refinery.lib.scripts.ps1.precedence` is that scale. Whether the slot reads a bare word as a
    value decides how a leaf is spelled, and that is `_word_slot`. How the text will be lexed back
    decides where a spelling runs on into what touches it, and that is `_mode`.

    A word with no quotes means a value where a command's name and arguments are read, and begins a
    command everywhere else. So `foo a, b` may keep its words while `foo (a, b)` may not — the
    bracket makes `a` a command name, and 5.1 then rejects the whole line. The parser's `raw` is
    only true of the slot it was read from, which is why replaying it is not enough.

    The arming and the mode are close relatives and are not the same thing. The arming is spent on
    one node, because only the leaf standing in the slot is spelled by it; the mode holds until a
    delimiter is written, because 5.1 goes on lexing a command's arguments in command mode until
    something opens a new one. `Write-Output $t.GetType()` is where they part: the member access
    takes the arming, and the numeral several levels under it is still in the argument's text.
    """

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self._word_slot_ahead = False
        self._word_slot = False
        self._mode = Ps1LexerMode.EXPRESSION

    def visit(self, node: Node) -> Node | None:
        """
        Take the arming set by the slot, so that it applies to this node and no other. A slot that
        arms nothing yields the quoted spelling, which is the reading that is valid everywhere;
        forgetting to arm one therefore costs a pair of quotes rather than the meaning of a script.
        """
        self._word_slot, self._word_slot_ahead = self._word_slot_ahead, False
        return super().visit(node)

    @contextlib.contextmanager
    def _reading(self, mode: Ps1LexerMode):
        """
        Write what follows as text that will be lexed in `mode`, and put back the mode that was
        running when it is done.
        """
        saved, self._mode = self._mode, mode
        try:
            yield
        finally:
            self._mode = saved

    def _emit_word(self, node: Expression, minimum: int):
        """
        Write `node` into a slot that reads a bare word as a value, which is the slot 5.1 lexes in
        command mode.
        """
        self._word_slot_ahead = True
        with self._reading(Ps1LexerMode.ARGUMENT):
            self._emit_operand(node, minimum)

    def _emit_block(self, block: Block):
        self._write('{')
        self._depth += 1
        for stmt in block.body:
            self._newline()
            with self._reading(Ps1LexerMode.EXPRESSION):
                self.visit(stmt)
        self._depth -= 1
        if block.body:
            self._newline()
        self._write('}')

    def _emit_statement_list(self, stmts: list):
        for i, stmt in enumerate(stmts):
            if i > 0:
                self._newline()
            with self._reading(Ps1LexerMode.EXPRESSION):
                self.visit(stmt)

    @staticmethod
    def _variable_scope_prefix(node: Ps1Variable) -> str:
        if node.scope == Ps1ScopeModifier.NONE:
            return ''
        if node.scope == Ps1ScopeModifier.DRIVE:
            return F'{node.drive}:'
        return F'{node.scope.value}:'

    def visit_Ps1Variable(self, node: Ps1Variable):
        prefix = '@' if node.splatted else '$'
        body = F'{self._variable_scope_prefix(node)}{node.name}'
        if node.braced:
            body = F'{{{body}}}'
        self._write(F'{prefix}{body}')

    def visit_Ps1IntegerLiteral(self, node: Ps1IntegerLiteral):
        """
        A numeral is written exactly as it is spelled, and a numeral the source spelled is never
        re-spelled. Where it stands in a command argument the text itself is passed on: 5.1 wraps a
        literal argument so that `PSObject.TokenText` keeps what was written, and the receiving
        command reads that back — `Write-Host 1.10` prints `1.10` and `notepad.exe 0x10` receives
        `0x10` rather than `16`. Holding only `raw` is what makes that true by construction, and a
        pass that normalized one would break it here without anything noticing.
        """
        self._write(node.raw)

    def visit_Ps1RealLiteral(self, node: Ps1RealLiteral):
        self._write(node.raw)

    def visit_Ps1StringLiteral(self, node: Ps1StringLiteral):
        if '\n' in node.raw:
            self._write(F'"{self._escape_for_dq(node.value)}"')
        elif node.is_bare_word and not self._word_slot:
            self._write(F"'{self._escape_for_sq(node.value)}'")
        else:
            self._write(node.raw)

    def visit_Ps1ExpandableString(self, node: Ps1ExpandableString):
        self._emit_expandable_parts(node.parts)

    def _emit_expandable_parts(self, parts):
        self._write('"')
        for part in parts:
            if isinstance(part, Ps1StringLiteral):
                self._write(self._escape_for_dq(part.value))
            elif isinstance(part, Ps1Variable):
                self._emit_variable_in_dq(part)
            else:
                self.visit(part)
        self._write('"')

    def _emit_variable_in_dq(self, node: Ps1Variable):
        prefix = '@' if node.splatted else '$'
        self._write(F'{prefix}{{{self._variable_scope_prefix(node)}{node.name}}}')

    @staticmethod
    def _escape_for_sq(value: str) -> str:
        return value.replace("'", "''")

    @staticmethod
    def _escape_for_dq(value: str) -> str:
        for c in '`"$':
            value = value.replace(c, F'`{c}')
        for ch, esc in BACKTICK_ENCODE.items():
            value = value.replace(ch, esc)
        return value

    def visit_Ps1HereString(self, node: Ps1HereString):
        if '\n' in node.value:
            self._write(F'"{self._escape_for_dq(node.value)}"')
        else:
            self._write(node.raw)

    def visit_Ps1ExpandableHereString(self, node: Ps1ExpandableHereString):
        # Emit from the (possibly transform-rewritten) parts rather than the stale `raw`, otherwise
        # an inlined variable/constant would be lost while its source assignment is removed. A
        # double-quoted expandable string is semantically equivalent to the here-string.
        self._emit_expandable_parts(node.parts)

    def _emit_operand(self, node: Expression, minimum: int):
        """
        Write `node` into a slot that binds at least as tightly as `minimum`, bracketing it when it
        does not. Every slot that can absorb what is printed beside it goes through here, naming
        what it requires; a tree built by a pass carries no parentheses of its own, so this is the
        only thing standing between `Binary(Binary(1, '+', 2), '*', 3)` and `1 + 2 * 3`.
        """
        if precedence.needs_brackets(node, minimum):
            self._emit_bracketed(node)
        else:
            self.visit(node)

    def _emit_bracketed(self, node: Expression):
        """
        Write `node` inside a bracket. What stands inside one is read as a pipeline, so the slot the
        bracket creates is never one that reads a bare word as a value, whatever the slot outside it
        was, and the text in it is lexed as an expression however it got here.
        """
        self._word_slot_ahead = False
        self._write('(')
        with self._reading(Ps1LexerMode.EXPRESSION):
            self.visit(node)
        self._write(')')

    def visit_Ps1BinaryExpression(self, node: Ps1BinaryExpression):
        # The left spine is walked rather than recursed through, because a folded concatenation is
        # thousands of operators deep and recursion would not reach the end of one. Walking stops
        # where a left operand binds more loosely than its parent, since that one needs a bracket
        # and so is not part of the same flat chain.
        spine: list[tuple[str, Expression | None, int]] = []
        current = node
        while True:
            power = precedence.of_operator(current.operator)
            spine.append((current.operator, current.right, power))
            left = current.left
            if (
                isinstance(left, Ps1BinaryExpression)
                and precedence.of_operator(left.operator) >= power
            ):
                current = left
                continue
            break
        if (head := current.left) is not None:
            self._emit_operand(head, spine[-1][2])
        for operator, right, power in reversed(spine):
            self._write(F' {operator} ')
            if right is not None:
                self._emit_operand(right, power + 1)

    def visit_Ps1UnaryExpression(self, node: Ps1UnaryExpression):
        operand = node.operand
        if not node.prefix:
            if operand is not None:
                self._emit_operand(operand, precedence.UNARY)
            self._write(node.operator)
            return
        spelling = '' if operand is None else self._operand_to_string(operand, precedence.UNARY)
        self._write(node.operator)
        if node.operator.startswith('-') and len(node.operator) > 1:
            self._write(' ')
        elif node.operator in ('+', '-') and _fuses_with_a_sign(spelling):
            self._write(' ')
        self._write(spelling)

    def visit_Ps1TypeExpression(self, node: Ps1TypeExpression):
        self._write(F'[{node.name}]')

    def visit_Ps1CastExpression(self, node: Ps1CastExpression):
        self._write(F'[{node.type_name}]')
        if node.operand:
            self._emit_operand(node.operand, precedence.UNARY)

    def _emit_receiver(self, node: Expression, access: str):
        """
        Write `node` as the thing `access` reads from. The receiver has to be a primary expression:
        `.` and `::` bind tighter than anything written with an operator, so `(Get-Variable Y).Tls`
        printed bare would read the member off the last argument of the command rather than off its
        result.

        A numeral needs more than that, because it does not end where the tree says it does: `3` in
        front of `.ToString` is the one word `3.ToString`, and in front of `[0]` or `::MaxValue`
        every numeral is, since neither bracket nor colon ends one. The lexer is asked, so that what
        is written here and what reads it back are the same rule. No space is ever written before
        the access instead: `(3) .ToString()` is a parse error, measured.
        """
        raw = _numeral_spelling(node)
        if raw is not None and not reads_as_one_numeral(raw, access, self._mode):
            self._emit_bracketed(node)
        else:
            self._emit_operand(node, precedence.ATOM)

    def _emit_member_prefix(self, node: Ps1MemberAccess | Ps1InvokeMember):
        if node.object:
            self._emit_receiver(node.object, node.access.value)
        self._write(node.access.value)
        if isinstance(node.member, Expression):
            self.visit(node.member)
        else:
            self._write(str(node.member))

    def visit_Ps1MemberAccess(self, node: Ps1MemberAccess):
        self._emit_member_prefix(node)

    def visit_Ps1IndexExpression(self, node: Ps1IndexExpression):
        if node.object:
            self._emit_receiver(node.object, '[')
        self._write('[')
        if node.index:
            with self._reading(Ps1LexerMode.EXPRESSION):
                self.visit(node.index)
        self._write(']')

    def visit_Ps1InvokeMember(self, node: Ps1InvokeMember):
        self._emit_member_prefix(node)
        self._write('(')
        with self._reading(Ps1LexerMode.EXPRESSION):
            for i, arg in enumerate(node.arguments):
                if i > 0:
                    self._write(', ')
                if precedence.needs_brackets_between_delimiters(arg):
                    self._write('(')
                    self.visit(arg)
                    self._write(')')
                else:
                    self.visit(arg)
        self._write(')')

    def visit_Ps1CommandInvocation(self, node: Ps1CommandInvocation):
        if node.invocation_operator:
            self._write(node.invocation_operator)
            self._write(' ')
        if node.name:
            # `& 'i' + 'ex'` invokes `i` and then concatenates, so a computed command name needs
            # brackets for the invocation operator to reach the whole expression.
            self._emit_word(node.name, precedence.ATOM)
        for arg in node.arguments:
            self._write(' ')
            self.visit(arg)
        for redir in node.redirections:
            self._write(' ')
            self.visit(redir)

    def visit_Ps1CommandArgument(self, node: Ps1CommandArgument):
        if node.kind == Ps1CommandArgumentKind.SWITCH:
            self._write(node.name)
        elif node.kind == Ps1CommandArgumentKind.NAMED:
            self._write(F'{node.name}:')
            if node.value:
                self._emit_argument_value(node.value)
        elif node.kind == Ps1CommandArgumentKind.POSITIONAL:
            if node.value:
                self._emit_argument_value(node.value)

    def _emit_argument_value(self, value: Expression):
        """
        An argument is read back by the rule that reads one bare, which reaches nothing that an
        operator holds together: an operator would reach across the arguments beside this one, a
        range re-lexes as a single bare word in argument mode, and a command swallows the rest of
        the line. The comma is bracketed too, even though it binds tighter than all of them,
        because it is what separates one argument from the next.

        Two spellings survive the precedence scale and are still read as something else here, and
        both are bracketed. A numeral whose spelling this slot does not end where the tree does is
        one: `Write-Output -1` passes the String `-1` where `Write-Output (-1)` passes an Int32, and
        `f +1` and `f -0.0` are words the same way. A cast is the other, measured: `f [byte] 5` is
        the one word `[byte]5`.

        Neither bracket can change what a source wrote, because neither spelling can reach here from
        one. A3a made the lexer read both the way 5.1 does — a dash or a bracket in an argument
        begins a word — so a numeral or a cast standing in this slot is one a pass put here, and
        bracketing it is what makes it mean what the pass meant.
        """
        raw = _numeral_spelling(value)
        misread = isinstance(value, (Ps1CastExpression, Ps1TypeExpression)) or (
            raw is not None and not reads_as_one_numeral(raw, '', Ps1LexerMode.ARGUMENT)
        )
        if misread:
            self._emit_bracketed(value)
        else:
            self._emit_word(value, precedence.COMMA + 1)

    def visit_Ps1AssignmentExpression(self, node: Ps1AssignmentExpression):
        if node.target:
            # The target is delimited by the operator that follows it, and a multi-assignment
            # writes through a comma-built list of targets, so this slot brackets nothing either.
            self.visit(node.target)
        self._write(F' {node.operator} ')
        if (value := node.value) is not None:
            # Nothing is bracketed here. The right side of an assignment runs to the end of the
            # statement, so there is nothing beside it for a command or an operator to reach into;
            # an assignment standing somewhere tighter is bracketed by that slot instead.
            self.visit(value)

    def visit_Ps1ArrayLiteral(self, node: Ps1ArrayLiteral):
        # A one-element array is written with the leading unary comma that builds it. Printing the
        # element alone yields the element, not an array of it, which is why
        # `New-Object IO.MemoryStream(,$bytes)` must keep its comma: without it the constructor is
        # handed the buffer's elements as separate arguments and throws.
        if len(node.elements) == 1:
            self._write(',')
        # An array standing where bare words are read as values holds its elements in that same
        # slot: `foo a, b` passes two words. Bracket the array and they are in a pipeline instead,
        # where the first would become a command name, so the arming is not passed on.
        word_slot = self._word_slot
        for i, elem in enumerate(node.elements):
            if i > 0:
                self._write(', ')
            self._word_slot_ahead = word_slot
            self._emit_operand(elem, precedence.COMMA + 1)

    def visit_Ps1ArrayExpression(self, node: Ps1ArrayExpression):
        self._write('@(')
        self._emit_statement_list(node.body)
        self._write(')')

    def visit_Ps1HashLiteral(self, node: Ps1HashLiteral):
        self._write('@{')
        if node.pairs:
            self._depth += 1
            for key, value in node.pairs:
                self._newline()
                # A key is read as a word rather than as a command: `@{ Name = 1 }` is a hash of
                # one entry, not a call to `Name`.
                self._emit_word(key, precedence.COMMA + 1)
                self._write(' = ')
                with self._reading(Ps1LexerMode.EXPRESSION):
                    self.visit(value)
            self._depth -= 1
            self._newline()
        self._write('}')

    def visit_Ps1SubExpression(self, node: Ps1SubExpression):
        self._write('$(')
        self._emit_statement_list(node.body)
        self._write(')')

    def visit_Ps1ParenExpression(self, node: Ps1ParenExpression):
        self._write('(')
        if node.expression:
            with self._reading(Ps1LexerMode.EXPRESSION):
                self.visit(node.expression)
        self._write(')')

    def _emit_script_body(self, node: Ps1Code, *, newline_after: bool):
        has_named = (
            node.begin_block or node.process_block
            or node.end_block or node.dynamicparam_block
        )
        if has_named:
            for keyword, block in (
                ('begin', node.begin_block),
                ('process', node.process_block),
                ('end', node.end_block),
                ('dynamicparam', node.dynamicparam_block),
            ):
                if block:
                    if not newline_after:
                        self._newline()
                    self._write(F'{keyword} ')
                    self._emit_block(block)
                    if newline_after:
                        self._newline()
        else:
            if newline_after:
                self._emit_statement_list(node.body)
            else:
                for stmt in node.body:
                    self._newline()
                    self.visit(stmt)

    def visit_Ps1ScriptBlock(self, node: Ps1ScriptBlock):
        self._write('{')
        self._depth += 1
        if node.param_block:
            self._newline()
            self.visit(node.param_block)
        self._emit_script_body(node, newline_after=False)
        self._depth -= 1
        has_content = (
            node.body or node.param_block
            or node.begin_block or node.process_block
            or node.end_block or node.dynamicparam_block
        )
        if has_content:
            self._newline()
        self._write('}')

    def visit_Ps1RangeExpression(self, node: Ps1RangeExpression):
        if node.start:
            self._emit_operand(node.start, precedence.RANGE)
        self._write('..')
        if node.end:
            self._emit_operand(node.end, precedence.RANGE + 1)

    def _captured(self, emit: Callable[[], object]) -> str:
        """
        What `emit` writes, handed back instead of written. The emission itself is the real one and
        runs once, so whatever it does to the arming a slot set is done exactly as it would be.
        """
        saved = self._parts
        self._parts = io.StringIO()
        try:
            emit()
            return self._parts.getvalue()
        finally:
            self._parts = saved

    def _render_to_string(self, node: Node) -> str:
        return self._captured(lambda: self.visit(node))

    def _operand_to_string(self, node: Expression, minimum: int) -> str:
        """
        What `_emit_operand` would write for `node`, including any bracket it decides on. A slot
        that has to know what its content begins with cannot ask the tree: the answer is the first
        character of a rendering, which no single node holds.
        """
        return self._captured(lambda: self._emit_operand(node, minimum))

    def visit_Ps1Attribute(self, node: Ps1Attribute):
        # The argument list is written even when it is empty, because it is what distinguishes an
        # attribute from a type constraint: `[CmdletBinding]` reads back as the type `CmdletBinding`
        # and is then dropped, and `class C { [ValidateNotNull()] [int] $P }` loses the `[int]` to
        # the same confusion.
        self._write(F'[{node.name}(')
        items: list[str] = []
        for arg in node.positional_args:
            items.append(self._render_to_string(arg))
        for key, val in node.named_args:
            items.append(F'{key}={self._render_to_string(val)}')
        self._write(', '.join(items))
        self._write(')]')

    def visit_Ps1ParameterDeclaration(self, node: Ps1ParameterDeclaration):
        for attr in node.attributes:
            self.visit(attr)
        if node.variable:
            self.visit(node.variable)
        if node.default_value:
            self._write(' = ')
            # The commas of the parameter list delimit this slot exactly as a command's arguments
            # delimit theirs, so a default that is a command or is built with a comma needs to say
            # where it ends.
            self._emit_operand(node.default_value, precedence.COMMA + 1)

    def visit_Ps1ParamBlock(self, node: Ps1ParamBlock):
        for attr in node.attributes:
            self.visit(attr)
            self._newline()
        self._write(KEYWORD_SPELLING.get('param', 'param'))
        self._write('(')
        for i, param in enumerate(node.parameters):
            if i > 0:
                self._write(', ')
            self.visit(param)
        self._write(')')

    def _emit_redirection_stream(self, stream: Ps1RedirectionStream) -> str:
        if stream == Ps1RedirectionStream.OUTPUT:
            return ''
        if stream == Ps1RedirectionStream.ALL:
            return '*'
        return str(stream.value)

    def visit_Ps1FileRedirection(self, node: Ps1FileRedirection):
        prefix = self._emit_redirection_stream(node.stream)
        op = '>>' if node.append else '>'
        self._write(F'{prefix}{op}')
        if node.target:
            # A redirection names its file the way a command names an argument, so a path may
            # stand there without quotes.
            self._write(' ')
            self._emit_word(node.target, precedence.COMMA + 1)

    def visit_Ps1InputRedirection(self, node: Ps1InputRedirection):
        self._write('<')
        if node.source:
            self._write(' ')
            self._emit_word(node.source, precedence.COMMA + 1)

    def visit_Ps1MergingRedirection(self, node: Ps1MergingRedirection):
        # A file redirection of the output stream is written bare, but a merge names its source
        # even then: dropping the `1` from `1>&2` produces `>&2`, which is a file redirection to
        # the target `&2` and does not read back as the statement that was written.
        if node.from_stream is Ps1RedirectionStream.ALL:
            prefix = '*'
        else:
            prefix = str(node.from_stream.value)
        self._write(F'{prefix}>&{node.to_stream.value}')

    def visit_Ps1PipelineElement(self, node: Ps1PipelineElement):
        if node.expression:
            self.visit(node.expression)
        for redir in node.redirections:
            self._write(' ')
            self.visit(redir)

    def visit_Ps1Pipeline(self, node: Ps1Pipeline):
        for i, elem in enumerate(node.elements):
            if i > 0:
                self._write(' | ')
            self.visit(elem)

    def visit_Ps1ExpressionStatement(self, node: Ps1ExpressionStatement):
        if node.expression:
            self.visit(node.expression)

    def visit_Ps1IfStatement(self, node: Ps1IfStatement):
        for i, (cond, body) in enumerate(node.clauses):
            if i == 0:
                self._write('if (')
            else:
                self._write(' elseif (')
            if cond:
                self.visit(cond)
            self._write(') ')
            self._emit_block(body)
        if node.else_block:
            self._write(' else ')
            self._emit_block(node.else_block)

    def visit_Ps1WhileLoop(self, node: Ps1WhileLoop):
        if node.label:
            self._write(F'{node.label} ')
        self._write('while (')
        if node.condition:
            self.visit(node.condition)
        self._write(') ')
        if node.body:
            self._emit_block(node.body)

    def visit_Ps1DoLoop(self, node: Ps1DoLoop):
        if node.label:
            self._write(F'{node.label} ')
        self._write('do ')
        if node.body:
            self._emit_block(node.body)
        keyword = 'until' if node.is_until else 'while'
        self._write(F' {keyword} (')
        if node.condition:
            self.visit(node.condition)
        self._write(')')

    def visit_Ps1ForLoop(self, node: Ps1ForLoop):
        if node.label:
            self._write(F'{node.label} ')
        self._write('for (')
        if node.initializer:
            self.visit(node.initializer)
        self._write('; ')
        if node.condition:
            self.visit(node.condition)
        self._write('; ')
        if node.iterator:
            self.visit(node.iterator)
        self._write(') ')
        if node.body:
            self._emit_block(node.body)

    def visit_Ps1ForEachLoop(self, node: Ps1ForEachLoop):
        if node.label:
            self._write(F'{node.label} ')
        self._write('foreach ')
        if node.parallel:
            self._write('-Parallel ')
        self._write('(')
        if node.variable:
            self.visit(node.variable)
        self._write(' in ')
        if node.iterable:
            self.visit(node.iterable)
        self._write(') ')
        if node.body:
            self._emit_block(node.body)

    def visit_Ps1SwitchStatement(self, node: Ps1SwitchStatement):
        if node.label:
            self._write(F'{node.label} ')
        self._write('switch ')
        if node.regex:
            self._write('-Regex ')
        if node.wildcard:
            self._write('-Wildcard ')
        if node.exact:
            self._write('-Exact ')
        if node.case_sensitive:
            self._write('-CaseSensitive ')
        if node.file:
            self._write('-File ')
            if node.value:
                self.visit(node.value)
            self._write(' {')
        else:
            self._write('(')
            if node.value:
                self.visit(node.value)
            self._write(') {')
        self._depth += 1
        for cond, body in node.clauses:
            self._newline()
            if cond is None:
                self._write('default ')
            else:
                # A clause is matched against a pattern, so a bare word here is the string it
                # spells rather than a command to run.
                self._emit_word(cond, precedence.COMMA + 1)
                self._write(' ')
            self._emit_block(body)
        self._depth -= 1
        self._newline()
        self._write('}')

    def visit_Ps1TryCatchFinally(self, node: Ps1TryCatchFinally):
        self._write('try ')
        if node.try_block:
            self._emit_block(node.try_block)
        for clause in node.catch_clauses:
            self._write(' catch')
            if clause.types:
                self._write(' ')
                self._write(', '.join(F'[{t}]' for t in clause.types))
            self._write(' ')
            if clause.body:
                self._emit_block(clause.body)
        if node.finally_block:
            self._write(' finally ')
            self._emit_block(node.finally_block)

    def visit_Ps1TrapStatement(self, node: Ps1TrapStatement):
        self._write('trap ')
        if node.type_name:
            self._write(F'[{node.type_name}] ')
        if node.body:
            self._emit_block(node.body)

    def visit_Ps1FunctionDefinition(self, node: Ps1FunctionDefinition):
        kw = 'filter' if node.is_filter else 'function'
        self._write(F'{kw} {node.name} ')
        if node.body:
            self.visit(node.body)

    def _emit_member_modifiers(self, modifiers: Ps1MemberModifier):
        if Ps1MemberModifier.STATIC in modifiers:
            self._write('static ')
        if Ps1MemberModifier.HIDDEN in modifiers:
            self._write('hidden ')

    def visit_Ps1PropertyMember(self, node: Ps1PropertyMember):
        for attr in node.attributes:
            self.visit(attr)
        self._emit_member_modifiers(node.modifiers)
        if node.type_constraint:
            self.visit(node.type_constraint)
        if node.variable:
            self.visit(node.variable)
        if node.initial_value:
            self._write(' = ')
            self.visit(node.initial_value)

    def visit_Ps1MethodMember(self, node: Ps1MethodMember):
        for attr in node.attributes:
            self.visit(attr)
        self._emit_member_modifiers(node.modifiers)
        if node.return_type:
            self.visit(node.return_type)
            self._write(' ')
        if node.definition:
            funcdef = node.definition
            self._write(F'{funcdef.name}(')
            if funcdef.body and funcdef.body.param_block:
                for i, param in enumerate(funcdef.body.param_block.parameters):
                    if i > 0:
                        self._write(', ')
                    self.visit(param)
            self._write(') {')
            self._depth += 1
            if funcdef.body:
                self._emit_script_body(funcdef.body, newline_after=False)
            self._depth -= 1
            has_content = funcdef.body and (
                funcdef.body.body
                or funcdef.body.begin_block
                or funcdef.body.process_block
                or funcdef.body.end_block
                or funcdef.body.dynamicparam_block
            )
            if has_content:
                self._newline()
            self._write('}')

    def visit_Ps1ClassDefinition(self, node: Ps1ClassDefinition):
        self._write(F'class {node.name}')
        if node.base_types:
            self._write(' : ')
            self._write(', '.join(node.base_types))
        self._write(' {')
        self._depth += 1
        for member in node.members:
            self._newline()
            self.visit(member)
        self._depth -= 1
        if node.members:
            self._newline()
        self._write('}')

    def visit_Ps1EnumMember(self, node: Ps1EnumMember):
        self._write(node.name)
        if node.value is not None:
            self._write(' = ')
            self.visit(node.value)

    def visit_Ps1EnumDefinition(self, node: Ps1EnumDefinition):
        self._write(F'enum {node.name}')
        if node.base_type:
            self._write(F' : {node.base_type}')
        self._write(' {')
        self._depth += 1
        for member in node.members:
            self._newline()
            self.visit(member)
        self._depth -= 1
        if node.members:
            self._newline()
        self._write('}')

    def _visit_jump(self, node: Ps1Jump, name: str):
        self._write(name)
        if suffix := node.label:
            # The label is read the way an argument is, and the colon in `break :outer` is part of
            # that spelling rather than of the name: quoted, it would name a label called `:outer`.
            self._write(' ')
            self._emit_word(suffix, precedence.COMMA + 1)

    def _visit_exit(self, node: Ps1Exit, name: str):
        self._write(name)
        if suffix := node.pipeline:
            self._write(' ')
            self.visit(suffix)

    def visit_Ps1ReturnStatement(self, node: Ps1ReturnStatement):
        self._visit_exit(node, 'return')

    def visit_Ps1ExitStatement(self, node: Ps1ExitStatement):
        self._visit_exit(node, 'exit')

    def visit_Ps1ThrowStatement(self, node: Ps1ThrowStatement):
        self._visit_exit(node, 'throw')

    def visit_Ps1BreakStatement(self, node: Ps1BreakStatement):
        self._visit_jump(node, 'break')

    def visit_Ps1ContinueStatement(self, node: Ps1ContinueStatement):
        self._visit_jump(node, 'continue')

    def visit_Ps1DataSection(self, node: Ps1DataSection):
        self._write('data ')
        if node.name:
            self._write(F'{node.name} ')
        if node.commands:
            self._write('-SupportedCommand ')
            for i, cmd in enumerate(node.commands):
                if i > 0:
                    self._write(', ')
                self.visit(cmd)
            self._write(' ')
        if node.body:
            self._emit_block(node.body)

    def visit_Ps1ErrorNode(self, node: Ps1ErrorNode):
        self._write(node.text)

    def visit_Ps1Script(self, node: Ps1Script):
        if node.param_block:
            self.visit(node.param_block)
            self._newline()
        self._emit_script_body(node, newline_after=True)

    def visit_Block(self, node: Block):
        self._emit_block(node)

Ancestors

Methods

def visit(self, node)

Take the arming set by the slot, so that it applies to this node and no other. A slot that arms nothing yields the quoted spelling, which is the reading that is valid everywhere; forgetting to arm one therefore costs a pair of quotes rather than the meaning of a script.

Expand source code Browse git
def visit(self, node: Node) -> Node | None:
    """
    Take the arming set by the slot, so that it applies to this node and no other. A slot that
    arms nothing yields the quoted spelling, which is the reading that is valid everywhere;
    forgetting to arm one therefore costs a pair of quotes rather than the meaning of a script.
    """
    self._word_slot, self._word_slot_ahead = self._word_slot_ahead, False
    return super().visit(node)
def visit_Ps1Variable(self, node)
Expand source code Browse git
def visit_Ps1Variable(self, node: Ps1Variable):
    prefix = '@' if node.splatted else '$'
    body = F'{self._variable_scope_prefix(node)}{node.name}'
    if node.braced:
        body = F'{{{body}}}'
    self._write(F'{prefix}{body}')
def visit_Ps1IntegerLiteral(self, node)

A numeral is written exactly as it is spelled, and a numeral the source spelled is never re-spelled. Where it stands in a command argument the text itself is passed on: 5.1 wraps a literal argument so that PSObject.TokenText keeps what was written, and the receiving command reads that back — Write-Host 1.10 prints 1.10 and notepad.exe 0x10 receives 0x10 rather than 16. Holding only raw is what makes that true by construction, and a pass that normalized one would break it here without anything noticing.

Expand source code Browse git
def visit_Ps1IntegerLiteral(self, node: Ps1IntegerLiteral):
    """
    A numeral is written exactly as it is spelled, and a numeral the source spelled is never
    re-spelled. Where it stands in a command argument the text itself is passed on: 5.1 wraps a
    literal argument so that `PSObject.TokenText` keeps what was written, and the receiving
    command reads that back — `Write-Host 1.10` prints `1.10` and `notepad.exe 0x10` receives
    `0x10` rather than `16`. Holding only `raw` is what makes that true by construction, and a
    pass that normalized one would break it here without anything noticing.
    """
    self._write(node.raw)
def visit_Ps1RealLiteral(self, node)
Expand source code Browse git
def visit_Ps1RealLiteral(self, node: Ps1RealLiteral):
    self._write(node.raw)
def visit_Ps1StringLiteral(self, node)
Expand source code Browse git
def visit_Ps1StringLiteral(self, node: Ps1StringLiteral):
    if '\n' in node.raw:
        self._write(F'"{self._escape_for_dq(node.value)}"')
    elif node.is_bare_word and not self._word_slot:
        self._write(F"'{self._escape_for_sq(node.value)}'")
    else:
        self._write(node.raw)
def visit_Ps1ExpandableString(self, node)
Expand source code Browse git
def visit_Ps1ExpandableString(self, node: Ps1ExpandableString):
    self._emit_expandable_parts(node.parts)
def visit_Ps1HereString(self, node)
Expand source code Browse git
def visit_Ps1HereString(self, node: Ps1HereString):
    if '\n' in node.value:
        self._write(F'"{self._escape_for_dq(node.value)}"')
    else:
        self._write(node.raw)
def visit_Ps1ExpandableHereString(self, node)
Expand source code Browse git
def visit_Ps1ExpandableHereString(self, node: Ps1ExpandableHereString):
    # Emit from the (possibly transform-rewritten) parts rather than the stale `raw`, otherwise
    # an inlined variable/constant would be lost while its source assignment is removed. A
    # double-quoted expandable string is semantically equivalent to the here-string.
    self._emit_expandable_parts(node.parts)
def visit_Ps1BinaryExpression(self, node)
Expand source code Browse git
def visit_Ps1BinaryExpression(self, node: Ps1BinaryExpression):
    # The left spine is walked rather than recursed through, because a folded concatenation is
    # thousands of operators deep and recursion would not reach the end of one. Walking stops
    # where a left operand binds more loosely than its parent, since that one needs a bracket
    # and so is not part of the same flat chain.
    spine: list[tuple[str, Expression | None, int]] = []
    current = node
    while True:
        power = precedence.of_operator(current.operator)
        spine.append((current.operator, current.right, power))
        left = current.left
        if (
            isinstance(left, Ps1BinaryExpression)
            and precedence.of_operator(left.operator) >= power
        ):
            current = left
            continue
        break
    if (head := current.left) is not None:
        self._emit_operand(head, spine[-1][2])
    for operator, right, power in reversed(spine):
        self._write(F' {operator} ')
        if right is not None:
            self._emit_operand(right, power + 1)
def visit_Ps1UnaryExpression(self, node)
Expand source code Browse git
def visit_Ps1UnaryExpression(self, node: Ps1UnaryExpression):
    operand = node.operand
    if not node.prefix:
        if operand is not None:
            self._emit_operand(operand, precedence.UNARY)
        self._write(node.operator)
        return
    spelling = '' if operand is None else self._operand_to_string(operand, precedence.UNARY)
    self._write(node.operator)
    if node.operator.startswith('-') and len(node.operator) > 1:
        self._write(' ')
    elif node.operator in ('+', '-') and _fuses_with_a_sign(spelling):
        self._write(' ')
    self._write(spelling)
def visit_Ps1TypeExpression(self, node)
Expand source code Browse git
def visit_Ps1TypeExpression(self, node: Ps1TypeExpression):
    self._write(F'[{node.name}]')
def visit_Ps1CastExpression(self, node)
Expand source code Browse git
def visit_Ps1CastExpression(self, node: Ps1CastExpression):
    self._write(F'[{node.type_name}]')
    if node.operand:
        self._emit_operand(node.operand, precedence.UNARY)
def visit_Ps1MemberAccess(self, node)
Expand source code Browse git
def visit_Ps1MemberAccess(self, node: Ps1MemberAccess):
    self._emit_member_prefix(node)
def visit_Ps1IndexExpression(self, node)
Expand source code Browse git
def visit_Ps1IndexExpression(self, node: Ps1IndexExpression):
    if node.object:
        self._emit_receiver(node.object, '[')
    self._write('[')
    if node.index:
        with self._reading(Ps1LexerMode.EXPRESSION):
            self.visit(node.index)
    self._write(']')
def visit_Ps1InvokeMember(self, node)
Expand source code Browse git
def visit_Ps1InvokeMember(self, node: Ps1InvokeMember):
    self._emit_member_prefix(node)
    self._write('(')
    with self._reading(Ps1LexerMode.EXPRESSION):
        for i, arg in enumerate(node.arguments):
            if i > 0:
                self._write(', ')
            if precedence.needs_brackets_between_delimiters(arg):
                self._write('(')
                self.visit(arg)
                self._write(')')
            else:
                self.visit(arg)
    self._write(')')
def visit_Ps1CommandInvocation(self, node)
Expand source code Browse git
def visit_Ps1CommandInvocation(self, node: Ps1CommandInvocation):
    if node.invocation_operator:
        self._write(node.invocation_operator)
        self._write(' ')
    if node.name:
        # `& 'i' + 'ex'` invokes `i` and then concatenates, so a computed command name needs
        # brackets for the invocation operator to reach the whole expression.
        self._emit_word(node.name, precedence.ATOM)
    for arg in node.arguments:
        self._write(' ')
        self.visit(arg)
    for redir in node.redirections:
        self._write(' ')
        self.visit(redir)
def visit_Ps1CommandArgument(self, node)
Expand source code Browse git
def visit_Ps1CommandArgument(self, node: Ps1CommandArgument):
    if node.kind == Ps1CommandArgumentKind.SWITCH:
        self._write(node.name)
    elif node.kind == Ps1CommandArgumentKind.NAMED:
        self._write(F'{node.name}:')
        if node.value:
            self._emit_argument_value(node.value)
    elif node.kind == Ps1CommandArgumentKind.POSITIONAL:
        if node.value:
            self._emit_argument_value(node.value)
def visit_Ps1AssignmentExpression(self, node)
Expand source code Browse git
def visit_Ps1AssignmentExpression(self, node: Ps1AssignmentExpression):
    if node.target:
        # The target is delimited by the operator that follows it, and a multi-assignment
        # writes through a comma-built list of targets, so this slot brackets nothing either.
        self.visit(node.target)
    self._write(F' {node.operator} ')
    if (value := node.value) is not None:
        # Nothing is bracketed here. The right side of an assignment runs to the end of the
        # statement, so there is nothing beside it for a command or an operator to reach into;
        # an assignment standing somewhere tighter is bracketed by that slot instead.
        self.visit(value)
def visit_Ps1ArrayLiteral(self, node)
Expand source code Browse git
def visit_Ps1ArrayLiteral(self, node: Ps1ArrayLiteral):
    # A one-element array is written with the leading unary comma that builds it. Printing the
    # element alone yields the element, not an array of it, which is why
    # `New-Object IO.MemoryStream(,$bytes)` must keep its comma: without it the constructor is
    # handed the buffer's elements as separate arguments and throws.
    if len(node.elements) == 1:
        self._write(',')
    # An array standing where bare words are read as values holds its elements in that same
    # slot: `foo a, b` passes two words. Bracket the array and they are in a pipeline instead,
    # where the first would become a command name, so the arming is not passed on.
    word_slot = self._word_slot
    for i, elem in enumerate(node.elements):
        if i > 0:
            self._write(', ')
        self._word_slot_ahead = word_slot
        self._emit_operand(elem, precedence.COMMA + 1)
def visit_Ps1ArrayExpression(self, node)
Expand source code Browse git
def visit_Ps1ArrayExpression(self, node: Ps1ArrayExpression):
    self._write('@(')
    self._emit_statement_list(node.body)
    self._write(')')
def visit_Ps1HashLiteral(self, node)
Expand source code Browse git
def visit_Ps1HashLiteral(self, node: Ps1HashLiteral):
    self._write('@{')
    if node.pairs:
        self._depth += 1
        for key, value in node.pairs:
            self._newline()
            # A key is read as a word rather than as a command: `@{ Name = 1 }` is a hash of
            # one entry, not a call to `Name`.
            self._emit_word(key, precedence.COMMA + 1)
            self._write(' = ')
            with self._reading(Ps1LexerMode.EXPRESSION):
                self.visit(value)
        self._depth -= 1
        self._newline()
    self._write('}')
def visit_Ps1SubExpression(self, node)
Expand source code Browse git
def visit_Ps1SubExpression(self, node: Ps1SubExpression):
    self._write('$(')
    self._emit_statement_list(node.body)
    self._write(')')
def visit_Ps1ParenExpression(self, node)
Expand source code Browse git
def visit_Ps1ParenExpression(self, node: Ps1ParenExpression):
    self._write('(')
    if node.expression:
        with self._reading(Ps1LexerMode.EXPRESSION):
            self.visit(node.expression)
    self._write(')')
def visit_Ps1ScriptBlock(self, node)
Expand source code Browse git
def visit_Ps1ScriptBlock(self, node: Ps1ScriptBlock):
    self._write('{')
    self._depth += 1
    if node.param_block:
        self._newline()
        self.visit(node.param_block)
    self._emit_script_body(node, newline_after=False)
    self._depth -= 1
    has_content = (
        node.body or node.param_block
        or node.begin_block or node.process_block
        or node.end_block or node.dynamicparam_block
    )
    if has_content:
        self._newline()
    self._write('}')
def visit_Ps1RangeExpression(self, node)
Expand source code Browse git
def visit_Ps1RangeExpression(self, node: Ps1RangeExpression):
    if node.start:
        self._emit_operand(node.start, precedence.RANGE)
    self._write('..')
    if node.end:
        self._emit_operand(node.end, precedence.RANGE + 1)
def visit_Ps1Attribute(self, node)
Expand source code Browse git
def visit_Ps1Attribute(self, node: Ps1Attribute):
    # The argument list is written even when it is empty, because it is what distinguishes an
    # attribute from a type constraint: `[CmdletBinding]` reads back as the type `CmdletBinding`
    # and is then dropped, and `class C { [ValidateNotNull()] [int] $P }` loses the `[int]` to
    # the same confusion.
    self._write(F'[{node.name}(')
    items: list[str] = []
    for arg in node.positional_args:
        items.append(self._render_to_string(arg))
    for key, val in node.named_args:
        items.append(F'{key}={self._render_to_string(val)}')
    self._write(', '.join(items))
    self._write(')]')
def visit_Ps1ParameterDeclaration(self, node)
Expand source code Browse git
def visit_Ps1ParameterDeclaration(self, node: Ps1ParameterDeclaration):
    for attr in node.attributes:
        self.visit(attr)
    if node.variable:
        self.visit(node.variable)
    if node.default_value:
        self._write(' = ')
        # The commas of the parameter list delimit this slot exactly as a command's arguments
        # delimit theirs, so a default that is a command or is built with a comma needs to say
        # where it ends.
        self._emit_operand(node.default_value, precedence.COMMA + 1)
def visit_Ps1ParamBlock(self, node)
Expand source code Browse git
def visit_Ps1ParamBlock(self, node: Ps1ParamBlock):
    for attr in node.attributes:
        self.visit(attr)
        self._newline()
    self._write(KEYWORD_SPELLING.get('param', 'param'))
    self._write('(')
    for i, param in enumerate(node.parameters):
        if i > 0:
            self._write(', ')
        self.visit(param)
    self._write(')')
def visit_Ps1FileRedirection(self, node)
Expand source code Browse git
def visit_Ps1FileRedirection(self, node: Ps1FileRedirection):
    prefix = self._emit_redirection_stream(node.stream)
    op = '>>' if node.append else '>'
    self._write(F'{prefix}{op}')
    if node.target:
        # A redirection names its file the way a command names an argument, so a path may
        # stand there without quotes.
        self._write(' ')
        self._emit_word(node.target, precedence.COMMA + 1)
def visit_Ps1InputRedirection(self, node)
Expand source code Browse git
def visit_Ps1InputRedirection(self, node: Ps1InputRedirection):
    self._write('<')
    if node.source:
        self._write(' ')
        self._emit_word(node.source, precedence.COMMA + 1)
def visit_Ps1MergingRedirection(self, node)
Expand source code Browse git
def visit_Ps1MergingRedirection(self, node: Ps1MergingRedirection):
    # A file redirection of the output stream is written bare, but a merge names its source
    # even then: dropping the `1` from `1>&2` produces `>&2`, which is a file redirection to
    # the target `&2` and does not read back as the statement that was written.
    if node.from_stream is Ps1RedirectionStream.ALL:
        prefix = '*'
    else:
        prefix = str(node.from_stream.value)
    self._write(F'{prefix}>&{node.to_stream.value}')
def visit_Ps1PipelineElement(self, node)
Expand source code Browse git
def visit_Ps1PipelineElement(self, node: Ps1PipelineElement):
    if node.expression:
        self.visit(node.expression)
    for redir in node.redirections:
        self._write(' ')
        self.visit(redir)
def visit_Ps1Pipeline(self, node)
Expand source code Browse git
def visit_Ps1Pipeline(self, node: Ps1Pipeline):
    for i, elem in enumerate(node.elements):
        if i > 0:
            self._write(' | ')
        self.visit(elem)
def visit_Ps1ExpressionStatement(self, node)
Expand source code Browse git
def visit_Ps1ExpressionStatement(self, node: Ps1ExpressionStatement):
    if node.expression:
        self.visit(node.expression)
def visit_Ps1IfStatement(self, node)
Expand source code Browse git
def visit_Ps1IfStatement(self, node: Ps1IfStatement):
    for i, (cond, body) in enumerate(node.clauses):
        if i == 0:
            self._write('if (')
        else:
            self._write(' elseif (')
        if cond:
            self.visit(cond)
        self._write(') ')
        self._emit_block(body)
    if node.else_block:
        self._write(' else ')
        self._emit_block(node.else_block)
def visit_Ps1WhileLoop(self, node)
Expand source code Browse git
def visit_Ps1WhileLoop(self, node: Ps1WhileLoop):
    if node.label:
        self._write(F'{node.label} ')
    self._write('while (')
    if node.condition:
        self.visit(node.condition)
    self._write(') ')
    if node.body:
        self._emit_block(node.body)
def visit_Ps1DoLoop(self, node)
Expand source code Browse git
def visit_Ps1DoLoop(self, node: Ps1DoLoop):
    if node.label:
        self._write(F'{node.label} ')
    self._write('do ')
    if node.body:
        self._emit_block(node.body)
    keyword = 'until' if node.is_until else 'while'
    self._write(F' {keyword} (')
    if node.condition:
        self.visit(node.condition)
    self._write(')')
def visit_Ps1ForLoop(self, node)
Expand source code Browse git
def visit_Ps1ForLoop(self, node: Ps1ForLoop):
    if node.label:
        self._write(F'{node.label} ')
    self._write('for (')
    if node.initializer:
        self.visit(node.initializer)
    self._write('; ')
    if node.condition:
        self.visit(node.condition)
    self._write('; ')
    if node.iterator:
        self.visit(node.iterator)
    self._write(') ')
    if node.body:
        self._emit_block(node.body)
def visit_Ps1ForEachLoop(self, node)
Expand source code Browse git
def visit_Ps1ForEachLoop(self, node: Ps1ForEachLoop):
    if node.label:
        self._write(F'{node.label} ')
    self._write('foreach ')
    if node.parallel:
        self._write('-Parallel ')
    self._write('(')
    if node.variable:
        self.visit(node.variable)
    self._write(' in ')
    if node.iterable:
        self.visit(node.iterable)
    self._write(') ')
    if node.body:
        self._emit_block(node.body)
def visit_Ps1SwitchStatement(self, node)
Expand source code Browse git
def visit_Ps1SwitchStatement(self, node: Ps1SwitchStatement):
    if node.label:
        self._write(F'{node.label} ')
    self._write('switch ')
    if node.regex:
        self._write('-Regex ')
    if node.wildcard:
        self._write('-Wildcard ')
    if node.exact:
        self._write('-Exact ')
    if node.case_sensitive:
        self._write('-CaseSensitive ')
    if node.file:
        self._write('-File ')
        if node.value:
            self.visit(node.value)
        self._write(' {')
    else:
        self._write('(')
        if node.value:
            self.visit(node.value)
        self._write(') {')
    self._depth += 1
    for cond, body in node.clauses:
        self._newline()
        if cond is None:
            self._write('default ')
        else:
            # A clause is matched against a pattern, so a bare word here is the string it
            # spells rather than a command to run.
            self._emit_word(cond, precedence.COMMA + 1)
            self._write(' ')
        self._emit_block(body)
    self._depth -= 1
    self._newline()
    self._write('}')
def visit_Ps1TryCatchFinally(self, node)
Expand source code Browse git
def visit_Ps1TryCatchFinally(self, node: Ps1TryCatchFinally):
    self._write('try ')
    if node.try_block:
        self._emit_block(node.try_block)
    for clause in node.catch_clauses:
        self._write(' catch')
        if clause.types:
            self._write(' ')
            self._write(', '.join(F'[{t}]' for t in clause.types))
        self._write(' ')
        if clause.body:
            self._emit_block(clause.body)
    if node.finally_block:
        self._write(' finally ')
        self._emit_block(node.finally_block)
def visit_Ps1TrapStatement(self, node)
Expand source code Browse git
def visit_Ps1TrapStatement(self, node: Ps1TrapStatement):
    self._write('trap ')
    if node.type_name:
        self._write(F'[{node.type_name}] ')
    if node.body:
        self._emit_block(node.body)
def visit_Ps1FunctionDefinition(self, node)
Expand source code Browse git
def visit_Ps1FunctionDefinition(self, node: Ps1FunctionDefinition):
    kw = 'filter' if node.is_filter else 'function'
    self._write(F'{kw} {node.name} ')
    if node.body:
        self.visit(node.body)
def visit_Ps1PropertyMember(self, node)
Expand source code Browse git
def visit_Ps1PropertyMember(self, node: Ps1PropertyMember):
    for attr in node.attributes:
        self.visit(attr)
    self._emit_member_modifiers(node.modifiers)
    if node.type_constraint:
        self.visit(node.type_constraint)
    if node.variable:
        self.visit(node.variable)
    if node.initial_value:
        self._write(' = ')
        self.visit(node.initial_value)
def visit_Ps1MethodMember(self, node)
Expand source code Browse git
def visit_Ps1MethodMember(self, node: Ps1MethodMember):
    for attr in node.attributes:
        self.visit(attr)
    self._emit_member_modifiers(node.modifiers)
    if node.return_type:
        self.visit(node.return_type)
        self._write(' ')
    if node.definition:
        funcdef = node.definition
        self._write(F'{funcdef.name}(')
        if funcdef.body and funcdef.body.param_block:
            for i, param in enumerate(funcdef.body.param_block.parameters):
                if i > 0:
                    self._write(', ')
                self.visit(param)
        self._write(') {')
        self._depth += 1
        if funcdef.body:
            self._emit_script_body(funcdef.body, newline_after=False)
        self._depth -= 1
        has_content = funcdef.body and (
            funcdef.body.body
            or funcdef.body.begin_block
            or funcdef.body.process_block
            or funcdef.body.end_block
            or funcdef.body.dynamicparam_block
        )
        if has_content:
            self._newline()
        self._write('}')
def visit_Ps1ClassDefinition(self, node)
Expand source code Browse git
def visit_Ps1ClassDefinition(self, node: Ps1ClassDefinition):
    self._write(F'class {node.name}')
    if node.base_types:
        self._write(' : ')
        self._write(', '.join(node.base_types))
    self._write(' {')
    self._depth += 1
    for member in node.members:
        self._newline()
        self.visit(member)
    self._depth -= 1
    if node.members:
        self._newline()
    self._write('}')
def visit_Ps1EnumMember(self, node)
Expand source code Browse git
def visit_Ps1EnumMember(self, node: Ps1EnumMember):
    self._write(node.name)
    if node.value is not None:
        self._write(' = ')
        self.visit(node.value)
def visit_Ps1EnumDefinition(self, node)
Expand source code Browse git
def visit_Ps1EnumDefinition(self, node: Ps1EnumDefinition):
    self._write(F'enum {node.name}')
    if node.base_type:
        self._write(F' : {node.base_type}')
    self._write(' {')
    self._depth += 1
    for member in node.members:
        self._newline()
        self.visit(member)
    self._depth -= 1
    if node.members:
        self._newline()
    self._write('}')
def visit_Ps1ReturnStatement(self, node)
Expand source code Browse git
def visit_Ps1ReturnStatement(self, node: Ps1ReturnStatement):
    self._visit_exit(node, 'return')
def visit_Ps1ExitStatement(self, node)
Expand source code Browse git
def visit_Ps1ExitStatement(self, node: Ps1ExitStatement):
    self._visit_exit(node, 'exit')
def visit_Ps1ThrowStatement(self, node)
Expand source code Browse git
def visit_Ps1ThrowStatement(self, node: Ps1ThrowStatement):
    self._visit_exit(node, 'throw')
def visit_Ps1BreakStatement(self, node)
Expand source code Browse git
def visit_Ps1BreakStatement(self, node: Ps1BreakStatement):
    self._visit_jump(node, 'break')
def visit_Ps1ContinueStatement(self, node)
Expand source code Browse git
def visit_Ps1ContinueStatement(self, node: Ps1ContinueStatement):
    self._visit_jump(node, 'continue')
def visit_Ps1DataSection(self, node)
Expand source code Browse git
def visit_Ps1DataSection(self, node: Ps1DataSection):
    self._write('data ')
    if node.name:
        self._write(F'{node.name} ')
    if node.commands:
        self._write('-SupportedCommand ')
        for i, cmd in enumerate(node.commands):
            if i > 0:
                self._write(', ')
            self.visit(cmd)
        self._write(' ')
    if node.body:
        self._emit_block(node.body)
def visit_Ps1ErrorNode(self, node)
Expand source code Browse git
def visit_Ps1ErrorNode(self, node: Ps1ErrorNode):
    self._write(node.text)
def visit_Ps1Script(self, node)
Expand source code Browse git
def visit_Ps1Script(self, node: Ps1Script):
    if node.param_block:
        self.visit(node.param_block)
        self._newline()
    self._emit_script_body(node, newline_after=True)
def visit_Block(self, node)
Expand source code Browse git
def visit_Block(self, node: Block):
    self._emit_block(node)