Module refinery.lib.scripts.ps1.parser
Recursive-descent parser for PowerShell based on the PowerShell Language Specification 3.0.
Expand source code Browse git
"""
Recursive-descent parser for PowerShell based on the PowerShell Language Specification 3.0.
"""
from __future__ import annotations
import re
from collections.abc import Callable
from contextlib import contextmanager
from typing import TypeVar
from refinery.lib.scripts import Block
from refinery.lib.scripts.ps1.lexer import (
_DASH_OPERATORS,
Ps1Lexer,
Ps1LexerMode,
)
from refinery.lib.scripts.ps1.model import (
Expression,
Node,
Ps1AccessKind,
Ps1ArrayExpression,
Ps1ArrayLiteral,
Ps1AssignmentExpression,
Ps1Attribute,
Ps1BinaryExpression,
Ps1BreakStatement,
Ps1CastExpression,
Ps1CatchClause,
Ps1ClassDefinition,
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,
Ps1Redirection,
Ps1RedirectionStream,
Ps1ReturnStatement,
Ps1ScopeModifier,
Ps1Script,
Ps1ScriptBlock,
Ps1StringLiteral,
Ps1SubExpression,
Ps1SwitchStatement,
Ps1ThrowStatement,
Ps1TrapStatement,
Ps1TryCatchFinally,
Ps1TypeExpression,
Ps1UnaryExpression,
Ps1Variable,
Ps1WhileLoop,
Statement,
)
from refinery.lib.scripts.ps1.token import (
_VARIABLE_PATTERN_CORE,
BACKTICK_ESCAPE,
DOUBLE_QUOTES,
NORMALIZE_QUOTES,
SINGLE_QUOTES,
Ps1Token,
Ps1TokenKind,
_strip_backtick_noop,
)
_T = TypeVar('_T')
_NON_COMPARISON_DASH_OPS = frozenset({
'-and',
'-band',
'-bnot',
'-bor',
'-bxor',
'-f',
'-not',
'-or',
'-xor',
})
_COMPARISON_OPERATORS = frozenset(_DASH_OPERATORS.values()) - _NON_COMPARISON_DASH_OPS
_BINARY_PRECEDENCE: dict[str, int] = {}
_BINARY_PRECEDENCE.update(dict.fromkeys(('-and', '-or', '-xor'), 10))
_BINARY_PRECEDENCE.update(dict.fromkeys(('-band', '-bor', '-bxor'), 20))
_BINARY_PRECEDENCE.update(dict.fromkeys(_COMPARISON_OPERATORS, 30))
#: The two kinds that spell a bare word. `Ps1Parser._parse_primary_atom` reads one as the string it
#: spells, because six rules of the grammar reach it holding one: an attribute argument, a class
#: member initializer, an index, a method argument, an enum member and a parameter default. What
#: must never take it for an expression is the choice between a command and an expression, because
#: every command name in the language is spelled this way.
_BARE_WORD_KINDS = frozenset({
Ps1TokenKind.GENERIC_EXPAND,
Ps1TokenKind.GENERIC_TOKEN,
})
#: The prefix operators `Ps1Parser._parse_unary_expression` reads before it reaches an atom.
_UNARY_OPERATOR_KINDS = frozenset({
Ps1TokenKind.COMMA,
Ps1TokenKind.DASH,
Ps1TokenKind.DECREMENT,
Ps1TokenKind.EXCLAIM,
Ps1TokenKind.INCREMENT,
Ps1TokenKind.OPERATOR,
Ps1TokenKind.PLUS,
})
_STATEMENT_TERMINATORS = frozenset({
Ps1TokenKind.NEWLINE,
Ps1TokenKind.SEMICOLON,
Ps1TokenKind.RBRACE,
Ps1TokenKind.RPAREN,
Ps1TokenKind.EOF,
Ps1TokenKind.PIPE,
})
_PIPELINE_TERMINATORS = _STATEMENT_TERMINATORS | {
Ps1TokenKind.DOUBLE_AMPERSAND,
Ps1TokenKind.DOUBLE_PIPE,
}
#: Token kinds that can never stand as a command name or argument. Any other token that is not an
#: expression is read as the bare word it spells, which is what makes `Copy-Item . dest` three
#: elements of one command.
_ARGUMENT_FORBIDDEN_KINDS = _PIPELINE_TERMINATORS | {
Ps1TokenKind.AMPERSAND,
Ps1TokenKind.COMMA,
Ps1TokenKind.DECREMENT,
Ps1TokenKind.REDIRECTION,
Ps1TokenKind.REDIRECT_IN,
}
#: A block comment may stand between an expression and a member access; whitespace may not.
_BLOCK_COMMENT = re.compile(r'<#.*?#>', re.DOTALL)
_VARIABLE_FRAG = re.compile(
r'\$(?:' + _VARIABLE_PATTERN_CORE + r')',
re.IGNORECASE,
)
_MERGING_PATTERN = re.compile(r'(\d|\*)?>&(\d)')
_MULTIPLIER_SUFFIXES = {
'kb': 1024,
'mb': 1024 ** 2,
'gb': 1024 ** 3,
'tb': 1024 ** 4,
'pb': 1024 ** 5,
}
def _normalize_string_delimiters(text: str, width: int) -> str:
"""
Normalize unicode smart quotes only in the `width` opening and closing delimiter characters of a
string token, leaving the verbatim content untouched so that literal smart quotes inside the
string survive byte-for-byte.
"""
if len(text) <= 2 * width:
return text.translate(NORMALIZE_QUOTES)
head = text[:width].translate(NORMALIZE_QUOTES)
tail = text[-width:].translate(NORMALIZE_QUOTES)
return head + text[width:-width] + tail
def _decode_backtick(text: str, i: int, length: int) -> tuple[str, int]:
"""
Decode the backtick escape at `text[i]` (the backtick). Handles the PowerShell 6+ unicode escape
`` `u{XXXX} `` in addition to the single-character escapes in `BACKTICK_ESCAPE`. Returns the
decoded text and the index of the first character after the escape.
"""
nc = text[i + 1]
if nc in ('u', 'U') and i + 2 < length and text[i + 2] == '{':
end = text.find('}', i + 3)
if end != -1:
try:
return chr(int(text[i + 3:end], 16)), end + 1
except (ValueError, OverflowError):
pass
return BACKTICK_ESCAPE.get(nc, nc), i + 2
class Ps1Parser:
def __init__(self, source: str):
self.source = source
self._lexer = Ps1Lexer(source)
self._pending: Ps1Token | None = None
self._previous_end = 0
self._disable_comma = False
@property
def _current(self) -> Ps1Token:
token = self._pending
if token is None:
token = self._pending = self._lexer.scan()
return token
def _advance(self) -> Ps1Token:
token = self._current
self._previous_end = self._lexer.pos
self._pending = None
return token
def _adjacent(self) -> bool:
"""
Whether the current token begins where the previous one ended. A member access, an index or
a postfix operator binds to what precedes it only when nothing separates the two, so that
`$a.Length` reads a property where `$a . Length` is two command arguments with a bare dot
between them. A block comment does not separate them, which is the one thing 5.1 lets
through.
"""
gap_start = self._previous_end
gap_end = self._current.offset
if gap_start == gap_end:
return True
gap = self.source[gap_start:gap_end]
if '<#' not in gap:
return False
return not _BLOCK_COMMENT.sub('', gap)
def _resync(self, offset: int):
"""
Rewind to `offset` and drop the lookahead, so that the next read scans the source there
again. Nothing is scanned here: the re-read happens when, and only if, a token is asked for.
Where the rewind undoes tokens that were consumed, the end of the last consumed one moves
back with it, because `Ps1Parser._adjacent` reads a gap between the two and a gap that runs
backwards is not one.
"""
self._lexer.pos = offset
self._previous_end = min(self._previous_end, offset)
self._pending = None
def _switch_mode(self, mode: Ps1LexerMode):
if mode is self._lexer.mode:
return
self._lexer.mode = mode
token = self._pending
if token is not None and not token.kind.mode_invariant:
self._resync(token.offset)
@contextmanager
def _mode(self, mode: Ps1LexerMode):
"""
Read the enclosed construct in `mode` and restore the caller's mode afterwards. Changing the
mode discards the lookahead, so a token scanned in one mode is never handed to a parse that
asked for another. That is the whole of the rule: it lives here rather than in the memory of
whoever writes the next call site.
"""
previous = self._lexer.mode
self._switch_mode(mode)
try:
yield
finally:
self._switch_mode(previous)
def _attempt(self, mode: Ps1LexerMode, rule: Callable[[], _T | None]) -> _T | None:
"""
Read `rule` in `mode`, and leave the source where it was found if the rule declines. A
speculative read is one thing and is written once: the mode the construct is spelled in, the
place to return to, and the rule itself.
What this replaces is a first-token guard kept outside the mode it asks about. In argument
mode a `[` belongs to the word around it, so a caller that asks there whether a type name
follows is told no about one that is plainly written; the guard belongs inside `rule`, where
the mode is already the one the construct is read in.
"""
offset = self._current.offset
with self._mode(mode):
result = rule()
if result is None:
self._resync(offset)
return result
def _at(self, *kinds: Ps1TokenKind) -> bool:
return self._current.kind in kinds
def _eat(self, kind: Ps1TokenKind) -> Ps1Token | None:
if self._current.kind == kind:
return self._advance()
return None
def _eat_colon(self) -> bool:
if self._current.kind == Ps1TokenKind.DOUBLE_COLON:
self._advance()
return True
if self._current.kind == Ps1TokenKind.GENERIC_TOKEN and self._current.value == ':':
self._advance()
return True
return False
def _expect(self, kind: Ps1TokenKind) -> Ps1Token:
if self._current.kind == kind:
return self._advance()
return Ps1Token(kind, '', self._current.offset)
def _skip_newlines(self):
while self._current.kind == Ps1TokenKind.NEWLINE:
self._advance()
def _parse_parenthesized_condition(self) -> Expression:
self._expect(Ps1TokenKind.LPAREN)
self._skip_newlines()
expr = self._parse_pipeline_expression(keyword_names_a_command=True)
self._skip_newlines()
self._expect(Ps1TokenKind.RPAREN)
if expr is None:
return Ps1ErrorNode(offset=self._current.offset, message='missing condition')
return expr
def _skip_separators(self):
while self._current.kind in (
Ps1TokenKind.COMMA,
Ps1TokenKind.NEWLINE,
Ps1TokenKind.SEMICOLON,
):
self._advance()
@staticmethod
def _bare_string(tok: Ps1Token) -> Ps1StringLiteral:
return Ps1StringLiteral(offset=tok.offset, value=tok.value, raw=tok.value)
@contextmanager
def _comma_mode(self, disabled: bool):
old = self._disable_comma
self._disable_comma = disabled
try:
yield
finally:
self._disable_comma = old
def _parse_redirection(
self, tok: Ps1Token,
) -> Ps1FileRedirection | Ps1MergingRedirection:
op = tok.value
m = _MERGING_PATTERN.fullmatch(op)
if m is not None:
prefix, to_digit = m.group(1), int(m.group(2))
if prefix is None:
from_stream = Ps1RedirectionStream.OUTPUT
elif prefix == '*':
from_stream = Ps1RedirectionStream.ALL
else:
from_stream = Ps1RedirectionStream(int(prefix))
return Ps1MergingRedirection(
offset=tok.offset,
from_stream=from_stream,
to_stream=Ps1RedirectionStream(to_digit),
)
if op[0] == '*':
stream = Ps1RedirectionStream.ALL
rest = op[1:]
elif op[0].isdigit():
stream = Ps1RedirectionStream(int(op[0]))
rest = op[1:]
else:
stream = Ps1RedirectionStream.OUTPUT
rest = op
append = rest == '>>'
return Ps1FileRedirection(
offset=tok.offset,
stream=stream,
target=self._parse_redirection_target(),
append=append,
)
def _parse_input_redirection(self, tok: Ps1Token) -> Ps1InputRedirection:
"""
Read a `<` and the file it names. PowerShell reserves the operator, reports it and then goes
on reading the command around it, so what this must not do is end the command or turn into
the write that `>` spells.
"""
return Ps1InputRedirection(
offset=tok.offset, source=self._parse_redirection_target())
def _parse_redirection_target(self) -> Expression | None:
with self._mode(Ps1LexerMode.ARGUMENT):
if self._is_pipeline_terminator():
return None
return self._parse_single_argument_value()
def _try_parse_redirection(self) -> Ps1Redirection | None:
if self._at(Ps1TokenKind.REDIRECTION):
return self._parse_redirection(self._advance())
if self._at(Ps1TokenKind.REDIRECT_IN):
return self._parse_input_redirection(self._advance())
return None
def _parse_redirections(self) -> list[Ps1Redirection]:
redirections: list[Ps1Redirection] = []
while (redirection := self._try_parse_redirection()) is not None:
redirections.append(redirection)
return redirections
@staticmethod
def _skip_quoted_raw(
src: str,
pos: int,
end: int,
quote_set: frozenset[str],
*,
backtick: bool = False,
) -> int:
"""
Starting just after an opening quote character, advance past the quoted string content
and the closing quote. Handles doubled-quote escapes and optionally backtick escapes.
Returns the position immediately after the closing quote.
"""
while pos < end:
ch = src[pos]
if backtick and ch == '`' and pos + 1 < end:
pos += 2
continue
if ch in quote_set:
pos += 1
if pos < end and src[pos] in quote_set:
pos += 1
continue
return pos
pos += 1
return pos
def _is_statement_terminator(self) -> bool:
return self._current.kind in _STATEMENT_TERMINATORS
def parse(self) -> Ps1Script:
return self._parse_script()
def _parse_code_body(
self,
until: Ps1TokenKind | None = None,
) -> dict:
"""
Read the statements a body holds, in argument mode. Every path into a body comes through
here — a script, a script block, a function with a parameter list, a class method — so the
mode a statement is read in is settled once rather than at each of them, and a body cannot
reach the statement list carrying the mode of whatever declared it.
"""
with self._mode(Ps1LexerMode.ARGUMENT):
return self._read_code_body(until)
def _read_code_body(self, until: Ps1TokenKind | None) -> dict:
self._skip_newlines()
fields: dict = {}
param_block = self._parse_param_block()
if param_block is not None:
fields['param_block'] = param_block
self._skip_newlines()
named = self._try_parse_named_blocks()
if named is not None:
begin_block, process_block, end_block, dynamicparam_block = named
fields.update(
begin_block=begin_block,
process_block=process_block,
end_block=end_block,
dynamicparam_block=dynamicparam_block,
)
else:
fields['body'] = self._parse_statement_list(until=until)
return fields
def _parse_script(self) -> Ps1Script:
offset = self._current.offset
return Ps1Script(offset=offset, **self._parse_code_body())
def _try_parse_named_blocks(
self,
) -> tuple[Block | None, Block | None, Block | None, Block | None] | None:
if not self._at(
Ps1TokenKind.BEGIN,
Ps1TokenKind.PROCESS,
Ps1TokenKind.END,
Ps1TokenKind.DYNAMICPARAM,
):
return None
begin_block = None
process_block = None
end_block = None
dynamicparam_block = None
while self._at(
Ps1TokenKind.BEGIN,
Ps1TokenKind.PROCESS,
Ps1TokenKind.END,
Ps1TokenKind.DYNAMICPARAM,
):
kw = self._advance()
self._skip_newlines()
block = self._parse_block()
if kw.kind == Ps1TokenKind.BEGIN:
begin_block = block
elif kw.kind == Ps1TokenKind.PROCESS:
process_block = block
elif kw.kind == Ps1TokenKind.END:
end_block = block
elif kw.kind == Ps1TokenKind.DYNAMICPARAM:
dynamicparam_block = block
self._skip_newlines()
return begin_block, process_block, end_block, dynamicparam_block
def _parse_block(self) -> Block:
offset = self._current.offset
self._expect(Ps1TokenKind.LBRACE)
self._skip_newlines()
stmts = self._parse_statement_list(until=Ps1TokenKind.RBRACE)
self._expect(Ps1TokenKind.RBRACE)
return Block(offset=offset, body=stmts)
def _parse_statement_list(self, until: Ps1TokenKind | None = None) -> list[Statement]:
stmts: list[Statement] = []
while not self._at(Ps1TokenKind.EOF):
while self._at(Ps1TokenKind.NEWLINE, Ps1TokenKind.SEMICOLON):
self._advance()
if until is not None and self._at(until):
break
if self._at(Ps1TokenKind.EOF):
break
mark = self._current.offset
stmt = self._parse_statement()
if stmt is not None:
stmts.append(stmt)
elif self._current.offset == mark:
tok = self._advance()
stmts.append(Ps1ExpressionStatement(
offset=tok.offset,
expression=Ps1ErrorNode(offset=tok.offset, text=tok.value),
))
while self._at(Ps1TokenKind.NEWLINE, Ps1TokenKind.SEMICOLON):
self._advance()
return stmts
def _parse_statement(self) -> Statement | None:
self._skip_newlines()
tok = self._current
kind = tok.kind
label = None
if kind == Ps1TokenKind.LABEL:
label = tok.value
self._advance()
self._skip_newlines()
kind = self._current.kind
if kind == Ps1TokenKind.IF:
return self._parse_if()
if kind == Ps1TokenKind.WHILE:
return self._parse_while(label)
if kind == Ps1TokenKind.DO:
return self._parse_do(label)
if kind == Ps1TokenKind.FOR:
return self._parse_for(label)
if kind == Ps1TokenKind.FOREACH:
return self._parse_foreach(label)
if kind == Ps1TokenKind.SWITCH:
return self._parse_switch(label)
if kind == Ps1TokenKind.TRY:
return self._parse_try()
if kind == Ps1TokenKind.TRAP:
return self._parse_trap()
if kind in (Ps1TokenKind.FUNCTION, Ps1TokenKind.FILTER):
return self._parse_function_definition()
if kind == Ps1TokenKind.CLASS:
return self._parse_class_definition()
if kind == Ps1TokenKind.ENUM:
return self._parse_enum_definition()
if kind == Ps1TokenKind.RETURN:
return self._parse_return()
if kind == Ps1TokenKind.THROW:
return self._parse_throw()
if kind == Ps1TokenKind.BREAK:
return self._parse_break()
if kind == Ps1TokenKind.CONTINUE:
return self._parse_continue()
if kind == Ps1TokenKind.EXIT:
return self._parse_exit()
if kind == Ps1TokenKind.DATA:
return self._parse_data()
return self._parse_pipeline_or_assignment()
def _parse_pipeline_or_assignment(self) -> Statement | None:
expr = self._parse_pipeline_expression(keyword_names_a_command=True)
if expr is None:
if self._at(Ps1TokenKind.EOF, Ps1TokenKind.RBRACE, Ps1TokenKind.RPAREN):
return None
tok = self._advance()
return Ps1ExpressionStatement(
offset=tok.offset,
expression=Ps1ErrorNode(offset=tok.offset, text=tok.value),
)
return Ps1ExpressionStatement(offset=expr.offset, expression=expr)
def _starts_command(self, *, keyword_names_a_command: bool) -> bool:
"""
Decide whether what comes next is a command or an expression. The token is re-scanned in
expression mode first, so the kind classified is a fact about *this* pipeline element rather
than about whatever was parsed before it, and it is then discarded again, which is what makes
scanning it here safe: `_parse_command` re-scans in argument mode before it reads a name.
Anything that cannot open an expression opens a command. Asking it in that direction is what
keeps the answer tied to the grammar, but it also means a kind nobody thought about becomes a
command name, so the kinds that may never be one are named in `_ARGUMENT_FORBIDDEN_KINDS` and
`_parse_command` declines them.
A keyword kind spells a command name only where nothing above will read it as a statement:
at a statement start, where `_parse_statement` has already declined, inside `( )`, which
holds a pipeline and never a statement, and after a `|`. Everywhere else — a `for` clause,
the operand of `return` — the keyword still opens a statement, and taking it for a name loses
that statement whole.
"""
with self._mode(Ps1LexerMode.EXPRESSION):
kind = self._current.kind
if kind.is_keyword:
return keyword_names_a_command
return kind not in self._UNARY_START_KINDS
def _parse_pipeline_expression(self, *, keyword_names_a_command: bool = False) -> Expression | None:
"""
Read one pipeline, starting from whichever of a command and an expression `_starts_command`
says it is.
"""
if self._starts_command(keyword_names_a_command=keyword_names_a_command):
first = self._parse_command()
if first is None:
return None
return self._parse_pipeline_tail(first)
with self._mode(Ps1LexerMode.EXPRESSION):
expr = self._parse_binary_expression(0)
if expr is None:
return None
operator = self._eat_assignment()
if operator is not None:
self._skip_newlines()
expr = Ps1AssignmentExpression(
offset=expr.offset,
target=expr,
operator=operator.value,
value=self._parse_assigned_value(),
)
return self._parse_pipeline_tail(expr)
def _eat_assignment(self) -> Ps1Token | None:
if self._current.kind.is_assignment:
return self._advance()
return None
def _parse_assigned_value(self) -> Node | None:
"""
Read what an assignment assigns. It is a whole statement, so `$x = if ($c) { 1 }` and
`$x = data { 1 }` are read alike and no list of the keywords worth allowing has to be kept.
A statement that is only an expression is unwrapped again, because an assignment whose value
is an expression is what the rest of the code reads. An assignment with nothing after it is
left without a value rather than being given the token that ends it.
Only a value that could open a statement is read as one. `_parse_statement` differs from the
pipeline it falls through to for exactly the keyword and label starts, and every assignment
in a chain costs the frames of whichever route it takes.
"""
if self._is_statement_terminator():
return None
if not (self._current.kind.is_keyword or self._at(Ps1TokenKind.LABEL)):
return self._parse_pipeline_expression()
statement = self._parse_statement()
if type(statement) is Ps1ExpressionStatement:
return statement.expression
return statement
def _parse_pipeline_tail(self, expr: Expression) -> Expression:
if not self._at(Ps1TokenKind.PIPE):
return expr
elements = [Ps1PipelineElement(offset=expr.offset, expression=expr)]
while self._eat(Ps1TokenKind.PIPE):
self._skip_newlines()
element = self._parse_pipeline_element()
if element is not None:
elements.append(element)
if len(elements) > 1:
return Ps1Pipeline(offset=elements[0].offset, elements=elements)
return expr
def _parse_pipeline_element(self) -> Ps1PipelineElement | None:
"""
Read one element after a `|`. It is classified the way the first element is, so `1 | 2` keeps
the `2` as the number it spells; PowerShell reports that an expression may only come first
and then reads it anyway, and what an analyst needs back is the expression, not a command
named after it.
A keyword names a command here without exception, because nothing above reads a statement
from the middle of a pipeline. Asking with the caller's answer instead would sever
`$x = Get-Process | ForEach-Object { }` at the pipe, `ForEach-Object` being the `foreach`
keyword to a lexer in expression mode.
"""
if self._starts_command(keyword_names_a_command=True):
command = self._parse_command()
if command is None:
return None
return Ps1PipelineElement(offset=command.offset, expression=command)
expr = self._parse_expression()
if expr is None:
return None
return Ps1PipelineElement(
offset=expr.offset,
expression=expr,
redirections=self._parse_redirections(),
)
def _parse_command_name(self) -> Expression:
"""
Read a command name from a token that is none of the shapes the caller has already taken. A
token that begins an expression names the command with the expression it opens, so that
`& $(Get-Command ls)`, `& @{ }` and a here-string each keep the structure they spell.
Anything else is the bare word it spells, which is what makes `Copy-Item . dest` three
elements of one command.
"""
if self._current.kind in self._UNARY_START_KINDS:
with self._mode(Ps1LexerMode.EXPRESSION):
name = self._parse_primary_expression()
if name is not None:
return name
return self._bare_string(self._advance())
def _parse_command(self) -> Expression | None:
"""
Read a command, starting from its name. Argument mode is entered *before* the name rather
than after it, so the name arrives as the one token PowerShell resolves: `foo=123`,
`C:\\x\\y.exe` and `.\\a.ps1` are each whole, while `. { }` and `. $sb` still split, because
argument mode ends a token at a dot that is followed by a space.
"""
with self._mode(Ps1LexerMode.ARGUMENT):
offset = self._current.offset
invocation_operator = ''
if self._at(Ps1TokenKind.AMPERSAND, Ps1TokenKind.DOT):
invocation_operator = self._advance().value
self._skip_newlines()
name_expr: Expression | None = None
if self._at(Ps1TokenKind.VARIABLE, Ps1TokenKind.SPLAT_VARIABLE):
name_expr = self._parse_variable()
elif self._at(Ps1TokenKind.LBRACE):
name_expr = self._parse_script_block()
elif self._at(Ps1TokenKind.LPAREN):
name_expr = self._parse_paren_expression()
elif self._at(Ps1TokenKind.STRING_EXPAND, Ps1TokenKind.STRING_VERBATIM):
name_expr = self._parse_string()
elif self._current.kind in _ARGUMENT_FORBIDDEN_KINDS:
if invocation_operator:
return Ps1CommandInvocation(
offset=offset, invocation_operator=invocation_operator)
return None
else:
name_expr = self._parse_command_name()
if invocation_operator and name_expr is not None:
with self._mode(Ps1LexerMode.EXPRESSION):
name_expr = self._parse_primary_postfix(name_expr)
arguments: list[Ps1CommandArgument | Expression] = []
redirections: list[Ps1Redirection] = []
while not self._is_pipeline_terminator():
if self._at(Ps1TokenKind.PARAMETER):
tok = self._advance()
name = tok.value
if name.endswith(':'):
name = name[:-1]
if not self._is_pipeline_terminator():
val = self._parse_argument_value()
arguments.append(Ps1CommandArgument(
offset=tok.offset,
kind=Ps1CommandArgumentKind.NAMED,
name=name,
value=val,
))
else:
arguments.append(Ps1CommandArgument(
offset=tok.offset,
kind=Ps1CommandArgumentKind.SWITCH,
name=name,
))
else:
arguments.append(Ps1CommandArgument(
offset=tok.offset,
kind=Ps1CommandArgumentKind.SWITCH,
name=name,
))
elif self._at(Ps1TokenKind.COMMA):
self._advance()
elif (redirection := self._try_parse_redirection()) is not None:
redirections.append(redirection)
elif self._at(Ps1TokenKind.OPERATOR):
tok = self._advance()
arguments.append(Ps1CommandArgument(
offset=tok.offset,
kind=Ps1CommandArgumentKind.SWITCH,
name=tok.value,
))
else:
val = self._parse_argument_value()
if val is None:
break
arguments.append(Ps1CommandArgument(
offset=val.offset,
kind=Ps1CommandArgumentKind.POSITIONAL,
value=val,
))
return Ps1CommandInvocation(
offset=offset,
name=name_expr,
arguments=arguments,
invocation_operator=invocation_operator,
redirections=redirections,
)
def _is_pipeline_terminator(self) -> bool:
return self._current.kind in _PIPELINE_TERMINATORS
def _parse_argument_value(self) -> Expression | None:
first = self._parse_single_argument_value()
if first is None:
return None
if not self._at(Ps1TokenKind.COMMA):
return first
elements = [first]
while self._eat(Ps1TokenKind.COMMA):
self._skip_newlines()
elem = self._parse_single_argument_value()
if elem is None:
break
elements.append(elem)
if len(elements) == 1:
return elements[0]
return Ps1ArrayLiteral(offset=first.offset, elements=elements)
def _parse_generic_as_string(self, tok: Ps1Token) -> Expression:
# Decode both GENERIC_EXPAND and plain GENERIC_TOKEN through the same splitter so embedded
# quotes and backtick escapes (e.g. `echo a'b c'd`) are resolved consistently. A plain
# GENERIC_TOKEN never contains a `$` expansion, so the split yields a single literal.
parts = self._split_generic_expandable(tok.value)
if len(parts) == 1 and isinstance(parts[0], Ps1StringLiteral):
return Ps1StringLiteral(
offset=tok.offset, value=parts[0].value, raw=tok.value)
return Ps1ExpandableString(
offset=tok.offset, parts=parts, raw=tok.value)
def _parse_single_argument_value(self) -> Expression | None:
if self._at(Ps1TokenKind.GENERIC_TOKEN, Ps1TokenKind.GENERIC_EXPAND):
tok = self._advance()
return self._parse_generic_as_string(tok)
if self._current.kind in _ARGUMENT_FORBIDDEN_KINDS:
return None
with self._mode(Ps1LexerMode.EXPRESSION):
if self._current.kind in self._ARGUMENT_PRIMARY_KINDS:
value = self._parse_unary_expression()
if value is not None:
return value
return self._bare_string(self._advance())
def _parse_expression(self) -> Expression | None:
with self._mode(Ps1LexerMode.EXPRESSION):
return self._parse_binary_expression(0)
def _current_binary_precedence(self) -> int | None:
if self._current.kind == Ps1TokenKind.DOTDOT:
return 70
if self._current.kind in (Ps1TokenKind.STAR, Ps1TokenKind.SLASH, Ps1TokenKind.PERCENT):
return 50
if self._current.kind in (Ps1TokenKind.PLUS, Ps1TokenKind.DASH):
return 40
if self._current.kind == Ps1TokenKind.OPERATOR:
v = self._current.value
if v == '-f':
return 60
return _BINARY_PRECEDENCE.get(v)
return None
def _parse_binary_expression(self, min_prec: int) -> Expression | None:
left = self._parse_array_literal_expression()
if left is None:
return None
while True:
prec = self._current_binary_precedence()
if prec is None or prec < min_prec:
break
op = self._advance()
self._skip_newlines()
right = self._parse_binary_expression(prec + 1)
if right is None:
break
if op.kind == Ps1TokenKind.DOTDOT:
left = Ps1RangeExpression(offset=left.offset, start=left, end=right)
else:
left = Ps1BinaryExpression(offset=left.offset, left=left, operator=op.value, right=right)
return left
def _parse_array_literal_expression(self) -> Expression | None:
first = self._parse_unary_expression()
if first is None:
return None
if self._disable_comma or not self._at(Ps1TokenKind.COMMA):
return first
elements = [first]
while self._eat(Ps1TokenKind.COMMA):
self._skip_newlines()
elem = self._parse_unary_expression()
if elem is None:
break
elements.append(elem)
if len(elements) == 1:
return elements[0]
return Ps1ArrayLiteral(offset=first.offset, elements=elements)
def _parse_argument_expression(self) -> Expression | None:
"""
Parse a single method argument expression. Uses the full expression
grammar but disables the comma operator so that commas delimit
arguments rather than forming array literals.
"""
with self._comma_mode(disabled=True):
return self._parse_expression()
def _parse_unary_expression(self) -> Expression | None:
tok = self._current
if tok.kind == Ps1TokenKind.COMMA:
if self._disable_comma:
return None
self._advance()
self._skip_newlines()
operand = self._parse_unary_expression()
if operand is None:
return Ps1ArrayLiteral(offset=tok.offset, elements=[])
return Ps1ArrayLiteral(offset=tok.offset, elements=[operand])
if tok.kind in (Ps1TokenKind.INCREMENT, Ps1TokenKind.DECREMENT):
op = self._advance()
self._skip_newlines()
operand = self._parse_unary_expression()
return Ps1UnaryExpression(
offset=tok.offset, operator=op.value, operand=operand, prefix=True)
if tok.kind == Ps1TokenKind.EXCLAIM:
self._advance()
self._skip_newlines()
operand = self._parse_unary_expression()
return Ps1UnaryExpression(
offset=tok.offset, operator='!', operand=operand, prefix=True)
if tok.kind == Ps1TokenKind.OPERATOR and tok.value in ('-not', '-bnot', '-split', '-csplit', '-isplit', '-join'):
op = self._advance()
self._skip_newlines()
operand = self._parse_unary_expression()
return Ps1UnaryExpression(
offset=tok.offset, operator=op.value, operand=operand, prefix=True)
if tok.kind in (Ps1TokenKind.PLUS, Ps1TokenKind.DASH):
op = self._advance()
self._skip_newlines()
operand = self._parse_unary_expression()
return Ps1UnaryExpression(
offset=tok.offset, operator=op.value, operand=operand, prefix=True)
if tok.kind == Ps1TokenKind.LBRACKET:
type_expr = self._try_parse_type_literal()
if type_expr is not None:
if self._at(Ps1TokenKind.DOUBLE_COLON):
return self._parse_primary_postfix(type_expr)
if not self._at(Ps1TokenKind.NEWLINE, Ps1TokenKind.COMMA):
operand = self._parse_unary_expression()
if operand is not None:
return Ps1CastExpression(
offset=tok.offset, type_name=type_expr.name, operand=operand)
return self._parse_primary_postfix(type_expr)
return self._parse_primary_expression()
def _try_parse_type_literal(self) -> Ps1TypeExpression | None:
"""
A type name is read in expression mode no matter which mode the caller left behind: a
generic token does not end at `]` in argument mode, so the closing bracket is absorbed into
the name and the bracket depth never returns to zero. In that mode the opening bracket is
not a bracket either, which is why no caller may ask for one before calling this.
"""
return self._attempt(Ps1LexerMode.EXPRESSION, self._read_type_literal)
def _read_type_literal(self) -> Ps1TypeExpression | None:
"""
Read the `[ ]` of a type name, or nothing at all. A bracket that the source never closes is
not a type name, so everything that ends a statement or a block ends the attempt as well:
the alternative is a name that swallows the brace its block was waiting for, and with it
every statement that block still had to give.
"""
if not self._at(Ps1TokenKind.LBRACKET):
return None
offset = self._current.offset
self._advance()
self._skip_newlines()
name_parts: list[str] = []
depth = 1
while depth > 0:
if self._at(Ps1TokenKind.RBRACKET):
depth -= 1
if depth == 0:
self._advance()
break
name_parts.append(']')
self._advance()
elif self._at(Ps1TokenKind.LBRACKET):
depth += 1
name_parts.append('[')
self._advance()
elif (
self._at(Ps1TokenKind.NEWLINE, Ps1TokenKind.SEMICOLON, Ps1TokenKind.RBRACE)
or self._at(Ps1TokenKind.EOF)
):
return None
else:
name_parts.append(self._current.value)
self._advance()
name = ''.join(name_parts).strip()
if not name:
return None
return Ps1TypeExpression(offset=offset, name=name)
def _parse_primary_expression(self) -> Expression | None:
expr = self._parse_primary_atom()
if expr is None:
return None
return self._parse_primary_postfix(expr)
def _parse_primary_postfix(self, expr: Expression) -> Expression:
while self._adjacent():
if self._at(Ps1TokenKind.DOT, Ps1TokenKind.DOUBLE_COLON):
expr = self._parse_member_access(expr)
elif self._at(Ps1TokenKind.LBRACKET):
expr = self._parse_index_expression(expr)
elif self._at(Ps1TokenKind.INCREMENT):
op = self._advance()
expr = Ps1UnaryExpression(
offset=op.offset, operator='++', operand=expr, prefix=False)
elif self._at(Ps1TokenKind.DECREMENT):
op = self._advance()
expr = Ps1UnaryExpression(
offset=op.offset, operator='--', operand=expr, prefix=False)
else:
break
return expr
def _parse_primary_atom(self) -> Expression | None:
rule = self._ATOM_RULES.get(self._current.kind)
if rule is None:
return None
return rule(self)
def _parse_bare_word(self) -> Expression:
return self._parse_generic_as_string(self._advance())
def _parse_integer(self) -> Ps1IntegerLiteral:
tok = self._advance()
raw = tok.value
text = raw.rstrip('lL').replace('_', '')
try:
if text[:2].lower() in ('0x', '0b'):
value = int(text, 0)
else:
# Plain decimal: parse with an explicit base so leading zeros (`007`) are accepted
# as decimal instead of raising under `int(text, 0)`.
value = int(text, 10)
except ValueError:
value = 0
return Ps1IntegerLiteral(offset=tok.offset, value=value, raw=raw)
def _parse_real(self) -> Ps1RealLiteral:
tok = self._advance()
raw = tok.value
text = raw.replace('_', '')
value = 0.0
for suffix, mult in _MULTIPLIER_SUFFIXES.items():
if text.lower().endswith(suffix):
text = text[:-len(suffix)].rstrip('lL')
try:
value = float(int(text, 0)) * mult
except (ValueError, OverflowError):
try:
value = float(text) * mult
except ValueError:
pass
break
else:
for suffix in ('d', 'D'):
if text.endswith(suffix):
text = text[:-1]
break
try:
value = float(text)
except ValueError:
pass
return Ps1RealLiteral(offset=tok.offset, value=value, raw=raw)
def _parse_string(self) -> Expression:
tok = self._advance()
raw = _normalize_string_delimiters(tok.value, 1)
if tok.kind == Ps1TokenKind.STRING_VERBATIM:
content = self._unescape_verbatim_string(tok.value[1:-1])
return Ps1StringLiteral(offset=tok.offset, value=content, raw=raw)
inner = raw[1:-1]
parts = self._split_expandable_string(inner)
if len(parts) == 1 and isinstance(parts[0], Ps1StringLiteral):
return Ps1StringLiteral(offset=tok.offset, value=parts[0].value, raw=raw)
return Ps1ExpandableString(offset=tok.offset, parts=parts, raw=raw)
def _try_parse_dollar_expansion(self, text: str, pos: int) -> tuple[Expression | None, int]:
length = len(text)
if pos + 1 < length and text[pos + 1] == '(':
node, new_pos = self._parse_embedded_subexpression(text, pos)
return node, new_pos
m = _VARIABLE_FRAG.match(text, pos)
if m:
return self._make_variable_from_text(m.group()), m.end()
return None, pos
def _split_expandable_string(self, text: str) -> list[Expression]:
parts: list[Expression] = []
pos = 0
length = len(text)
buf: list[str] = []
def flush_text():
if buf:
raw_text = ''.join(buf)
decoded = self._decode_dq_escapes(raw_text)
parts.append(Ps1StringLiteral(offset=-1, value=decoded, raw=raw_text))
buf.clear()
while pos < length:
c = text[pos]
if c == '`' and pos + 1 < length:
buf.append(c)
buf.append(text[pos + 1])
pos += 2
continue
if c in DOUBLE_QUOTES and pos + 1 < length and text[pos + 1] in DOUBLE_QUOTES:
buf.append('""')
pos += 2
continue
if c == '$':
node, new_pos = self._try_parse_dollar_expansion(text, pos)
if node is not None:
flush_text()
parts.append(node)
pos = new_pos
continue
buf.append(c)
pos += 1
flush_text()
return parts
def _split_generic_expandable(self, text: str) -> list[Expression]:
"""
Split a GENERIC_EXPAND token value into interleaved literal and expression parts. Unlike
`Ps1Parser._split_expandable_string` (for double-quoted
strings), this operates on raw source text that may contain embedded single/double-quoted
strings and backtick escapes.
"""
parts: list[Expression] = []
pos = 0
length = len(text)
buf: list[str] = []
def flush_text():
if buf:
value = ''.join(buf)
parts.append(Ps1StringLiteral(offset=-1, value=value, raw=value))
buf.clear()
while pos < length:
c = text[pos]
if c == '`' and pos + 1 < length:
decoded, pos = _decode_backtick(text, pos, length)
buf.append(decoded)
continue
if c in SINGLE_QUOTES:
pos += 1
while pos < length:
if text[pos] in SINGLE_QUOTES:
pos += 1
if pos < length and text[pos] in SINGLE_QUOTES:
buf.append("'")
pos += 1
continue
break
buf.append(text[pos])
pos += 1
continue
if c in DOUBLE_QUOTES:
flush_text()
pos += 1
inner_start = pos
while pos < length:
if text[pos] == '`' and pos + 1 < length:
pos += 2
continue
if text[pos] in DOUBLE_QUOTES:
if pos + 1 < length and text[pos + 1] in DOUBLE_QUOTES:
pos += 2
continue
inner_end = pos
pos += 1
break
pos += 1
else:
inner_end = pos
inner = text[inner_start:inner_end]
sub_parts = self._split_expandable_string(inner)
parts.extend(sub_parts)
continue
if c == '$':
node, new_pos = self._try_parse_dollar_expansion(text, pos)
if node is not None:
flush_text()
parts.append(node)
pos = new_pos
continue
buf.append(c)
pos += 1
flush_text()
return parts
@classmethod
def _scan_subexpression_extent(cls, text: str, pos: int) -> int:
"""
Given that `'$(` occurs at `pos + 2`, skip past the matching closing parenthesis while
correctly handling nested parentheses and quoted strings. Returns the position immediately
after the last one.
"""
length = len(text)
depth = 1
pos += 2
while pos < length and depth > 0:
sc = text[pos]
if sc == '`' and pos + 1 < length:
# A backtick escapes the next character (including a parenthesis) the same way the
# lexer skips it, so the parser and lexer agree on where the subexpression ends.
pos += 2
continue
if sc in SINGLE_QUOTES:
pos = cls._skip_quoted_raw(text, pos + 1, length, SINGLE_QUOTES)
continue
if sc in DOUBLE_QUOTES:
pos = cls._skip_quoted_raw(text, pos + 1, length, DOUBLE_QUOTES, backtick=True)
continue
if sc == '(':
depth += 1
elif sc == ')':
depth -= 1
pos += 1
return pos
def _parse_embedded_subexpression(self, text: str, start: int) -> tuple[Ps1SubExpression, int]:
"""
Parse a `$(...)` subexpression embedded in a string starting at position `start` (which
points at the dollar). Returns the AST node and the position after the closing parenthesis.
"""
end = self._scan_subexpression_extent(text, start)
sub_text = text[start + 2:end - 1]
sub_parser = Ps1Parser(sub_text)
sub_stmts = sub_parser._parse_statement_list()
return Ps1SubExpression(offset=-1, body=sub_stmts), end
def _decode_dq_escapes(self, text: str) -> str:
result: list[str] = []
i = 0
length = len(text)
while i < length:
c = text[i]
if c == '`' and i + 1 < length:
decoded, i = _decode_backtick(text, i, length)
result.append(decoded)
elif c in DOUBLE_QUOTES and i + 1 < length and text[i + 1] in DOUBLE_QUOTES:
result.append('"')
i += 2
else:
result.append(c)
i += 1
return ''.join(result)
@staticmethod
def _unescape_verbatim_string(text: str) -> str:
result: list[str] = []
i = 0
length = len(text)
while i < length:
c = text[i]
if c in SINGLE_QUOTES and i + 1 < length and text[i + 1] in SINGLE_QUOTES:
result.append("'")
i += 2
else:
result.append(c)
i += 1
return ''.join(result)
def _make_variable_from_text(self, text: str) -> Ps1Variable:
name = text
splatted = name.startswith('@')
if name.startswith('$') or name.startswith('@'):
name = name[1:]
braced = False
if name.startswith('{') and name.endswith('}'):
braced = True
name = name[1:-1]
if '`' in name:
name = _strip_backtick_noop(name)
scope = Ps1ScopeModifier.NONE
drive = ''
if ':' in name:
prefix, rest = name.split(':', 1)
prefix_lower = prefix.lower()
try:
scope = Ps1ScopeModifier(prefix_lower)
except ValueError:
scope = Ps1ScopeModifier.DRIVE
drive = prefix
name = rest
if not braced and name.startswith('{') and name.endswith('}'):
braced = True
name = name[1:-1]
return Ps1Variable(
offset=-1, name=name, scope=scope, braced=braced, splatted=splatted, drive=drive
)
def _parse_here_string(self) -> Expression:
tok = self._advance()
raw = _normalize_string_delimiters(tok.value, 2)
if tok.kind == Ps1TokenKind.HSTRING_VERBATIM:
inner = self._strip_here_string(raw, "@'", "'@")
return Ps1HereString(
offset=tok.offset, value=inner, raw=raw)
inner = self._strip_here_string(raw, '@"', '"@')
parts = self._split_expandable_string(inner)
if len(parts) == 1 and isinstance(parts[0], Ps1StringLiteral):
return Ps1HereString(
offset=tok.offset, value=parts[0].value, raw=raw)
return Ps1ExpandableHereString(offset=tok.offset, parts=parts, raw=raw)
@staticmethod
def _strip_here_string(raw: str, open_delim: str, close_delim: str) -> str:
inner = raw[len(open_delim):]
if inner.endswith(close_delim):
inner = inner[:-len(close_delim)]
inner = inner.lstrip(' \t')
if inner.startswith('\r\n'):
inner = inner[2:]
elif inner.startswith(('\n', '\r')):
inner = inner[1:]
if inner.endswith('\r\n'):
inner = inner[:-2]
elif inner.endswith(('\n', '\r')):
inner = inner[:-1]
return inner
def _parse_variable(self) -> Ps1Variable:
tok = self._advance()
return self._make_variable_from_text(tok.value)
def _parse_paren_expression(self) -> Expression:
offset = self._current.offset
self._expect(Ps1TokenKind.LPAREN)
self._skip_newlines()
with self._mode(Ps1LexerMode.ARGUMENT):
with self._comma_mode(disabled=False):
expr = self._parse_pipeline_expression(keyword_names_a_command=True)
self._skip_newlines()
self._expect(Ps1TokenKind.RPAREN)
return Ps1ParenExpression(offset=offset, expression=expr)
def _parse_delimited_statement_block(
self,
open_kind: Ps1TokenKind,
cls: type[Ps1SubExpression] | type[Ps1ArrayExpression],
) -> Expression:
offset = self._current.offset
self._expect(open_kind)
self._skip_newlines()
with self._mode(Ps1LexerMode.ARGUMENT):
with self._comma_mode(disabled=False):
stmts = self._parse_statement_list(until=Ps1TokenKind.RPAREN)
self._skip_newlines()
self._expect(Ps1TokenKind.RPAREN)
return cls(offset=offset, body=stmts)
def _parse_sub_expression(self) -> Expression:
return self._parse_delimited_statement_block(
Ps1TokenKind.DOLLAR_LPAREN, Ps1SubExpression)
def _parse_array_expression(self) -> Expression:
return self._parse_delimited_statement_block(
Ps1TokenKind.AT_LPAREN, Ps1ArrayExpression)
def _parse_label_or_key(self) -> Expression | None:
if self._at(
Ps1TokenKind.GENERIC_TOKEN,
Ps1TokenKind.GENERIC_EXPAND,
Ps1TokenKind.LABEL,
) or self._current.kind.is_keyword:
return self._bare_string(self._advance())
if self._is_statement_terminator():
return None
with self._comma_mode(disabled=True):
return self._parse_unary_expression()
def _parse_hash_literal(self) -> Ps1HashLiteral:
offset = self._current.offset
self._expect(Ps1TokenKind.AT_LBRACE)
self._skip_newlines()
pairs: list[tuple[Expression, Expression]] = []
while not self._at(Ps1TokenKind.RBRACE, Ps1TokenKind.EOF):
self._skip_newlines()
if self._at(Ps1TokenKind.RBRACE):
break
with self._mode(Ps1LexerMode.EXPRESSION):
key = self._parse_label_or_key()
if key is None:
break
self._skip_newlines()
self._expect(Ps1TokenKind.EQUALS)
with self._mode(Ps1LexerMode.ARGUMENT):
self._skip_newlines()
value = self._parse_assigned_value()
if value is None:
value = Ps1StringLiteral(offset=self._current.offset, value='', raw='')
pairs.append((key, value))
while self._current.kind in (Ps1TokenKind.NEWLINE, Ps1TokenKind.SEMICOLON):
self._advance()
self._skip_newlines()
self._expect(Ps1TokenKind.RBRACE)
return Ps1HashLiteral(offset=offset, pairs=pairs)
def _parse_script_block(self) -> Ps1ScriptBlock:
offset = self._current.offset
self._expect(Ps1TokenKind.LBRACE)
with self._comma_mode(disabled=False):
fields = self._parse_code_body(until=Ps1TokenKind.RBRACE)
self._skip_newlines()
self._expect(Ps1TokenKind.RBRACE)
return Ps1ScriptBlock(offset=offset, **fields)
def _parse_member_access(self, obj: Expression) -> Expression:
access_tok = self._advance()
access = (
Ps1AccessKind.STATIC
if access_tok.kind == Ps1TokenKind.DOUBLE_COLON
else Ps1AccessKind.INSTANCE
)
self._skip_newlines()
member: str | Expression
if self._at(Ps1TokenKind.GENERIC_TOKEN):
tok = self._advance()
member = tok.value
elif self._at(Ps1TokenKind.VARIABLE):
member = self._parse_variable()
elif self._at(Ps1TokenKind.STRING_EXPAND, Ps1TokenKind.STRING_VERBATIM):
member = self._parse_string()
elif self._at(Ps1TokenKind.LPAREN):
member = self._parse_paren_expression()
elif self._at(Ps1TokenKind.DOLLAR_LPAREN):
member = self._parse_sub_expression()
else:
tok = self._advance()
member = tok.value
if self._at(Ps1TokenKind.LPAREN) and self._adjacent():
self._advance()
self._skip_newlines()
args: list[Expression] = []
while not self._at(Ps1TokenKind.RPAREN, Ps1TokenKind.EOF):
arg = self._parse_argument_expression()
if arg is not None:
args.append(arg)
self._skip_newlines()
if not self._eat(Ps1TokenKind.COMMA):
break
self._skip_newlines()
self._expect(Ps1TokenKind.RPAREN)
return Ps1InvokeMember(
offset=obj.offset,
object=obj,
member=member,
arguments=args,
access=access,
)
return Ps1MemberAccess(offset=obj.offset, object=obj, member=member, access=access)
def _parse_index_expression(self, obj: Expression) -> Expression:
self._advance()
self._skip_newlines()
with self._comma_mode(disabled=False):
index = self._parse_expression()
self._skip_newlines()
self._expect(Ps1TokenKind.RBRACKET)
return Ps1IndexExpression(offset=obj.offset, object=obj, index=index)
def _parse_if(self) -> Ps1IfStatement:
offset = self._current.offset
clauses: list[tuple[Expression, Block]] = []
self._expect(Ps1TokenKind.IF)
self._skip_newlines()
cond = self._parse_parenthesized_condition()
self._skip_newlines()
body = self._parse_block()
clauses.append((cond, body))
self._skip_newlines()
while self._at(Ps1TokenKind.ELSEIF):
self._advance()
self._skip_newlines()
cond = self._parse_parenthesized_condition()
self._skip_newlines()
body = self._parse_block()
clauses.append((cond, body))
self._skip_newlines()
else_block = None
if self._at(Ps1TokenKind.ELSE):
self._advance()
self._skip_newlines()
else_block = self._parse_block()
return Ps1IfStatement(offset=offset, clauses=clauses, else_block=else_block)
def _parse_while(self, label: str | None = None) -> Ps1WhileLoop:
offset = self._current.offset
self._expect(Ps1TokenKind.WHILE)
self._skip_newlines()
cond = self._parse_parenthesized_condition()
self._skip_newlines()
body = self._parse_block()
return Ps1WhileLoop(offset=offset, condition=cond, body=body, label=label)
def _parse_do(self, label: str | None = None) -> Statement:
offset = self._current.offset
self._expect(Ps1TokenKind.DO)
self._skip_newlines()
body = self._parse_block()
self._skip_newlines()
is_until = self._at(Ps1TokenKind.UNTIL)
if is_until or self._at(Ps1TokenKind.WHILE):
self._advance()
self._skip_newlines()
cond = self._parse_parenthesized_condition()
return Ps1DoLoop(
offset=offset, condition=cond, body=body, is_until=is_until, label=label)
return Ps1DoLoop(offset=offset, body=body, label=label)
def _parse_for(self, label: str | None = None) -> Ps1ForLoop:
offset = self._current.offset
self._expect(Ps1TokenKind.FOR)
self._skip_newlines()
self._expect(Ps1TokenKind.LPAREN)
self._skip_newlines()
init = None
if not self._at(Ps1TokenKind.SEMICOLON):
init = self._parse_pipeline_expression()
self._eat(Ps1TokenKind.SEMICOLON)
self._skip_newlines()
cond = None
if not self._at(Ps1TokenKind.SEMICOLON):
cond = self._parse_pipeline_expression()
self._eat(Ps1TokenKind.SEMICOLON)
self._skip_newlines()
iter_expr = None
if not self._at(Ps1TokenKind.RPAREN):
iter_expr = self._parse_pipeline_expression()
self._skip_newlines()
self._expect(Ps1TokenKind.RPAREN)
self._skip_newlines()
body = self._parse_block()
return Ps1ForLoop(
offset=offset, initializer=init, condition=cond, iterator=iter_expr, body=body, label=label)
def _parse_foreach(self, label: str | None = None) -> Ps1ForEachLoop:
offset = self._current.offset
self._expect(Ps1TokenKind.FOREACH)
self._skip_newlines()
parallel = False
if self._at(Ps1TokenKind.PARAMETER) and self._current.value.lower().startswith('-parallel'):
self._advance()
parallel = True
self._expect(Ps1TokenKind.LPAREN)
self._skip_newlines()
var = self._parse_variable() if self._at(
Ps1TokenKind.VARIABLE, Ps1TokenKind.SPLAT_VARIABLE) else None
self._skip_newlines()
self._expect(Ps1TokenKind.IN)
self._skip_newlines()
iterable = self._parse_pipeline_expression()
self._skip_newlines()
self._expect(Ps1TokenKind.RPAREN)
self._skip_newlines()
body = self._parse_block()
return Ps1ForEachLoop(
offset=offset, variable=var, iterable=iterable, body=body, parallel=parallel, label=label)
def _parse_switch_clause_condition(self) -> Expression | None:
"""
Read one switch clause condition, which is a command argument and not an expression, so that
`Get-Thing` is one name there, as 5.1 reads it, and not a subtraction of `Thing` from `Get`.
A comma still joins several of them into one array, which is how `1,2 { ... }` matches two
values with one clause.
"""
with self._mode(Ps1LexerMode.ARGUMENT):
return self._parse_argument_value()
def _parse_switch(self, label: str | None = None) -> Ps1SwitchStatement:
offset = self._current.offset
self._expect(Ps1TokenKind.SWITCH)
self._skip_newlines()
regex = False
wildcard = False
exact = False
case_sensitive = False
file = False
value: Expression | None = None
with self._mode(Ps1LexerMode.ARGUMENT):
while self._at(Ps1TokenKind.PARAMETER):
p = self._current.value.lower().lstrip('-').rstrip(':')
self._advance()
self._skip_newlines()
if p == 'regex':
regex = True
elif p == 'wildcard':
wildcard = True
elif p == 'exact':
exact = True
elif p == 'casesensitive':
case_sensitive = True
elif p == 'file':
file = True
if file:
value = self._parse_argument_value()
if not file:
value = self._parse_parenthesized_condition()
self._skip_newlines()
self._expect(Ps1TokenKind.LBRACE)
self._skip_newlines()
clauses: list[tuple[Expression | None, Block]] = []
while not self._at(Ps1TokenKind.RBRACE, Ps1TokenKind.EOF):
self._skip_separators()
if self._at(Ps1TokenKind.RBRACE):
break
if self._at(Ps1TokenKind.GENERIC_TOKEN, Ps1TokenKind.GENERIC_EXPAND) and self._current.value.lower() == 'default':
self._advance()
self._skip_newlines()
block = self._parse_block()
clauses.append((None, block))
else:
cond = self._parse_switch_clause_condition()
self._skip_newlines()
block = self._parse_block()
clauses.append((cond, block))
self._skip_separators()
self._expect(Ps1TokenKind.RBRACE)
return Ps1SwitchStatement(
offset=offset,
value=value,
clauses=clauses,
label=label,
regex=regex,
wildcard=wildcard,
exact=exact,
case_sensitive=case_sensitive,
file=file,
)
def _parse_try(self) -> Ps1TryCatchFinally:
offset = self._current.offset
self._expect(Ps1TokenKind.TRY)
self._skip_newlines()
try_block = self._parse_block()
self._skip_newlines()
catch_clauses: list[Ps1CatchClause] = []
while self._at(Ps1TokenKind.CATCH):
self._advance()
self._skip_newlines()
types: list[str] = []
while True:
te = self._try_parse_type_literal()
if te is None:
break
types.append(te.name)
self._skip_newlines()
if not self._eat(Ps1TokenKind.COMMA):
break
self._skip_newlines()
body = self._parse_block()
catch_clauses.append(Ps1CatchClause(
offset=body.offset, types=types, body=body))
self._skip_newlines()
finally_block = None
if self._at(Ps1TokenKind.FINALLY):
self._advance()
self._skip_newlines()
finally_block = self._parse_block()
return Ps1TryCatchFinally(
offset=offset,
try_block=try_block,
catch_clauses=catch_clauses,
finally_block=finally_block,
)
def _parse_trap(self) -> Ps1TrapStatement:
offset = self._current.offset
self._expect(Ps1TokenKind.TRAP)
self._skip_newlines()
type_name = ''
te = self._try_parse_type_literal()
if te is not None:
type_name = te.name
self._skip_newlines()
body = self._parse_block()
return Ps1TrapStatement(offset=offset, type_name=type_name, body=body)
def _parse_function_definition(self) -> Ps1FunctionDefinition:
offset = self._current.offset
kw = self._advance()
is_filter = kw.kind == Ps1TokenKind.FILTER
name = ''
with self._mode(Ps1LexerMode.ARGUMENT):
self._skip_newlines()
if self._at(
Ps1TokenKind.GENERIC_TOKEN,
Ps1TokenKind.VARIABLE,
) or self._current.kind.is_keyword:
name = self._advance().value
with self._mode(Ps1LexerMode.EXPRESSION):
self._skip_newlines()
if self._at(Ps1TokenKind.LPAREN):
self._advance()
self._skip_newlines()
params = self._parse_parameter_list()
self._skip_newlines()
self._expect(Ps1TokenKind.RPAREN)
self._skip_newlines()
self._expect(Ps1TokenKind.LBRACE)
self._skip_newlines()
script_body = self._parse_script_block_body(expect_close=True)
script_body.param_block = Ps1ParamBlock(
offset=offset, parameters=params)
script_body.param_block.parent = script_body
return Ps1FunctionDefinition(
offset=offset, name=name, is_filter=is_filter, body=script_body)
body = self._parse_script_block()
return Ps1FunctionDefinition(
offset=offset, name=name, is_filter=is_filter, body=body)
def _parse_class_definition(self) -> Ps1ClassDefinition:
offset = self._current.offset
self._advance()
name = ''
base_types: list[str] = []
with self._mode(Ps1LexerMode.ARGUMENT):
self._skip_newlines()
if self._at(Ps1TokenKind.GENERIC_TOKEN) or self._current.kind.is_keyword:
name = self._advance().value
self._skip_newlines()
if self._eat_colon():
self._skip_newlines()
while not self._at(Ps1TokenKind.LBRACE, Ps1TokenKind.EOF):
type_lit = self._try_parse_type_literal()
if type_lit is not None:
base_types.append(type_lit.name)
elif self._at(Ps1TokenKind.GENERIC_TOKEN) or self._current.kind.is_keyword:
base_types.append(self._advance().value)
elif self._eat(Ps1TokenKind.COMMA):
self._skip_newlines()
continue
else:
break
self._skip_newlines()
self._skip_newlines()
self._expect(Ps1TokenKind.LBRACE)
members: list[Ps1PropertyMember | Ps1MethodMember] = []
with self._mode(Ps1LexerMode.EXPRESSION):
self._skip_newlines()
while not self._at(Ps1TokenKind.RBRACE, Ps1TokenKind.EOF):
while self._at(Ps1TokenKind.NEWLINE, Ps1TokenKind.SEMICOLON):
self._advance()
if self._at(Ps1TokenKind.RBRACE, Ps1TokenKind.EOF):
break
member = self._parse_class_member()
if member is not None:
members.append(member)
else:
break
self._expect(Ps1TokenKind.RBRACE)
return Ps1ClassDefinition(
offset=offset,
name=name,
base_types=base_types,
members=members,
)
def _parse_class_member(self) -> Ps1PropertyMember | Ps1MethodMember | None:
self._skip_newlines()
if self._at(Ps1TokenKind.RBRACE, Ps1TokenKind.EOF):
return None
offset = self._current.offset
attributes: list[Ps1Attribute] = []
type_constraint: Ps1TypeExpression | None = None
modifiers = Ps1MemberModifier.NONE
while True:
self._skip_newlines()
if self._at(Ps1TokenKind.LBRACKET):
attr = self._parse_attribute()
if isinstance(attr, Ps1Attribute):
attributes.append(attr)
elif type_constraint is None:
type_constraint = attr
self._skip_newlines()
continue
if (
self._current.kind == Ps1TokenKind.GENERIC_TOKEN
and self._current.value.lower() == 'static'
):
modifiers |= Ps1MemberModifier.STATIC
self._advance()
continue
if (
self._current.kind == Ps1TokenKind.GENERIC_TOKEN
and self._current.value.lower() == 'hidden'
):
modifiers |= Ps1MemberModifier.HIDDEN
self._advance()
continue
break
if self._at(Ps1TokenKind.VARIABLE):
var_tok = self._advance()
var = self._make_variable_from_text(var_tok.value)
initial_value: Expression | None = None
if self._eat(Ps1TokenKind.EQUALS):
self._skip_newlines()
initial_value = self._parse_expression()
while self._at(Ps1TokenKind.NEWLINE, Ps1TokenKind.SEMICOLON):
self._advance()
return Ps1PropertyMember(
offset=offset,
attributes=attributes,
modifiers=modifiers,
type_constraint=type_constraint,
variable=var,
initial_value=initial_value,
)
if (
self._at(Ps1TokenKind.GENERIC_TOKEN)
or self._current.kind.is_keyword
):
method_name = self._advance().value
self._skip_newlines()
params: list[Ps1ParameterDeclaration] = []
if self._at(Ps1TokenKind.LPAREN):
self._advance()
self._skip_newlines()
params = self._parse_parameter_list()
self._skip_newlines()
self._expect(Ps1TokenKind.RPAREN)
self._skip_newlines()
self._expect(Ps1TokenKind.LBRACE)
self._skip_newlines()
script_body = self._parse_script_block_body(expect_close=True)
if params:
script_body.param_block = Ps1ParamBlock(
offset=offset, parameters=params)
script_body.param_block.parent = script_body
funcdef = Ps1FunctionDefinition(
offset=offset,
name=method_name,
body=script_body,
)
return_type = type_constraint
return Ps1MethodMember(
offset=offset,
attributes=attributes,
modifiers=modifiers,
return_type=return_type,
definition=funcdef,
)
return None
def _parse_enum_definition(self) -> Ps1EnumDefinition:
offset = self._current.offset
self._advance()
name = ''
base_type = ''
members: list[Ps1EnumMember] = []
with self._mode(Ps1LexerMode.ARGUMENT):
self._skip_newlines()
if self._at(Ps1TokenKind.GENERIC_TOKEN) or self._current.kind.is_keyword:
name = self._advance().value
self._skip_newlines()
if self._eat_colon():
self._skip_newlines()
type_lit = self._try_parse_type_literal()
if type_lit is not None:
base_type = type_lit.name
elif self._at(Ps1TokenKind.GENERIC_TOKEN) or self._current.kind.is_keyword:
base_type = self._advance().value
self._skip_newlines()
self._expect(Ps1TokenKind.LBRACE)
with self._mode(Ps1LexerMode.EXPRESSION):
self._skip_newlines()
while not self._at(Ps1TokenKind.RBRACE, Ps1TokenKind.EOF):
member = self._parse_enum_member()
if member is not None:
members.append(member)
else:
break
self._expect(Ps1TokenKind.RBRACE)
return Ps1EnumDefinition(
offset=offset,
name=name,
base_type=base_type,
members=members,
)
def _parse_enum_member(self) -> Ps1EnumMember | None:
self._skip_newlines()
if self._at(Ps1TokenKind.RBRACE, Ps1TokenKind.EOF):
return None
offset = self._current.offset
if not (
self._at(Ps1TokenKind.GENERIC_TOKEN)
or self._current.kind.is_keyword
):
return None
name = self._advance().value
value: Expression | None = None
with self._mode(Ps1LexerMode.EXPRESSION):
if self._eat(Ps1TokenKind.EQUALS):
self._skip_newlines()
value = self._parse_expression()
while self._at(Ps1TokenKind.NEWLINE, Ps1TokenKind.SEMICOLON):
self._advance()
return Ps1EnumMember(offset=offset, name=name, value=value)
def _parse_script_block_body(self, expect_close: bool = False) -> Ps1ScriptBlock:
offset = self._current.offset
fields = self._parse_code_body(until=Ps1TokenKind.RBRACE)
self._skip_newlines()
if expect_close:
self._expect(Ps1TokenKind.RBRACE)
return Ps1ScriptBlock(offset=offset, **fields)
def _parse_param_block(self) -> Ps1ParamBlock | None:
"""
A parameter list is read in expression mode no matter which mode the caller left behind: in
argument mode a generic token does not end at `=`, so `param($x=1)` reads its default as
part of one word and every unspaced default in the language is lost.
Whether a body opens with one at all is decided by reading it, not by looking for the word
`param` first. A body opens in argument mode, where `param.exe` is one word and the keyword
is invisible, and it opens in expression mode over a script whose first statement is a call
to a program of that name; only reading as far as the `(` tells the two apart.
"""
return self._attempt(Ps1LexerMode.EXPRESSION, self._read_param_block)
def _read_param_block(self) -> Ps1ParamBlock | None:
offset = self._current.offset
attrs: list[Ps1Attribute] = []
while self._at(Ps1TokenKind.LBRACKET):
attr = self._parse_attribute()
if isinstance(attr, Ps1Attribute):
attrs.append(attr)
self._skip_newlines()
if not self._eat(Ps1TokenKind.PARAM):
return None
self._skip_newlines()
if not self._at(Ps1TokenKind.LPAREN):
return None
self._advance()
self._skip_newlines()
params = self._parse_parameter_list()
self._skip_newlines()
self._expect(Ps1TokenKind.RPAREN)
return Ps1ParamBlock(offset=offset, parameters=params, attributes=attrs)
def _parse_parameter_list(self) -> list[Ps1ParameterDeclaration]:
params: list[Ps1ParameterDeclaration] = []
while not self._at(Ps1TokenKind.RPAREN, Ps1TokenKind.EOF):
self._skip_newlines()
if self._at(Ps1TokenKind.RPAREN):
break
param = self._parse_parameter_declaration()
params.append(param)
self._skip_newlines()
if not self._eat(Ps1TokenKind.COMMA):
break
self._skip_newlines()
return params
def _parse_parameter_declaration(self) -> Ps1ParameterDeclaration:
offset = self._current.offset
attrs: list[Ps1Attribute | Ps1TypeExpression] = []
while self._at(Ps1TokenKind.LBRACKET):
attr = self._parse_attribute()
attrs.append(attr)
self._skip_newlines()
var = None
if self._at(Ps1TokenKind.VARIABLE, Ps1TokenKind.SPLAT_VARIABLE):
var = self._parse_variable()
default = None
if self._eat(Ps1TokenKind.EQUALS):
self._skip_newlines()
with self._comma_mode(disabled=True):
default = self._parse_expression()
return Ps1ParameterDeclaration(
offset=offset, variable=var, attributes=attrs, default_value=default)
def _parse_attribute(self) -> Ps1Attribute | Ps1TypeExpression:
"""
A type name and its arguments are read in expression mode no matter which mode the caller
left behind, for the reason `_try_parse_type_literal` states: in argument mode a generic
token does not end at `]`, so the name absorbs its own closing bracket and everything after
it. `& { param([int]$x) $x }` reaches here in argument mode, because the block is an
argument of the call operator, and swallowed the whole body before this.
"""
with self._mode(Ps1LexerMode.EXPRESSION):
return self._read_attribute()
def _read_attribute(self) -> Ps1Attribute | Ps1TypeExpression:
offset = self._current.offset
self._expect(Ps1TokenKind.LBRACKET)
self._skip_newlines()
name_parts: list[str] = []
depth = 0
while not self._at(Ps1TokenKind.LPAREN, Ps1TokenKind.EOF):
if self._at(Ps1TokenKind.LBRACKET):
depth += 1
name_parts.append('[')
self._advance()
elif self._at(Ps1TokenKind.RBRACKET):
if depth == 0:
break
depth -= 1
name_parts.append(']')
self._advance()
else:
name_parts.append(self._current.value)
self._advance()
name = ''.join(name_parts).strip()
if self._at(Ps1TokenKind.LPAREN):
self._advance()
self._skip_newlines()
positional: list[Expression] = []
named: list[tuple[str, Expression]] = []
with self._comma_mode(disabled=True):
while not self._at(Ps1TokenKind.RPAREN, Ps1TokenKind.EOF):
self._skip_newlines()
if self._at(Ps1TokenKind.RPAREN):
break
if self._at(Ps1TokenKind.GENERIC_TOKEN, Ps1TokenKind.GENERIC_EXPAND):
key_tok = self._advance()
if self._eat(Ps1TokenKind.EQUALS):
self._skip_newlines()
val = self._parse_expression()
if val is not None:
named.append((key_tok.value, val))
self._skip_newlines()
self._eat(Ps1TokenKind.COMMA)
continue
self._resync(key_tok.offset)
expr = self._parse_expression()
if expr is not None:
positional.append(expr)
self._skip_newlines()
if not self._eat(Ps1TokenKind.COMMA):
break
self._expect(Ps1TokenKind.RPAREN)
self._skip_newlines()
self._expect(Ps1TokenKind.RBRACKET)
return Ps1Attribute(
offset=offset, name=name,
positional_args=positional, named_args=named)
self._expect(Ps1TokenKind.RBRACKET)
return Ps1TypeExpression(offset=offset, name=name)
def _parse_flow_with_pipeline(self, kind: Ps1TokenKind, cls: type[Ps1Exit]) -> Statement:
offset = self._current.offset
self._expect(kind)
pipeline = None
if not self._is_statement_terminator():
pipeline = self._parse_pipeline_expression()
return cls(offset=offset, pipeline=pipeline)
def _parse_flow_with_label(self, kind: Ps1TokenKind, cls: type[Ps1Jump]) -> Statement:
offset = self._current.offset
self._expect(kind)
label = None
if not self._is_statement_terminator():
label = self._parse_label_or_key()
return cls(offset=offset, label=label)
def _parse_return(self) -> Statement:
return self._parse_flow_with_pipeline(Ps1TokenKind.RETURN, Ps1ReturnStatement)
def _parse_throw(self) -> Statement:
return self._parse_flow_with_pipeline(Ps1TokenKind.THROW, Ps1ThrowStatement)
def _parse_break(self) -> Statement:
return self._parse_flow_with_label(Ps1TokenKind.BREAK, Ps1BreakStatement)
def _parse_continue(self) -> Statement:
return self._parse_flow_with_label(Ps1TokenKind.CONTINUE, Ps1ContinueStatement)
def _parse_exit(self) -> Statement:
return self._parse_flow_with_pipeline(Ps1TokenKind.EXIT, Ps1ExitStatement)
def _parse_data(self) -> Ps1DataSection:
offset = self._current.offset
self._expect(Ps1TokenKind.DATA)
self._skip_newlines()
name = ''
commands: list[Expression] = []
with self._mode(Ps1LexerMode.ARGUMENT):
if self._at(Ps1TokenKind.GENERIC_TOKEN):
name = self._advance().value
self._skip_newlines()
if self._at(Ps1TokenKind.PARAMETER):
param = self._current.value.lower().lstrip('-').rstrip(':')
self._advance()
self._skip_newlines()
if param == 'supportedcommand':
while True:
self._skip_newlines()
arg = self._parse_single_argument_value()
if arg is None:
break
commands.append(arg)
if not self._eat(Ps1TokenKind.COMMA):
break
with self._mode(Ps1LexerMode.EXPRESSION):
body = self._parse_block()
return Ps1DataSection(offset=offset, name=name, commands=commands, body=body)
#: What a primary expression can begin with, and the rule that reads each one. Every question of
#: the form "can an expression start here" is answered from this table rather than from a list
#: kept beside it, because a list kept beside it drifts: the two that were here before this
#: disagreed with the grammar and with each other.
_ATOM_RULES: dict[Ps1TokenKind, Callable[[Ps1Parser], Expression | None]] = {
Ps1TokenKind.AT_LBRACE : _parse_hash_literal, # noqa
Ps1TokenKind.AT_LPAREN : _parse_array_expression, # noqa
Ps1TokenKind.DOLLAR_LPAREN : _parse_sub_expression, # noqa
Ps1TokenKind.GENERIC_EXPAND : _parse_bare_word, # noqa
Ps1TokenKind.GENERIC_TOKEN : _parse_bare_word, # noqa
Ps1TokenKind.HSTRING_EXPAND : _parse_here_string, # noqa
Ps1TokenKind.HSTRING_VERBATIM : _parse_here_string, # noqa
Ps1TokenKind.INTEGER : _parse_integer, # noqa
Ps1TokenKind.LBRACE : _parse_script_block, # noqa
Ps1TokenKind.LBRACKET : _try_parse_type_literal, # noqa
Ps1TokenKind.LPAREN : _parse_paren_expression, # noqa
Ps1TokenKind.REAL : _parse_real, # noqa
Ps1TokenKind.SPLAT_VARIABLE : _parse_variable, # noqa
Ps1TokenKind.STRING_EXPAND : _parse_string, # noqa
Ps1TokenKind.STRING_VERBATIM : _parse_string, # noqa
Ps1TokenKind.VARIABLE : _parse_variable, # noqa
}
#: What `Ps1Parser._parse_unary_expression` can begin with. A pipeline element that does not
#: begin with one of these is a command, which is why the bare word has to come out: it is the
#: spelling of every command name there is, and honouring it here takes `Get-ChildItem` apart.
_UNARY_START_KINDS = (frozenset(_ATOM_RULES) - _BARE_WORD_KINDS) | _UNARY_OPERATOR_KINDS
#: What a command argument can begin with. Narrower than the above by the two kinds that would
#: read past the argument they start: an operator takes the next argument as its operand, so
#: `echo a,-not` builds a unary expression with nothing under it, and a bracket reads
#: `Write-Host [ 0 ]` as a type name rather than as the three words PowerShell passes.
_ARGUMENT_PRIMARY_KINDS = _UNARY_START_KINDS - {
Ps1TokenKind.LBRACKET,
Ps1TokenKind.OPERATOR,
}
Classes
class Ps1Parser (source)-
Expand source code Browse git
class Ps1Parser: def __init__(self, source: str): self.source = source self._lexer = Ps1Lexer(source) self._pending: Ps1Token | None = None self._previous_end = 0 self._disable_comma = False @property def _current(self) -> Ps1Token: token = self._pending if token is None: token = self._pending = self._lexer.scan() return token def _advance(self) -> Ps1Token: token = self._current self._previous_end = self._lexer.pos self._pending = None return token def _adjacent(self) -> bool: """ Whether the current token begins where the previous one ended. A member access, an index or a postfix operator binds to what precedes it only when nothing separates the two, so that `$a.Length` reads a property where `$a . Length` is two command arguments with a bare dot between them. A block comment does not separate them, which is the one thing 5.1 lets through. """ gap_start = self._previous_end gap_end = self._current.offset if gap_start == gap_end: return True gap = self.source[gap_start:gap_end] if '<#' not in gap: return False return not _BLOCK_COMMENT.sub('', gap) def _resync(self, offset: int): """ Rewind to `offset` and drop the lookahead, so that the next read scans the source there again. Nothing is scanned here: the re-read happens when, and only if, a token is asked for. Where the rewind undoes tokens that were consumed, the end of the last consumed one moves back with it, because `Ps1Parser._adjacent` reads a gap between the two and a gap that runs backwards is not one. """ self._lexer.pos = offset self._previous_end = min(self._previous_end, offset) self._pending = None def _switch_mode(self, mode: Ps1LexerMode): if mode is self._lexer.mode: return self._lexer.mode = mode token = self._pending if token is not None and not token.kind.mode_invariant: self._resync(token.offset) @contextmanager def _mode(self, mode: Ps1LexerMode): """ Read the enclosed construct in `mode` and restore the caller's mode afterwards. Changing the mode discards the lookahead, so a token scanned in one mode is never handed to a parse that asked for another. That is the whole of the rule: it lives here rather than in the memory of whoever writes the next call site. """ previous = self._lexer.mode self._switch_mode(mode) try: yield finally: self._switch_mode(previous) def _attempt(self, mode: Ps1LexerMode, rule: Callable[[], _T | None]) -> _T | None: """ Read `rule` in `mode`, and leave the source where it was found if the rule declines. A speculative read is one thing and is written once: the mode the construct is spelled in, the place to return to, and the rule itself. What this replaces is a first-token guard kept outside the mode it asks about. In argument mode a `[` belongs to the word around it, so a caller that asks there whether a type name follows is told no about one that is plainly written; the guard belongs inside `rule`, where the mode is already the one the construct is read in. """ offset = self._current.offset with self._mode(mode): result = rule() if result is None: self._resync(offset) return result def _at(self, *kinds: Ps1TokenKind) -> bool: return self._current.kind in kinds def _eat(self, kind: Ps1TokenKind) -> Ps1Token | None: if self._current.kind == kind: return self._advance() return None def _eat_colon(self) -> bool: if self._current.kind == Ps1TokenKind.DOUBLE_COLON: self._advance() return True if self._current.kind == Ps1TokenKind.GENERIC_TOKEN and self._current.value == ':': self._advance() return True return False def _expect(self, kind: Ps1TokenKind) -> Ps1Token: if self._current.kind == kind: return self._advance() return Ps1Token(kind, '', self._current.offset) def _skip_newlines(self): while self._current.kind == Ps1TokenKind.NEWLINE: self._advance() def _parse_parenthesized_condition(self) -> Expression: self._expect(Ps1TokenKind.LPAREN) self._skip_newlines() expr = self._parse_pipeline_expression(keyword_names_a_command=True) self._skip_newlines() self._expect(Ps1TokenKind.RPAREN) if expr is None: return Ps1ErrorNode(offset=self._current.offset, message='missing condition') return expr def _skip_separators(self): while self._current.kind in ( Ps1TokenKind.COMMA, Ps1TokenKind.NEWLINE, Ps1TokenKind.SEMICOLON, ): self._advance() @staticmethod def _bare_string(tok: Ps1Token) -> Ps1StringLiteral: return Ps1StringLiteral(offset=tok.offset, value=tok.value, raw=tok.value) @contextmanager def _comma_mode(self, disabled: bool): old = self._disable_comma self._disable_comma = disabled try: yield finally: self._disable_comma = old def _parse_redirection( self, tok: Ps1Token, ) -> Ps1FileRedirection | Ps1MergingRedirection: op = tok.value m = _MERGING_PATTERN.fullmatch(op) if m is not None: prefix, to_digit = m.group(1), int(m.group(2)) if prefix is None: from_stream = Ps1RedirectionStream.OUTPUT elif prefix == '*': from_stream = Ps1RedirectionStream.ALL else: from_stream = Ps1RedirectionStream(int(prefix)) return Ps1MergingRedirection( offset=tok.offset, from_stream=from_stream, to_stream=Ps1RedirectionStream(to_digit), ) if op[0] == '*': stream = Ps1RedirectionStream.ALL rest = op[1:] elif op[0].isdigit(): stream = Ps1RedirectionStream(int(op[0])) rest = op[1:] else: stream = Ps1RedirectionStream.OUTPUT rest = op append = rest == '>>' return Ps1FileRedirection( offset=tok.offset, stream=stream, target=self._parse_redirection_target(), append=append, ) def _parse_input_redirection(self, tok: Ps1Token) -> Ps1InputRedirection: """ Read a `<` and the file it names. PowerShell reserves the operator, reports it and then goes on reading the command around it, so what this must not do is end the command or turn into the write that `>` spells. """ return Ps1InputRedirection( offset=tok.offset, source=self._parse_redirection_target()) def _parse_redirection_target(self) -> Expression | None: with self._mode(Ps1LexerMode.ARGUMENT): if self._is_pipeline_terminator(): return None return self._parse_single_argument_value() def _try_parse_redirection(self) -> Ps1Redirection | None: if self._at(Ps1TokenKind.REDIRECTION): return self._parse_redirection(self._advance()) if self._at(Ps1TokenKind.REDIRECT_IN): return self._parse_input_redirection(self._advance()) return None def _parse_redirections(self) -> list[Ps1Redirection]: redirections: list[Ps1Redirection] = [] while (redirection := self._try_parse_redirection()) is not None: redirections.append(redirection) return redirections @staticmethod def _skip_quoted_raw( src: str, pos: int, end: int, quote_set: frozenset[str], *, backtick: bool = False, ) -> int: """ Starting just after an opening quote character, advance past the quoted string content and the closing quote. Handles doubled-quote escapes and optionally backtick escapes. Returns the position immediately after the closing quote. """ while pos < end: ch = src[pos] if backtick and ch == '`' and pos + 1 < end: pos += 2 continue if ch in quote_set: pos += 1 if pos < end and src[pos] in quote_set: pos += 1 continue return pos pos += 1 return pos def _is_statement_terminator(self) -> bool: return self._current.kind in _STATEMENT_TERMINATORS def parse(self) -> Ps1Script: return self._parse_script() def _parse_code_body( self, until: Ps1TokenKind | None = None, ) -> dict: """ Read the statements a body holds, in argument mode. Every path into a body comes through here — a script, a script block, a function with a parameter list, a class method — so the mode a statement is read in is settled once rather than at each of them, and a body cannot reach the statement list carrying the mode of whatever declared it. """ with self._mode(Ps1LexerMode.ARGUMENT): return self._read_code_body(until) def _read_code_body(self, until: Ps1TokenKind | None) -> dict: self._skip_newlines() fields: dict = {} param_block = self._parse_param_block() if param_block is not None: fields['param_block'] = param_block self._skip_newlines() named = self._try_parse_named_blocks() if named is not None: begin_block, process_block, end_block, dynamicparam_block = named fields.update( begin_block=begin_block, process_block=process_block, end_block=end_block, dynamicparam_block=dynamicparam_block, ) else: fields['body'] = self._parse_statement_list(until=until) return fields def _parse_script(self) -> Ps1Script: offset = self._current.offset return Ps1Script(offset=offset, **self._parse_code_body()) def _try_parse_named_blocks( self, ) -> tuple[Block | None, Block | None, Block | None, Block | None] | None: if not self._at( Ps1TokenKind.BEGIN, Ps1TokenKind.PROCESS, Ps1TokenKind.END, Ps1TokenKind.DYNAMICPARAM, ): return None begin_block = None process_block = None end_block = None dynamicparam_block = None while self._at( Ps1TokenKind.BEGIN, Ps1TokenKind.PROCESS, Ps1TokenKind.END, Ps1TokenKind.DYNAMICPARAM, ): kw = self._advance() self._skip_newlines() block = self._parse_block() if kw.kind == Ps1TokenKind.BEGIN: begin_block = block elif kw.kind == Ps1TokenKind.PROCESS: process_block = block elif kw.kind == Ps1TokenKind.END: end_block = block elif kw.kind == Ps1TokenKind.DYNAMICPARAM: dynamicparam_block = block self._skip_newlines() return begin_block, process_block, end_block, dynamicparam_block def _parse_block(self) -> Block: offset = self._current.offset self._expect(Ps1TokenKind.LBRACE) self._skip_newlines() stmts = self._parse_statement_list(until=Ps1TokenKind.RBRACE) self._expect(Ps1TokenKind.RBRACE) return Block(offset=offset, body=stmts) def _parse_statement_list(self, until: Ps1TokenKind | None = None) -> list[Statement]: stmts: list[Statement] = [] while not self._at(Ps1TokenKind.EOF): while self._at(Ps1TokenKind.NEWLINE, Ps1TokenKind.SEMICOLON): self._advance() if until is not None and self._at(until): break if self._at(Ps1TokenKind.EOF): break mark = self._current.offset stmt = self._parse_statement() if stmt is not None: stmts.append(stmt) elif self._current.offset == mark: tok = self._advance() stmts.append(Ps1ExpressionStatement( offset=tok.offset, expression=Ps1ErrorNode(offset=tok.offset, text=tok.value), )) while self._at(Ps1TokenKind.NEWLINE, Ps1TokenKind.SEMICOLON): self._advance() return stmts def _parse_statement(self) -> Statement | None: self._skip_newlines() tok = self._current kind = tok.kind label = None if kind == Ps1TokenKind.LABEL: label = tok.value self._advance() self._skip_newlines() kind = self._current.kind if kind == Ps1TokenKind.IF: return self._parse_if() if kind == Ps1TokenKind.WHILE: return self._parse_while(label) if kind == Ps1TokenKind.DO: return self._parse_do(label) if kind == Ps1TokenKind.FOR: return self._parse_for(label) if kind == Ps1TokenKind.FOREACH: return self._parse_foreach(label) if kind == Ps1TokenKind.SWITCH: return self._parse_switch(label) if kind == Ps1TokenKind.TRY: return self._parse_try() if kind == Ps1TokenKind.TRAP: return self._parse_trap() if kind in (Ps1TokenKind.FUNCTION, Ps1TokenKind.FILTER): return self._parse_function_definition() if kind == Ps1TokenKind.CLASS: return self._parse_class_definition() if kind == Ps1TokenKind.ENUM: return self._parse_enum_definition() if kind == Ps1TokenKind.RETURN: return self._parse_return() if kind == Ps1TokenKind.THROW: return self._parse_throw() if kind == Ps1TokenKind.BREAK: return self._parse_break() if kind == Ps1TokenKind.CONTINUE: return self._parse_continue() if kind == Ps1TokenKind.EXIT: return self._parse_exit() if kind == Ps1TokenKind.DATA: return self._parse_data() return self._parse_pipeline_or_assignment() def _parse_pipeline_or_assignment(self) -> Statement | None: expr = self._parse_pipeline_expression(keyword_names_a_command=True) if expr is None: if self._at(Ps1TokenKind.EOF, Ps1TokenKind.RBRACE, Ps1TokenKind.RPAREN): return None tok = self._advance() return Ps1ExpressionStatement( offset=tok.offset, expression=Ps1ErrorNode(offset=tok.offset, text=tok.value), ) return Ps1ExpressionStatement(offset=expr.offset, expression=expr) def _starts_command(self, *, keyword_names_a_command: bool) -> bool: """ Decide whether what comes next is a command or an expression. The token is re-scanned in expression mode first, so the kind classified is a fact about *this* pipeline element rather than about whatever was parsed before it, and it is then discarded again, which is what makes scanning it here safe: `_parse_command` re-scans in argument mode before it reads a name. Anything that cannot open an expression opens a command. Asking it in that direction is what keeps the answer tied to the grammar, but it also means a kind nobody thought about becomes a command name, so the kinds that may never be one are named in `_ARGUMENT_FORBIDDEN_KINDS` and `_parse_command` declines them. A keyword kind spells a command name only where nothing above will read it as a statement: at a statement start, where `_parse_statement` has already declined, inside `( )`, which holds a pipeline and never a statement, and after a `|`. Everywhere else — a `for` clause, the operand of `return` — the keyword still opens a statement, and taking it for a name loses that statement whole. """ with self._mode(Ps1LexerMode.EXPRESSION): kind = self._current.kind if kind.is_keyword: return keyword_names_a_command return kind not in self._UNARY_START_KINDS def _parse_pipeline_expression(self, *, keyword_names_a_command: bool = False) -> Expression | None: """ Read one pipeline, starting from whichever of a command and an expression `_starts_command` says it is. """ if self._starts_command(keyword_names_a_command=keyword_names_a_command): first = self._parse_command() if first is None: return None return self._parse_pipeline_tail(first) with self._mode(Ps1LexerMode.EXPRESSION): expr = self._parse_binary_expression(0) if expr is None: return None operator = self._eat_assignment() if operator is not None: self._skip_newlines() expr = Ps1AssignmentExpression( offset=expr.offset, target=expr, operator=operator.value, value=self._parse_assigned_value(), ) return self._parse_pipeline_tail(expr) def _eat_assignment(self) -> Ps1Token | None: if self._current.kind.is_assignment: return self._advance() return None def _parse_assigned_value(self) -> Node | None: """ Read what an assignment assigns. It is a whole statement, so `$x = if ($c) { 1 }` and `$x = data { 1 }` are read alike and no list of the keywords worth allowing has to be kept. A statement that is only an expression is unwrapped again, because an assignment whose value is an expression is what the rest of the code reads. An assignment with nothing after it is left without a value rather than being given the token that ends it. Only a value that could open a statement is read as one. `_parse_statement` differs from the pipeline it falls through to for exactly the keyword and label starts, and every assignment in a chain costs the frames of whichever route it takes. """ if self._is_statement_terminator(): return None if not (self._current.kind.is_keyword or self._at(Ps1TokenKind.LABEL)): return self._parse_pipeline_expression() statement = self._parse_statement() if type(statement) is Ps1ExpressionStatement: return statement.expression return statement def _parse_pipeline_tail(self, expr: Expression) -> Expression: if not self._at(Ps1TokenKind.PIPE): return expr elements = [Ps1PipelineElement(offset=expr.offset, expression=expr)] while self._eat(Ps1TokenKind.PIPE): self._skip_newlines() element = self._parse_pipeline_element() if element is not None: elements.append(element) if len(elements) > 1: return Ps1Pipeline(offset=elements[0].offset, elements=elements) return expr def _parse_pipeline_element(self) -> Ps1PipelineElement | None: """ Read one element after a `|`. It is classified the way the first element is, so `1 | 2` keeps the `2` as the number it spells; PowerShell reports that an expression may only come first and then reads it anyway, and what an analyst needs back is the expression, not a command named after it. A keyword names a command here without exception, because nothing above reads a statement from the middle of a pipeline. Asking with the caller's answer instead would sever `$x = Get-Process | ForEach-Object { }` at the pipe, `ForEach-Object` being the `foreach` keyword to a lexer in expression mode. """ if self._starts_command(keyword_names_a_command=True): command = self._parse_command() if command is None: return None return Ps1PipelineElement(offset=command.offset, expression=command) expr = self._parse_expression() if expr is None: return None return Ps1PipelineElement( offset=expr.offset, expression=expr, redirections=self._parse_redirections(), ) def _parse_command_name(self) -> Expression: """ Read a command name from a token that is none of the shapes the caller has already taken. A token that begins an expression names the command with the expression it opens, so that `& $(Get-Command ls)`, `& @{ }` and a here-string each keep the structure they spell. Anything else is the bare word it spells, which is what makes `Copy-Item . dest` three elements of one command. """ if self._current.kind in self._UNARY_START_KINDS: with self._mode(Ps1LexerMode.EXPRESSION): name = self._parse_primary_expression() if name is not None: return name return self._bare_string(self._advance()) def _parse_command(self) -> Expression | None: """ Read a command, starting from its name. Argument mode is entered *before* the name rather than after it, so the name arrives as the one token PowerShell resolves: `foo=123`, `C:\\x\\y.exe` and `.\\a.ps1` are each whole, while `. { }` and `. $sb` still split, because argument mode ends a token at a dot that is followed by a space. """ with self._mode(Ps1LexerMode.ARGUMENT): offset = self._current.offset invocation_operator = '' if self._at(Ps1TokenKind.AMPERSAND, Ps1TokenKind.DOT): invocation_operator = self._advance().value self._skip_newlines() name_expr: Expression | None = None if self._at(Ps1TokenKind.VARIABLE, Ps1TokenKind.SPLAT_VARIABLE): name_expr = self._parse_variable() elif self._at(Ps1TokenKind.LBRACE): name_expr = self._parse_script_block() elif self._at(Ps1TokenKind.LPAREN): name_expr = self._parse_paren_expression() elif self._at(Ps1TokenKind.STRING_EXPAND, Ps1TokenKind.STRING_VERBATIM): name_expr = self._parse_string() elif self._current.kind in _ARGUMENT_FORBIDDEN_KINDS: if invocation_operator: return Ps1CommandInvocation( offset=offset, invocation_operator=invocation_operator) return None else: name_expr = self._parse_command_name() if invocation_operator and name_expr is not None: with self._mode(Ps1LexerMode.EXPRESSION): name_expr = self._parse_primary_postfix(name_expr) arguments: list[Ps1CommandArgument | Expression] = [] redirections: list[Ps1Redirection] = [] while not self._is_pipeline_terminator(): if self._at(Ps1TokenKind.PARAMETER): tok = self._advance() name = tok.value if name.endswith(':'): name = name[:-1] if not self._is_pipeline_terminator(): val = self._parse_argument_value() arguments.append(Ps1CommandArgument( offset=tok.offset, kind=Ps1CommandArgumentKind.NAMED, name=name, value=val, )) else: arguments.append(Ps1CommandArgument( offset=tok.offset, kind=Ps1CommandArgumentKind.SWITCH, name=name, )) else: arguments.append(Ps1CommandArgument( offset=tok.offset, kind=Ps1CommandArgumentKind.SWITCH, name=name, )) elif self._at(Ps1TokenKind.COMMA): self._advance() elif (redirection := self._try_parse_redirection()) is not None: redirections.append(redirection) elif self._at(Ps1TokenKind.OPERATOR): tok = self._advance() arguments.append(Ps1CommandArgument( offset=tok.offset, kind=Ps1CommandArgumentKind.SWITCH, name=tok.value, )) else: val = self._parse_argument_value() if val is None: break arguments.append(Ps1CommandArgument( offset=val.offset, kind=Ps1CommandArgumentKind.POSITIONAL, value=val, )) return Ps1CommandInvocation( offset=offset, name=name_expr, arguments=arguments, invocation_operator=invocation_operator, redirections=redirections, ) def _is_pipeline_terminator(self) -> bool: return self._current.kind in _PIPELINE_TERMINATORS def _parse_argument_value(self) -> Expression | None: first = self._parse_single_argument_value() if first is None: return None if not self._at(Ps1TokenKind.COMMA): return first elements = [first] while self._eat(Ps1TokenKind.COMMA): self._skip_newlines() elem = self._parse_single_argument_value() if elem is None: break elements.append(elem) if len(elements) == 1: return elements[0] return Ps1ArrayLiteral(offset=first.offset, elements=elements) def _parse_generic_as_string(self, tok: Ps1Token) -> Expression: # Decode both GENERIC_EXPAND and plain GENERIC_TOKEN through the same splitter so embedded # quotes and backtick escapes (e.g. `echo a'b c'd`) are resolved consistently. A plain # GENERIC_TOKEN never contains a `$` expansion, so the split yields a single literal. parts = self._split_generic_expandable(tok.value) if len(parts) == 1 and isinstance(parts[0], Ps1StringLiteral): return Ps1StringLiteral( offset=tok.offset, value=parts[0].value, raw=tok.value) return Ps1ExpandableString( offset=tok.offset, parts=parts, raw=tok.value) def _parse_single_argument_value(self) -> Expression | None: if self._at(Ps1TokenKind.GENERIC_TOKEN, Ps1TokenKind.GENERIC_EXPAND): tok = self._advance() return self._parse_generic_as_string(tok) if self._current.kind in _ARGUMENT_FORBIDDEN_KINDS: return None with self._mode(Ps1LexerMode.EXPRESSION): if self._current.kind in self._ARGUMENT_PRIMARY_KINDS: value = self._parse_unary_expression() if value is not None: return value return self._bare_string(self._advance()) def _parse_expression(self) -> Expression | None: with self._mode(Ps1LexerMode.EXPRESSION): return self._parse_binary_expression(0) def _current_binary_precedence(self) -> int | None: if self._current.kind == Ps1TokenKind.DOTDOT: return 70 if self._current.kind in (Ps1TokenKind.STAR, Ps1TokenKind.SLASH, Ps1TokenKind.PERCENT): return 50 if self._current.kind in (Ps1TokenKind.PLUS, Ps1TokenKind.DASH): return 40 if self._current.kind == Ps1TokenKind.OPERATOR: v = self._current.value if v == '-f': return 60 return _BINARY_PRECEDENCE.get(v) return None def _parse_binary_expression(self, min_prec: int) -> Expression | None: left = self._parse_array_literal_expression() if left is None: return None while True: prec = self._current_binary_precedence() if prec is None or prec < min_prec: break op = self._advance() self._skip_newlines() right = self._parse_binary_expression(prec + 1) if right is None: break if op.kind == Ps1TokenKind.DOTDOT: left = Ps1RangeExpression(offset=left.offset, start=left, end=right) else: left = Ps1BinaryExpression(offset=left.offset, left=left, operator=op.value, right=right) return left def _parse_array_literal_expression(self) -> Expression | None: first = self._parse_unary_expression() if first is None: return None if self._disable_comma or not self._at(Ps1TokenKind.COMMA): return first elements = [first] while self._eat(Ps1TokenKind.COMMA): self._skip_newlines() elem = self._parse_unary_expression() if elem is None: break elements.append(elem) if len(elements) == 1: return elements[0] return Ps1ArrayLiteral(offset=first.offset, elements=elements) def _parse_argument_expression(self) -> Expression | None: """ Parse a single method argument expression. Uses the full expression grammar but disables the comma operator so that commas delimit arguments rather than forming array literals. """ with self._comma_mode(disabled=True): return self._parse_expression() def _parse_unary_expression(self) -> Expression | None: tok = self._current if tok.kind == Ps1TokenKind.COMMA: if self._disable_comma: return None self._advance() self._skip_newlines() operand = self._parse_unary_expression() if operand is None: return Ps1ArrayLiteral(offset=tok.offset, elements=[]) return Ps1ArrayLiteral(offset=tok.offset, elements=[operand]) if tok.kind in (Ps1TokenKind.INCREMENT, Ps1TokenKind.DECREMENT): op = self._advance() self._skip_newlines() operand = self._parse_unary_expression() return Ps1UnaryExpression( offset=tok.offset, operator=op.value, operand=operand, prefix=True) if tok.kind == Ps1TokenKind.EXCLAIM: self._advance() self._skip_newlines() operand = self._parse_unary_expression() return Ps1UnaryExpression( offset=tok.offset, operator='!', operand=operand, prefix=True) if tok.kind == Ps1TokenKind.OPERATOR and tok.value in ('-not', '-bnot', '-split', '-csplit', '-isplit', '-join'): op = self._advance() self._skip_newlines() operand = self._parse_unary_expression() return Ps1UnaryExpression( offset=tok.offset, operator=op.value, operand=operand, prefix=True) if tok.kind in (Ps1TokenKind.PLUS, Ps1TokenKind.DASH): op = self._advance() self._skip_newlines() operand = self._parse_unary_expression() return Ps1UnaryExpression( offset=tok.offset, operator=op.value, operand=operand, prefix=True) if tok.kind == Ps1TokenKind.LBRACKET: type_expr = self._try_parse_type_literal() if type_expr is not None: if self._at(Ps1TokenKind.DOUBLE_COLON): return self._parse_primary_postfix(type_expr) if not self._at(Ps1TokenKind.NEWLINE, Ps1TokenKind.COMMA): operand = self._parse_unary_expression() if operand is not None: return Ps1CastExpression( offset=tok.offset, type_name=type_expr.name, operand=operand) return self._parse_primary_postfix(type_expr) return self._parse_primary_expression() def _try_parse_type_literal(self) -> Ps1TypeExpression | None: """ A type name is read in expression mode no matter which mode the caller left behind: a generic token does not end at `]` in argument mode, so the closing bracket is absorbed into the name and the bracket depth never returns to zero. In that mode the opening bracket is not a bracket either, which is why no caller may ask for one before calling this. """ return self._attempt(Ps1LexerMode.EXPRESSION, self._read_type_literal) def _read_type_literal(self) -> Ps1TypeExpression | None: """ Read the `[ ]` of a type name, or nothing at all. A bracket that the source never closes is not a type name, so everything that ends a statement or a block ends the attempt as well: the alternative is a name that swallows the brace its block was waiting for, and with it every statement that block still had to give. """ if not self._at(Ps1TokenKind.LBRACKET): return None offset = self._current.offset self._advance() self._skip_newlines() name_parts: list[str] = [] depth = 1 while depth > 0: if self._at(Ps1TokenKind.RBRACKET): depth -= 1 if depth == 0: self._advance() break name_parts.append(']') self._advance() elif self._at(Ps1TokenKind.LBRACKET): depth += 1 name_parts.append('[') self._advance() elif ( self._at(Ps1TokenKind.NEWLINE, Ps1TokenKind.SEMICOLON, Ps1TokenKind.RBRACE) or self._at(Ps1TokenKind.EOF) ): return None else: name_parts.append(self._current.value) self._advance() name = ''.join(name_parts).strip() if not name: return None return Ps1TypeExpression(offset=offset, name=name) def _parse_primary_expression(self) -> Expression | None: expr = self._parse_primary_atom() if expr is None: return None return self._parse_primary_postfix(expr) def _parse_primary_postfix(self, expr: Expression) -> Expression: while self._adjacent(): if self._at(Ps1TokenKind.DOT, Ps1TokenKind.DOUBLE_COLON): expr = self._parse_member_access(expr) elif self._at(Ps1TokenKind.LBRACKET): expr = self._parse_index_expression(expr) elif self._at(Ps1TokenKind.INCREMENT): op = self._advance() expr = Ps1UnaryExpression( offset=op.offset, operator='++', operand=expr, prefix=False) elif self._at(Ps1TokenKind.DECREMENT): op = self._advance() expr = Ps1UnaryExpression( offset=op.offset, operator='--', operand=expr, prefix=False) else: break return expr def _parse_primary_atom(self) -> Expression | None: rule = self._ATOM_RULES.get(self._current.kind) if rule is None: return None return rule(self) def _parse_bare_word(self) -> Expression: return self._parse_generic_as_string(self._advance()) def _parse_integer(self) -> Ps1IntegerLiteral: tok = self._advance() raw = tok.value text = raw.rstrip('lL').replace('_', '') try: if text[:2].lower() in ('0x', '0b'): value = int(text, 0) else: # Plain decimal: parse with an explicit base so leading zeros (`007`) are accepted # as decimal instead of raising under `int(text, 0)`. value = int(text, 10) except ValueError: value = 0 return Ps1IntegerLiteral(offset=tok.offset, value=value, raw=raw) def _parse_real(self) -> Ps1RealLiteral: tok = self._advance() raw = tok.value text = raw.replace('_', '') value = 0.0 for suffix, mult in _MULTIPLIER_SUFFIXES.items(): if text.lower().endswith(suffix): text = text[:-len(suffix)].rstrip('lL') try: value = float(int(text, 0)) * mult except (ValueError, OverflowError): try: value = float(text) * mult except ValueError: pass break else: for suffix in ('d', 'D'): if text.endswith(suffix): text = text[:-1] break try: value = float(text) except ValueError: pass return Ps1RealLiteral(offset=tok.offset, value=value, raw=raw) def _parse_string(self) -> Expression: tok = self._advance() raw = _normalize_string_delimiters(tok.value, 1) if tok.kind == Ps1TokenKind.STRING_VERBATIM: content = self._unescape_verbatim_string(tok.value[1:-1]) return Ps1StringLiteral(offset=tok.offset, value=content, raw=raw) inner = raw[1:-1] parts = self._split_expandable_string(inner) if len(parts) == 1 and isinstance(parts[0], Ps1StringLiteral): return Ps1StringLiteral(offset=tok.offset, value=parts[0].value, raw=raw) return Ps1ExpandableString(offset=tok.offset, parts=parts, raw=raw) def _try_parse_dollar_expansion(self, text: str, pos: int) -> tuple[Expression | None, int]: length = len(text) if pos + 1 < length and text[pos + 1] == '(': node, new_pos = self._parse_embedded_subexpression(text, pos) return node, new_pos m = _VARIABLE_FRAG.match(text, pos) if m: return self._make_variable_from_text(m.group()), m.end() return None, pos def _split_expandable_string(self, text: str) -> list[Expression]: parts: list[Expression] = [] pos = 0 length = len(text) buf: list[str] = [] def flush_text(): if buf: raw_text = ''.join(buf) decoded = self._decode_dq_escapes(raw_text) parts.append(Ps1StringLiteral(offset=-1, value=decoded, raw=raw_text)) buf.clear() while pos < length: c = text[pos] if c == '`' and pos + 1 < length: buf.append(c) buf.append(text[pos + 1]) pos += 2 continue if c in DOUBLE_QUOTES and pos + 1 < length and text[pos + 1] in DOUBLE_QUOTES: buf.append('""') pos += 2 continue if c == '$': node, new_pos = self._try_parse_dollar_expansion(text, pos) if node is not None: flush_text() parts.append(node) pos = new_pos continue buf.append(c) pos += 1 flush_text() return parts def _split_generic_expandable(self, text: str) -> list[Expression]: """ Split a GENERIC_EXPAND token value into interleaved literal and expression parts. Unlike `Ps1Parser._split_expandable_string` (for double-quoted strings), this operates on raw source text that may contain embedded single/double-quoted strings and backtick escapes. """ parts: list[Expression] = [] pos = 0 length = len(text) buf: list[str] = [] def flush_text(): if buf: value = ''.join(buf) parts.append(Ps1StringLiteral(offset=-1, value=value, raw=value)) buf.clear() while pos < length: c = text[pos] if c == '`' and pos + 1 < length: decoded, pos = _decode_backtick(text, pos, length) buf.append(decoded) continue if c in SINGLE_QUOTES: pos += 1 while pos < length: if text[pos] in SINGLE_QUOTES: pos += 1 if pos < length and text[pos] in SINGLE_QUOTES: buf.append("'") pos += 1 continue break buf.append(text[pos]) pos += 1 continue if c in DOUBLE_QUOTES: flush_text() pos += 1 inner_start = pos while pos < length: if text[pos] == '`' and pos + 1 < length: pos += 2 continue if text[pos] in DOUBLE_QUOTES: if pos + 1 < length and text[pos + 1] in DOUBLE_QUOTES: pos += 2 continue inner_end = pos pos += 1 break pos += 1 else: inner_end = pos inner = text[inner_start:inner_end] sub_parts = self._split_expandable_string(inner) parts.extend(sub_parts) continue if c == '$': node, new_pos = self._try_parse_dollar_expansion(text, pos) if node is not None: flush_text() parts.append(node) pos = new_pos continue buf.append(c) pos += 1 flush_text() return parts @classmethod def _scan_subexpression_extent(cls, text: str, pos: int) -> int: """ Given that `'$(` occurs at `pos + 2`, skip past the matching closing parenthesis while correctly handling nested parentheses and quoted strings. Returns the position immediately after the last one. """ length = len(text) depth = 1 pos += 2 while pos < length and depth > 0: sc = text[pos] if sc == '`' and pos + 1 < length: # A backtick escapes the next character (including a parenthesis) the same way the # lexer skips it, so the parser and lexer agree on where the subexpression ends. pos += 2 continue if sc in SINGLE_QUOTES: pos = cls._skip_quoted_raw(text, pos + 1, length, SINGLE_QUOTES) continue if sc in DOUBLE_QUOTES: pos = cls._skip_quoted_raw(text, pos + 1, length, DOUBLE_QUOTES, backtick=True) continue if sc == '(': depth += 1 elif sc == ')': depth -= 1 pos += 1 return pos def _parse_embedded_subexpression(self, text: str, start: int) -> tuple[Ps1SubExpression, int]: """ Parse a `$(...)` subexpression embedded in a string starting at position `start` (which points at the dollar). Returns the AST node and the position after the closing parenthesis. """ end = self._scan_subexpression_extent(text, start) sub_text = text[start + 2:end - 1] sub_parser = Ps1Parser(sub_text) sub_stmts = sub_parser._parse_statement_list() return Ps1SubExpression(offset=-1, body=sub_stmts), end def _decode_dq_escapes(self, text: str) -> str: result: list[str] = [] i = 0 length = len(text) while i < length: c = text[i] if c == '`' and i + 1 < length: decoded, i = _decode_backtick(text, i, length) result.append(decoded) elif c in DOUBLE_QUOTES and i + 1 < length and text[i + 1] in DOUBLE_QUOTES: result.append('"') i += 2 else: result.append(c) i += 1 return ''.join(result) @staticmethod def _unescape_verbatim_string(text: str) -> str: result: list[str] = [] i = 0 length = len(text) while i < length: c = text[i] if c in SINGLE_QUOTES and i + 1 < length and text[i + 1] in SINGLE_QUOTES: result.append("'") i += 2 else: result.append(c) i += 1 return ''.join(result) def _make_variable_from_text(self, text: str) -> Ps1Variable: name = text splatted = name.startswith('@') if name.startswith('$') or name.startswith('@'): name = name[1:] braced = False if name.startswith('{') and name.endswith('}'): braced = True name = name[1:-1] if '`' in name: name = _strip_backtick_noop(name) scope = Ps1ScopeModifier.NONE drive = '' if ':' in name: prefix, rest = name.split(':', 1) prefix_lower = prefix.lower() try: scope = Ps1ScopeModifier(prefix_lower) except ValueError: scope = Ps1ScopeModifier.DRIVE drive = prefix name = rest if not braced and name.startswith('{') and name.endswith('}'): braced = True name = name[1:-1] return Ps1Variable( offset=-1, name=name, scope=scope, braced=braced, splatted=splatted, drive=drive ) def _parse_here_string(self) -> Expression: tok = self._advance() raw = _normalize_string_delimiters(tok.value, 2) if tok.kind == Ps1TokenKind.HSTRING_VERBATIM: inner = self._strip_here_string(raw, "@'", "'@") return Ps1HereString( offset=tok.offset, value=inner, raw=raw) inner = self._strip_here_string(raw, '@"', '"@') parts = self._split_expandable_string(inner) if len(parts) == 1 and isinstance(parts[0], Ps1StringLiteral): return Ps1HereString( offset=tok.offset, value=parts[0].value, raw=raw) return Ps1ExpandableHereString(offset=tok.offset, parts=parts, raw=raw) @staticmethod def _strip_here_string(raw: str, open_delim: str, close_delim: str) -> str: inner = raw[len(open_delim):] if inner.endswith(close_delim): inner = inner[:-len(close_delim)] inner = inner.lstrip(' \t') if inner.startswith('\r\n'): inner = inner[2:] elif inner.startswith(('\n', '\r')): inner = inner[1:] if inner.endswith('\r\n'): inner = inner[:-2] elif inner.endswith(('\n', '\r')): inner = inner[:-1] return inner def _parse_variable(self) -> Ps1Variable: tok = self._advance() return self._make_variable_from_text(tok.value) def _parse_paren_expression(self) -> Expression: offset = self._current.offset self._expect(Ps1TokenKind.LPAREN) self._skip_newlines() with self._mode(Ps1LexerMode.ARGUMENT): with self._comma_mode(disabled=False): expr = self._parse_pipeline_expression(keyword_names_a_command=True) self._skip_newlines() self._expect(Ps1TokenKind.RPAREN) return Ps1ParenExpression(offset=offset, expression=expr) def _parse_delimited_statement_block( self, open_kind: Ps1TokenKind, cls: type[Ps1SubExpression] | type[Ps1ArrayExpression], ) -> Expression: offset = self._current.offset self._expect(open_kind) self._skip_newlines() with self._mode(Ps1LexerMode.ARGUMENT): with self._comma_mode(disabled=False): stmts = self._parse_statement_list(until=Ps1TokenKind.RPAREN) self._skip_newlines() self._expect(Ps1TokenKind.RPAREN) return cls(offset=offset, body=stmts) def _parse_sub_expression(self) -> Expression: return self._parse_delimited_statement_block( Ps1TokenKind.DOLLAR_LPAREN, Ps1SubExpression) def _parse_array_expression(self) -> Expression: return self._parse_delimited_statement_block( Ps1TokenKind.AT_LPAREN, Ps1ArrayExpression) def _parse_label_or_key(self) -> Expression | None: if self._at( Ps1TokenKind.GENERIC_TOKEN, Ps1TokenKind.GENERIC_EXPAND, Ps1TokenKind.LABEL, ) or self._current.kind.is_keyword: return self._bare_string(self._advance()) if self._is_statement_terminator(): return None with self._comma_mode(disabled=True): return self._parse_unary_expression() def _parse_hash_literal(self) -> Ps1HashLiteral: offset = self._current.offset self._expect(Ps1TokenKind.AT_LBRACE) self._skip_newlines() pairs: list[tuple[Expression, Expression]] = [] while not self._at(Ps1TokenKind.RBRACE, Ps1TokenKind.EOF): self._skip_newlines() if self._at(Ps1TokenKind.RBRACE): break with self._mode(Ps1LexerMode.EXPRESSION): key = self._parse_label_or_key() if key is None: break self._skip_newlines() self._expect(Ps1TokenKind.EQUALS) with self._mode(Ps1LexerMode.ARGUMENT): self._skip_newlines() value = self._parse_assigned_value() if value is None: value = Ps1StringLiteral(offset=self._current.offset, value='', raw='') pairs.append((key, value)) while self._current.kind in (Ps1TokenKind.NEWLINE, Ps1TokenKind.SEMICOLON): self._advance() self._skip_newlines() self._expect(Ps1TokenKind.RBRACE) return Ps1HashLiteral(offset=offset, pairs=pairs) def _parse_script_block(self) -> Ps1ScriptBlock: offset = self._current.offset self._expect(Ps1TokenKind.LBRACE) with self._comma_mode(disabled=False): fields = self._parse_code_body(until=Ps1TokenKind.RBRACE) self._skip_newlines() self._expect(Ps1TokenKind.RBRACE) return Ps1ScriptBlock(offset=offset, **fields) def _parse_member_access(self, obj: Expression) -> Expression: access_tok = self._advance() access = ( Ps1AccessKind.STATIC if access_tok.kind == Ps1TokenKind.DOUBLE_COLON else Ps1AccessKind.INSTANCE ) self._skip_newlines() member: str | Expression if self._at(Ps1TokenKind.GENERIC_TOKEN): tok = self._advance() member = tok.value elif self._at(Ps1TokenKind.VARIABLE): member = self._parse_variable() elif self._at(Ps1TokenKind.STRING_EXPAND, Ps1TokenKind.STRING_VERBATIM): member = self._parse_string() elif self._at(Ps1TokenKind.LPAREN): member = self._parse_paren_expression() elif self._at(Ps1TokenKind.DOLLAR_LPAREN): member = self._parse_sub_expression() else: tok = self._advance() member = tok.value if self._at(Ps1TokenKind.LPAREN) and self._adjacent(): self._advance() self._skip_newlines() args: list[Expression] = [] while not self._at(Ps1TokenKind.RPAREN, Ps1TokenKind.EOF): arg = self._parse_argument_expression() if arg is not None: args.append(arg) self._skip_newlines() if not self._eat(Ps1TokenKind.COMMA): break self._skip_newlines() self._expect(Ps1TokenKind.RPAREN) return Ps1InvokeMember( offset=obj.offset, object=obj, member=member, arguments=args, access=access, ) return Ps1MemberAccess(offset=obj.offset, object=obj, member=member, access=access) def _parse_index_expression(self, obj: Expression) -> Expression: self._advance() self._skip_newlines() with self._comma_mode(disabled=False): index = self._parse_expression() self._skip_newlines() self._expect(Ps1TokenKind.RBRACKET) return Ps1IndexExpression(offset=obj.offset, object=obj, index=index) def _parse_if(self) -> Ps1IfStatement: offset = self._current.offset clauses: list[tuple[Expression, Block]] = [] self._expect(Ps1TokenKind.IF) self._skip_newlines() cond = self._parse_parenthesized_condition() self._skip_newlines() body = self._parse_block() clauses.append((cond, body)) self._skip_newlines() while self._at(Ps1TokenKind.ELSEIF): self._advance() self._skip_newlines() cond = self._parse_parenthesized_condition() self._skip_newlines() body = self._parse_block() clauses.append((cond, body)) self._skip_newlines() else_block = None if self._at(Ps1TokenKind.ELSE): self._advance() self._skip_newlines() else_block = self._parse_block() return Ps1IfStatement(offset=offset, clauses=clauses, else_block=else_block) def _parse_while(self, label: str | None = None) -> Ps1WhileLoop: offset = self._current.offset self._expect(Ps1TokenKind.WHILE) self._skip_newlines() cond = self._parse_parenthesized_condition() self._skip_newlines() body = self._parse_block() return Ps1WhileLoop(offset=offset, condition=cond, body=body, label=label) def _parse_do(self, label: str | None = None) -> Statement: offset = self._current.offset self._expect(Ps1TokenKind.DO) self._skip_newlines() body = self._parse_block() self._skip_newlines() is_until = self._at(Ps1TokenKind.UNTIL) if is_until or self._at(Ps1TokenKind.WHILE): self._advance() self._skip_newlines() cond = self._parse_parenthesized_condition() return Ps1DoLoop( offset=offset, condition=cond, body=body, is_until=is_until, label=label) return Ps1DoLoop(offset=offset, body=body, label=label) def _parse_for(self, label: str | None = None) -> Ps1ForLoop: offset = self._current.offset self._expect(Ps1TokenKind.FOR) self._skip_newlines() self._expect(Ps1TokenKind.LPAREN) self._skip_newlines() init = None if not self._at(Ps1TokenKind.SEMICOLON): init = self._parse_pipeline_expression() self._eat(Ps1TokenKind.SEMICOLON) self._skip_newlines() cond = None if not self._at(Ps1TokenKind.SEMICOLON): cond = self._parse_pipeline_expression() self._eat(Ps1TokenKind.SEMICOLON) self._skip_newlines() iter_expr = None if not self._at(Ps1TokenKind.RPAREN): iter_expr = self._parse_pipeline_expression() self._skip_newlines() self._expect(Ps1TokenKind.RPAREN) self._skip_newlines() body = self._parse_block() return Ps1ForLoop( offset=offset, initializer=init, condition=cond, iterator=iter_expr, body=body, label=label) def _parse_foreach(self, label: str | None = None) -> Ps1ForEachLoop: offset = self._current.offset self._expect(Ps1TokenKind.FOREACH) self._skip_newlines() parallel = False if self._at(Ps1TokenKind.PARAMETER) and self._current.value.lower().startswith('-parallel'): self._advance() parallel = True self._expect(Ps1TokenKind.LPAREN) self._skip_newlines() var = self._parse_variable() if self._at( Ps1TokenKind.VARIABLE, Ps1TokenKind.SPLAT_VARIABLE) else None self._skip_newlines() self._expect(Ps1TokenKind.IN) self._skip_newlines() iterable = self._parse_pipeline_expression() self._skip_newlines() self._expect(Ps1TokenKind.RPAREN) self._skip_newlines() body = self._parse_block() return Ps1ForEachLoop( offset=offset, variable=var, iterable=iterable, body=body, parallel=parallel, label=label) def _parse_switch_clause_condition(self) -> Expression | None: """ Read one switch clause condition, which is a command argument and not an expression, so that `Get-Thing` is one name there, as 5.1 reads it, and not a subtraction of `Thing` from `Get`. A comma still joins several of them into one array, which is how `1,2 { ... }` matches two values with one clause. """ with self._mode(Ps1LexerMode.ARGUMENT): return self._parse_argument_value() def _parse_switch(self, label: str | None = None) -> Ps1SwitchStatement: offset = self._current.offset self._expect(Ps1TokenKind.SWITCH) self._skip_newlines() regex = False wildcard = False exact = False case_sensitive = False file = False value: Expression | None = None with self._mode(Ps1LexerMode.ARGUMENT): while self._at(Ps1TokenKind.PARAMETER): p = self._current.value.lower().lstrip('-').rstrip(':') self._advance() self._skip_newlines() if p == 'regex': regex = True elif p == 'wildcard': wildcard = True elif p == 'exact': exact = True elif p == 'casesensitive': case_sensitive = True elif p == 'file': file = True if file: value = self._parse_argument_value() if not file: value = self._parse_parenthesized_condition() self._skip_newlines() self._expect(Ps1TokenKind.LBRACE) self._skip_newlines() clauses: list[tuple[Expression | None, Block]] = [] while not self._at(Ps1TokenKind.RBRACE, Ps1TokenKind.EOF): self._skip_separators() if self._at(Ps1TokenKind.RBRACE): break if self._at(Ps1TokenKind.GENERIC_TOKEN, Ps1TokenKind.GENERIC_EXPAND) and self._current.value.lower() == 'default': self._advance() self._skip_newlines() block = self._parse_block() clauses.append((None, block)) else: cond = self._parse_switch_clause_condition() self._skip_newlines() block = self._parse_block() clauses.append((cond, block)) self._skip_separators() self._expect(Ps1TokenKind.RBRACE) return Ps1SwitchStatement( offset=offset, value=value, clauses=clauses, label=label, regex=regex, wildcard=wildcard, exact=exact, case_sensitive=case_sensitive, file=file, ) def _parse_try(self) -> Ps1TryCatchFinally: offset = self._current.offset self._expect(Ps1TokenKind.TRY) self._skip_newlines() try_block = self._parse_block() self._skip_newlines() catch_clauses: list[Ps1CatchClause] = [] while self._at(Ps1TokenKind.CATCH): self._advance() self._skip_newlines() types: list[str] = [] while True: te = self._try_parse_type_literal() if te is None: break types.append(te.name) self._skip_newlines() if not self._eat(Ps1TokenKind.COMMA): break self._skip_newlines() body = self._parse_block() catch_clauses.append(Ps1CatchClause( offset=body.offset, types=types, body=body)) self._skip_newlines() finally_block = None if self._at(Ps1TokenKind.FINALLY): self._advance() self._skip_newlines() finally_block = self._parse_block() return Ps1TryCatchFinally( offset=offset, try_block=try_block, catch_clauses=catch_clauses, finally_block=finally_block, ) def _parse_trap(self) -> Ps1TrapStatement: offset = self._current.offset self._expect(Ps1TokenKind.TRAP) self._skip_newlines() type_name = '' te = self._try_parse_type_literal() if te is not None: type_name = te.name self._skip_newlines() body = self._parse_block() return Ps1TrapStatement(offset=offset, type_name=type_name, body=body) def _parse_function_definition(self) -> Ps1FunctionDefinition: offset = self._current.offset kw = self._advance() is_filter = kw.kind == Ps1TokenKind.FILTER name = '' with self._mode(Ps1LexerMode.ARGUMENT): self._skip_newlines() if self._at( Ps1TokenKind.GENERIC_TOKEN, Ps1TokenKind.VARIABLE, ) or self._current.kind.is_keyword: name = self._advance().value with self._mode(Ps1LexerMode.EXPRESSION): self._skip_newlines() if self._at(Ps1TokenKind.LPAREN): self._advance() self._skip_newlines() params = self._parse_parameter_list() self._skip_newlines() self._expect(Ps1TokenKind.RPAREN) self._skip_newlines() self._expect(Ps1TokenKind.LBRACE) self._skip_newlines() script_body = self._parse_script_block_body(expect_close=True) script_body.param_block = Ps1ParamBlock( offset=offset, parameters=params) script_body.param_block.parent = script_body return Ps1FunctionDefinition( offset=offset, name=name, is_filter=is_filter, body=script_body) body = self._parse_script_block() return Ps1FunctionDefinition( offset=offset, name=name, is_filter=is_filter, body=body) def _parse_class_definition(self) -> Ps1ClassDefinition: offset = self._current.offset self._advance() name = '' base_types: list[str] = [] with self._mode(Ps1LexerMode.ARGUMENT): self._skip_newlines() if self._at(Ps1TokenKind.GENERIC_TOKEN) or self._current.kind.is_keyword: name = self._advance().value self._skip_newlines() if self._eat_colon(): self._skip_newlines() while not self._at(Ps1TokenKind.LBRACE, Ps1TokenKind.EOF): type_lit = self._try_parse_type_literal() if type_lit is not None: base_types.append(type_lit.name) elif self._at(Ps1TokenKind.GENERIC_TOKEN) or self._current.kind.is_keyword: base_types.append(self._advance().value) elif self._eat(Ps1TokenKind.COMMA): self._skip_newlines() continue else: break self._skip_newlines() self._skip_newlines() self._expect(Ps1TokenKind.LBRACE) members: list[Ps1PropertyMember | Ps1MethodMember] = [] with self._mode(Ps1LexerMode.EXPRESSION): self._skip_newlines() while not self._at(Ps1TokenKind.RBRACE, Ps1TokenKind.EOF): while self._at(Ps1TokenKind.NEWLINE, Ps1TokenKind.SEMICOLON): self._advance() if self._at(Ps1TokenKind.RBRACE, Ps1TokenKind.EOF): break member = self._parse_class_member() if member is not None: members.append(member) else: break self._expect(Ps1TokenKind.RBRACE) return Ps1ClassDefinition( offset=offset, name=name, base_types=base_types, members=members, ) def _parse_class_member(self) -> Ps1PropertyMember | Ps1MethodMember | None: self._skip_newlines() if self._at(Ps1TokenKind.RBRACE, Ps1TokenKind.EOF): return None offset = self._current.offset attributes: list[Ps1Attribute] = [] type_constraint: Ps1TypeExpression | None = None modifiers = Ps1MemberModifier.NONE while True: self._skip_newlines() if self._at(Ps1TokenKind.LBRACKET): attr = self._parse_attribute() if isinstance(attr, Ps1Attribute): attributes.append(attr) elif type_constraint is None: type_constraint = attr self._skip_newlines() continue if ( self._current.kind == Ps1TokenKind.GENERIC_TOKEN and self._current.value.lower() == 'static' ): modifiers |= Ps1MemberModifier.STATIC self._advance() continue if ( self._current.kind == Ps1TokenKind.GENERIC_TOKEN and self._current.value.lower() == 'hidden' ): modifiers |= Ps1MemberModifier.HIDDEN self._advance() continue break if self._at(Ps1TokenKind.VARIABLE): var_tok = self._advance() var = self._make_variable_from_text(var_tok.value) initial_value: Expression | None = None if self._eat(Ps1TokenKind.EQUALS): self._skip_newlines() initial_value = self._parse_expression() while self._at(Ps1TokenKind.NEWLINE, Ps1TokenKind.SEMICOLON): self._advance() return Ps1PropertyMember( offset=offset, attributes=attributes, modifiers=modifiers, type_constraint=type_constraint, variable=var, initial_value=initial_value, ) if ( self._at(Ps1TokenKind.GENERIC_TOKEN) or self._current.kind.is_keyword ): method_name = self._advance().value self._skip_newlines() params: list[Ps1ParameterDeclaration] = [] if self._at(Ps1TokenKind.LPAREN): self._advance() self._skip_newlines() params = self._parse_parameter_list() self._skip_newlines() self._expect(Ps1TokenKind.RPAREN) self._skip_newlines() self._expect(Ps1TokenKind.LBRACE) self._skip_newlines() script_body = self._parse_script_block_body(expect_close=True) if params: script_body.param_block = Ps1ParamBlock( offset=offset, parameters=params) script_body.param_block.parent = script_body funcdef = Ps1FunctionDefinition( offset=offset, name=method_name, body=script_body, ) return_type = type_constraint return Ps1MethodMember( offset=offset, attributes=attributes, modifiers=modifiers, return_type=return_type, definition=funcdef, ) return None def _parse_enum_definition(self) -> Ps1EnumDefinition: offset = self._current.offset self._advance() name = '' base_type = '' members: list[Ps1EnumMember] = [] with self._mode(Ps1LexerMode.ARGUMENT): self._skip_newlines() if self._at(Ps1TokenKind.GENERIC_TOKEN) or self._current.kind.is_keyword: name = self._advance().value self._skip_newlines() if self._eat_colon(): self._skip_newlines() type_lit = self._try_parse_type_literal() if type_lit is not None: base_type = type_lit.name elif self._at(Ps1TokenKind.GENERIC_TOKEN) or self._current.kind.is_keyword: base_type = self._advance().value self._skip_newlines() self._expect(Ps1TokenKind.LBRACE) with self._mode(Ps1LexerMode.EXPRESSION): self._skip_newlines() while not self._at(Ps1TokenKind.RBRACE, Ps1TokenKind.EOF): member = self._parse_enum_member() if member is not None: members.append(member) else: break self._expect(Ps1TokenKind.RBRACE) return Ps1EnumDefinition( offset=offset, name=name, base_type=base_type, members=members, ) def _parse_enum_member(self) -> Ps1EnumMember | None: self._skip_newlines() if self._at(Ps1TokenKind.RBRACE, Ps1TokenKind.EOF): return None offset = self._current.offset if not ( self._at(Ps1TokenKind.GENERIC_TOKEN) or self._current.kind.is_keyword ): return None name = self._advance().value value: Expression | None = None with self._mode(Ps1LexerMode.EXPRESSION): if self._eat(Ps1TokenKind.EQUALS): self._skip_newlines() value = self._parse_expression() while self._at(Ps1TokenKind.NEWLINE, Ps1TokenKind.SEMICOLON): self._advance() return Ps1EnumMember(offset=offset, name=name, value=value) def _parse_script_block_body(self, expect_close: bool = False) -> Ps1ScriptBlock: offset = self._current.offset fields = self._parse_code_body(until=Ps1TokenKind.RBRACE) self._skip_newlines() if expect_close: self._expect(Ps1TokenKind.RBRACE) return Ps1ScriptBlock(offset=offset, **fields) def _parse_param_block(self) -> Ps1ParamBlock | None: """ A parameter list is read in expression mode no matter which mode the caller left behind: in argument mode a generic token does not end at `=`, so `param($x=1)` reads its default as part of one word and every unspaced default in the language is lost. Whether a body opens with one at all is decided by reading it, not by looking for the word `param` first. A body opens in argument mode, where `param.exe` is one word and the keyword is invisible, and it opens in expression mode over a script whose first statement is a call to a program of that name; only reading as far as the `(` tells the two apart. """ return self._attempt(Ps1LexerMode.EXPRESSION, self._read_param_block) def _read_param_block(self) -> Ps1ParamBlock | None: offset = self._current.offset attrs: list[Ps1Attribute] = [] while self._at(Ps1TokenKind.LBRACKET): attr = self._parse_attribute() if isinstance(attr, Ps1Attribute): attrs.append(attr) self._skip_newlines() if not self._eat(Ps1TokenKind.PARAM): return None self._skip_newlines() if not self._at(Ps1TokenKind.LPAREN): return None self._advance() self._skip_newlines() params = self._parse_parameter_list() self._skip_newlines() self._expect(Ps1TokenKind.RPAREN) return Ps1ParamBlock(offset=offset, parameters=params, attributes=attrs) def _parse_parameter_list(self) -> list[Ps1ParameterDeclaration]: params: list[Ps1ParameterDeclaration] = [] while not self._at(Ps1TokenKind.RPAREN, Ps1TokenKind.EOF): self._skip_newlines() if self._at(Ps1TokenKind.RPAREN): break param = self._parse_parameter_declaration() params.append(param) self._skip_newlines() if not self._eat(Ps1TokenKind.COMMA): break self._skip_newlines() return params def _parse_parameter_declaration(self) -> Ps1ParameterDeclaration: offset = self._current.offset attrs: list[Ps1Attribute | Ps1TypeExpression] = [] while self._at(Ps1TokenKind.LBRACKET): attr = self._parse_attribute() attrs.append(attr) self._skip_newlines() var = None if self._at(Ps1TokenKind.VARIABLE, Ps1TokenKind.SPLAT_VARIABLE): var = self._parse_variable() default = None if self._eat(Ps1TokenKind.EQUALS): self._skip_newlines() with self._comma_mode(disabled=True): default = self._parse_expression() return Ps1ParameterDeclaration( offset=offset, variable=var, attributes=attrs, default_value=default) def _parse_attribute(self) -> Ps1Attribute | Ps1TypeExpression: """ A type name and its arguments are read in expression mode no matter which mode the caller left behind, for the reason `_try_parse_type_literal` states: in argument mode a generic token does not end at `]`, so the name absorbs its own closing bracket and everything after it. `& { param([int]$x) $x }` reaches here in argument mode, because the block is an argument of the call operator, and swallowed the whole body before this. """ with self._mode(Ps1LexerMode.EXPRESSION): return self._read_attribute() def _read_attribute(self) -> Ps1Attribute | Ps1TypeExpression: offset = self._current.offset self._expect(Ps1TokenKind.LBRACKET) self._skip_newlines() name_parts: list[str] = [] depth = 0 while not self._at(Ps1TokenKind.LPAREN, Ps1TokenKind.EOF): if self._at(Ps1TokenKind.LBRACKET): depth += 1 name_parts.append('[') self._advance() elif self._at(Ps1TokenKind.RBRACKET): if depth == 0: break depth -= 1 name_parts.append(']') self._advance() else: name_parts.append(self._current.value) self._advance() name = ''.join(name_parts).strip() if self._at(Ps1TokenKind.LPAREN): self._advance() self._skip_newlines() positional: list[Expression] = [] named: list[tuple[str, Expression]] = [] with self._comma_mode(disabled=True): while not self._at(Ps1TokenKind.RPAREN, Ps1TokenKind.EOF): self._skip_newlines() if self._at(Ps1TokenKind.RPAREN): break if self._at(Ps1TokenKind.GENERIC_TOKEN, Ps1TokenKind.GENERIC_EXPAND): key_tok = self._advance() if self._eat(Ps1TokenKind.EQUALS): self._skip_newlines() val = self._parse_expression() if val is not None: named.append((key_tok.value, val)) self._skip_newlines() self._eat(Ps1TokenKind.COMMA) continue self._resync(key_tok.offset) expr = self._parse_expression() if expr is not None: positional.append(expr) self._skip_newlines() if not self._eat(Ps1TokenKind.COMMA): break self._expect(Ps1TokenKind.RPAREN) self._skip_newlines() self._expect(Ps1TokenKind.RBRACKET) return Ps1Attribute( offset=offset, name=name, positional_args=positional, named_args=named) self._expect(Ps1TokenKind.RBRACKET) return Ps1TypeExpression(offset=offset, name=name) def _parse_flow_with_pipeline(self, kind: Ps1TokenKind, cls: type[Ps1Exit]) -> Statement: offset = self._current.offset self._expect(kind) pipeline = None if not self._is_statement_terminator(): pipeline = self._parse_pipeline_expression() return cls(offset=offset, pipeline=pipeline) def _parse_flow_with_label(self, kind: Ps1TokenKind, cls: type[Ps1Jump]) -> Statement: offset = self._current.offset self._expect(kind) label = None if not self._is_statement_terminator(): label = self._parse_label_or_key() return cls(offset=offset, label=label) def _parse_return(self) -> Statement: return self._parse_flow_with_pipeline(Ps1TokenKind.RETURN, Ps1ReturnStatement) def _parse_throw(self) -> Statement: return self._parse_flow_with_pipeline(Ps1TokenKind.THROW, Ps1ThrowStatement) def _parse_break(self) -> Statement: return self._parse_flow_with_label(Ps1TokenKind.BREAK, Ps1BreakStatement) def _parse_continue(self) -> Statement: return self._parse_flow_with_label(Ps1TokenKind.CONTINUE, Ps1ContinueStatement) def _parse_exit(self) -> Statement: return self._parse_flow_with_pipeline(Ps1TokenKind.EXIT, Ps1ExitStatement) def _parse_data(self) -> Ps1DataSection: offset = self._current.offset self._expect(Ps1TokenKind.DATA) self._skip_newlines() name = '' commands: list[Expression] = [] with self._mode(Ps1LexerMode.ARGUMENT): if self._at(Ps1TokenKind.GENERIC_TOKEN): name = self._advance().value self._skip_newlines() if self._at(Ps1TokenKind.PARAMETER): param = self._current.value.lower().lstrip('-').rstrip(':') self._advance() self._skip_newlines() if param == 'supportedcommand': while True: self._skip_newlines() arg = self._parse_single_argument_value() if arg is None: break commands.append(arg) if not self._eat(Ps1TokenKind.COMMA): break with self._mode(Ps1LexerMode.EXPRESSION): body = self._parse_block() return Ps1DataSection(offset=offset, name=name, commands=commands, body=body) #: What a primary expression can begin with, and the rule that reads each one. Every question of #: the form "can an expression start here" is answered from this table rather than from a list #: kept beside it, because a list kept beside it drifts: the two that were here before this #: disagreed with the grammar and with each other. _ATOM_RULES: dict[Ps1TokenKind, Callable[[Ps1Parser], Expression | None]] = { Ps1TokenKind.AT_LBRACE : _parse_hash_literal, # noqa Ps1TokenKind.AT_LPAREN : _parse_array_expression, # noqa Ps1TokenKind.DOLLAR_LPAREN : _parse_sub_expression, # noqa Ps1TokenKind.GENERIC_EXPAND : _parse_bare_word, # noqa Ps1TokenKind.GENERIC_TOKEN : _parse_bare_word, # noqa Ps1TokenKind.HSTRING_EXPAND : _parse_here_string, # noqa Ps1TokenKind.HSTRING_VERBATIM : _parse_here_string, # noqa Ps1TokenKind.INTEGER : _parse_integer, # noqa Ps1TokenKind.LBRACE : _parse_script_block, # noqa Ps1TokenKind.LBRACKET : _try_parse_type_literal, # noqa Ps1TokenKind.LPAREN : _parse_paren_expression, # noqa Ps1TokenKind.REAL : _parse_real, # noqa Ps1TokenKind.SPLAT_VARIABLE : _parse_variable, # noqa Ps1TokenKind.STRING_EXPAND : _parse_string, # noqa Ps1TokenKind.STRING_VERBATIM : _parse_string, # noqa Ps1TokenKind.VARIABLE : _parse_variable, # noqa } #: What `Ps1Parser._parse_unary_expression` can begin with. A pipeline element that does not #: begin with one of these is a command, which is why the bare word has to come out: it is the #: spelling of every command name there is, and honouring it here takes `Get-ChildItem` apart. _UNARY_START_KINDS = (frozenset(_ATOM_RULES) - _BARE_WORD_KINDS) | _UNARY_OPERATOR_KINDS #: What a command argument can begin with. Narrower than the above by the two kinds that would #: read past the argument they start: an operator takes the next argument as its operand, so #: `echo a,-not` builds a unary expression with nothing under it, and a bracket reads #: `Write-Host [ 0 ]` as a type name rather than as the three words PowerShell passes. _ARGUMENT_PRIMARY_KINDS = _UNARY_START_KINDS - { Ps1TokenKind.LBRACKET, Ps1TokenKind.OPERATOR, }Methods
def parse(self)-
Expand source code Browse git
def parse(self) -> Ps1Script: return self._parse_script()