chore: import upstream snapshot with attribution
Integ / changes (push) Has been skipped
Pre-commit / pre-commit (push) Failing after 1s
CLI exit codes / changes (push) Has been skipped
Test (Install) / changes (push) Has been skipped
Test (Python) / changes (push) Has been skipped
Test (TypeScript) / changes (push) Has been skipped
CLI exit codes / cli-gate (push) Has been cancelled
Test (Install) / test-install-gate (push) Has been cancelled
Integ / integ-gate (push) Has been cancelled
Test (Python) / test-python-gate (push) Has been cancelled
Test (TypeScript) / test-typescript-gate (push) Has been cancelled
Test (Install) / python-minimal (3.12) (push) Has been cancelled
Test (Install) / python-minimal (3.11) (push) Has been cancelled
Test (Install) / python-extra (agno, mirage.agents.agno) (push) Has been cancelled
Test (Install) / python-extra (chroma, mirage.resource.chroma) (push) Has been cancelled
Test (Install) / python-extra (pdf, mirage.core.filetype.pdf) (push) Has been cancelled
Integ / integ (push) Has been cancelled
Integ / integ-database (push) Has been cancelled
Integ / integ-database-ts (push) Has been cancelled
Integ / integ-data (push) Has been cancelled
Integ / integ-ssh (push) Has been cancelled
Integ / integ-ssh-ts (push) Has been cancelled
Test (Python) / audit (push) Has been cancelled
Test (TypeScript) / test (push) Has been cancelled
Test (TypeScript) / python-fs-shim (push) Has been cancelled
CLI exit codes / Python CLI (push) Has been cancelled
CLI exit codes / TypeScript CLI (push) Has been cancelled
CLI exit codes / Cross-language snapshot interop (push) Has been cancelled
Test (Python) / test (push) Has been cancelled
Test (Python) / import-isolation (deepagents, openai, mirage.agents.openai_agents) (push) Has been cancelled
Test (Python) / import-isolation (deepagents, pydantic-ai, mirage.agents.pydantic_ai) (push) Has been cancelled
Integ / integ-ts (push) Has been cancelled
Integ / integ-fuse (push) Has been cancelled
Test (Install) / python-extra (databricks, mirage.resource.databricks_volume) (push) Has been cancelled
Test (Install) / python-extra (deepagents, mirage.agents.langchain) (push) Has been cancelled
Test (Install) / python-extra (email, mirage.resource.email) (push) Has been cancelled
Test (Install) / python-extra (fuse, mirage.fuse.mount) (push) Has been cancelled
Test (Install) / python-extra (hdf5, mirage.core.filetype.hdf5) (push) Has been cancelled
Test (Install) / python-extra (hf, mirage.resource.hf_buckets) (push) Has been cancelled
Test (Install) / python-extra (lancedb, mirage.resource.lancedb) (push) Has been cancelled
Test (Install) / python-extra (langfuse, mirage.resource.langfuse) (push) Has been cancelled
Test (Install) / python-extra (mongodb, mirage.resource.mongodb) (push) Has been cancelled
Test (Install) / python-extra (nextcloud, mirage.resource.nextcloud) (push) Has been cancelled
Test (Install) / python-extra (openai, mirage.agents.openai_agents) (push) Has been cancelled
Test (Install) / python-extra (openhands, mirage.agents.openhands, 3.12) (push) Has been cancelled
Test (Install) / python-extra (parquet, mirage.core.filetype.parquet) (push) Has been cancelled
Test (Install) / python-extra (postgres, mirage.resource.postgres) (push) Has been cancelled
Test (Install) / python-extra (pydantic-ai, mirage.agents.pydantic_ai) (push) Has been cancelled
Test (Install) / python-extra (qdrant, mirage.resource.qdrant) (push) Has been cancelled
Test (Install) / python-extra (redis, mirage.resource.redis) (push) Has been cancelled
Test (Install) / python-extra (s3, mirage.resource.s3) (push) Has been cancelled
Test (Install) / python-extra (ssh, mirage.resource.ssh) (push) Has been cancelled
Test (Install) / ts-minimal (push) Has been cancelled
Integ / changes (push) Has been skipped
Pre-commit / pre-commit (push) Failing after 1s
CLI exit codes / changes (push) Has been skipped
Test (Install) / changes (push) Has been skipped
Test (Python) / changes (push) Has been skipped
Test (TypeScript) / changes (push) Has been skipped
CLI exit codes / cli-gate (push) Has been cancelled
Test (Install) / test-install-gate (push) Has been cancelled
Integ / integ-gate (push) Has been cancelled
Test (Python) / test-python-gate (push) Has been cancelled
Test (TypeScript) / test-typescript-gate (push) Has been cancelled
Test (Install) / python-minimal (3.12) (push) Has been cancelled
Test (Install) / python-minimal (3.11) (push) Has been cancelled
Test (Install) / python-extra (agno, mirage.agents.agno) (push) Has been cancelled
Test (Install) / python-extra (chroma, mirage.resource.chroma) (push) Has been cancelled
Test (Install) / python-extra (pdf, mirage.core.filetype.pdf) (push) Has been cancelled
Integ / integ (push) Has been cancelled
Integ / integ-database (push) Has been cancelled
Integ / integ-database-ts (push) Has been cancelled
Integ / integ-data (push) Has been cancelled
Integ / integ-ssh (push) Has been cancelled
Integ / integ-ssh-ts (push) Has been cancelled
Test (Python) / audit (push) Has been cancelled
Test (TypeScript) / test (push) Has been cancelled
Test (TypeScript) / python-fs-shim (push) Has been cancelled
CLI exit codes / Python CLI (push) Has been cancelled
CLI exit codes / TypeScript CLI (push) Has been cancelled
CLI exit codes / Cross-language snapshot interop (push) Has been cancelled
Test (Python) / test (push) Has been cancelled
Test (Python) / import-isolation (deepagents, openai, mirage.agents.openai_agents) (push) Has been cancelled
Test (Python) / import-isolation (deepagents, pydantic-ai, mirage.agents.pydantic_ai) (push) Has been cancelled
Integ / integ-ts (push) Has been cancelled
Integ / integ-fuse (push) Has been cancelled
Test (Install) / python-extra (databricks, mirage.resource.databricks_volume) (push) Has been cancelled
Test (Install) / python-extra (deepagents, mirage.agents.langchain) (push) Has been cancelled
Test (Install) / python-extra (email, mirage.resource.email) (push) Has been cancelled
Test (Install) / python-extra (fuse, mirage.fuse.mount) (push) Has been cancelled
Test (Install) / python-extra (hdf5, mirage.core.filetype.hdf5) (push) Has been cancelled
Test (Install) / python-extra (hf, mirage.resource.hf_buckets) (push) Has been cancelled
Test (Install) / python-extra (lancedb, mirage.resource.lancedb) (push) Has been cancelled
Test (Install) / python-extra (langfuse, mirage.resource.langfuse) (push) Has been cancelled
Test (Install) / python-extra (mongodb, mirage.resource.mongodb) (push) Has been cancelled
Test (Install) / python-extra (nextcloud, mirage.resource.nextcloud) (push) Has been cancelled
Test (Install) / python-extra (openai, mirage.agents.openai_agents) (push) Has been cancelled
Test (Install) / python-extra (openhands, mirage.agents.openhands, 3.12) (push) Has been cancelled
Test (Install) / python-extra (parquet, mirage.core.filetype.parquet) (push) Has been cancelled
Test (Install) / python-extra (postgres, mirage.resource.postgres) (push) Has been cancelled
Test (Install) / python-extra (pydantic-ai, mirage.agents.pydantic_ai) (push) Has been cancelled
Test (Install) / python-extra (qdrant, mirage.resource.qdrant) (push) Has been cancelled
Test (Install) / python-extra (redis, mirage.resource.redis) (push) Has been cancelled
Test (Install) / python-extra (s3, mirage.resource.s3) (push) Has been cancelled
Test (Install) / python-extra (ssh, mirage.resource.ssh) (push) Has been cancelled
Test (Install) / ts-minimal (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from mirage.shell.parse import parse
|
||||
from mirage.shell.types import NodeType, RedirectKind, ShellBuiltin
|
||||
|
||||
__all__ = [
|
||||
"NodeType",
|
||||
"RedirectKind",
|
||||
"ShellBuiltin",
|
||||
"parse",
|
||||
]
|
||||
@@ -0,0 +1,400 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from collections.abc import Mapping
|
||||
|
||||
from mirage.shell.constants import (ARITH_ASSIGN_OPS, ARITH_MAX_DEPTH,
|
||||
ARITH_NAME, ARITH_SIGN, ARITH_TOKEN,
|
||||
ARITH_WRAP)
|
||||
from mirage.shell.errors import ArithError
|
||||
|
||||
|
||||
def _tokenize(expr: str) -> list[str]:
|
||||
tokens: list[str] = []
|
||||
for match in ARITH_TOKEN.finditer(expr):
|
||||
kind = match.lastgroup
|
||||
if kind == "ws":
|
||||
continue
|
||||
if kind == "bad":
|
||||
raise ArithError(f'syntax error: invalid character "{match[0]}"')
|
||||
tokens.append(match[0])
|
||||
return tokens
|
||||
|
||||
|
||||
def _wrap(value: int) -> int:
|
||||
value &= ARITH_WRAP - 1
|
||||
return value - ARITH_WRAP if value & ARITH_SIGN else value
|
||||
|
||||
|
||||
def _trunc_div(a: int, b: int) -> int:
|
||||
if b == 0:
|
||||
raise ArithError("division by 0")
|
||||
q = a // b
|
||||
if q < 0 and q * b != a:
|
||||
q += 1
|
||||
return q
|
||||
|
||||
|
||||
def _trunc_mod(a: int, b: int) -> int:
|
||||
return a - _trunc_div(a, b) * b
|
||||
|
||||
|
||||
def _parse_literal(text: str) -> int:
|
||||
if text.lower().startswith("0x"):
|
||||
return int(text, 16)
|
||||
if text.startswith("0") and text != "0":
|
||||
try:
|
||||
return int(text, 8)
|
||||
except ValueError:
|
||||
raise ArithError(f"value too great for base (error token is "
|
||||
f'"{text}")') from None
|
||||
return int(text)
|
||||
|
||||
|
||||
class ArithParser:
|
||||
"""Recursive-descent parser producing tuple AST nodes.
|
||||
|
||||
Grammar mirrors bash arithmetic precedence (comma, assignment,
|
||||
ternary, ``||``, ``&&``, ``|``, ``^``, ``&``, equality, relational,
|
||||
shift, additive, multiplicative, ``**``, unary, ``++``/``--``,
|
||||
primary). Evaluation is separate so ``&&``/``||``/ternary can
|
||||
short-circuit side effects.
|
||||
"""
|
||||
|
||||
def __init__(self, tokens: list[str]) -> None:
|
||||
self.tokens = tokens
|
||||
self.pos = 0
|
||||
|
||||
def peek(self) -> str | None:
|
||||
return self.tokens[self.pos] if self.pos < len(self.tokens) else None
|
||||
|
||||
def take(self) -> str:
|
||||
tok = self.peek()
|
||||
if tok is None:
|
||||
raise ArithError("syntax error: operand expected")
|
||||
self.pos += 1
|
||||
return tok
|
||||
|
||||
def expect(self, tok: str) -> None:
|
||||
if self.take() != tok:
|
||||
raise ArithError(f'syntax error: "{tok}" expected')
|
||||
|
||||
def parse(self) -> tuple:
|
||||
node = self.comma()
|
||||
if self.peek() is not None:
|
||||
raise ArithError(f'syntax error: unexpected token "{self.peek()}"')
|
||||
return node
|
||||
|
||||
def comma(self) -> tuple:
|
||||
parts = [self.assign()]
|
||||
while self.peek() == ",":
|
||||
self.take()
|
||||
parts.append(self.assign())
|
||||
return parts[0] if len(parts) == 1 else ("comma", parts)
|
||||
|
||||
def assign(self) -> tuple:
|
||||
if (self.peek() is not None
|
||||
and ARITH_NAME.fullmatch(self.tokens[self.pos])
|
||||
and self.pos + 1 < len(self.tokens)
|
||||
and self.tokens[self.pos + 1] in ARITH_ASSIGN_OPS):
|
||||
name = self.take()
|
||||
op = self.take()
|
||||
return ("assign", name, op, self.assign())
|
||||
return self.ternary()
|
||||
|
||||
def ternary(self) -> tuple:
|
||||
cond = self.logic_or()
|
||||
if self.peek() != "?":
|
||||
return cond
|
||||
self.take()
|
||||
then = self.assign()
|
||||
self.expect(":")
|
||||
other = self.assign()
|
||||
return ("ternary", cond, then, other)
|
||||
|
||||
def logic_or(self) -> tuple:
|
||||
node = self.logic_and()
|
||||
while self.peek() == "||":
|
||||
self.take()
|
||||
node = ("logic", "||", node, self.logic_and())
|
||||
return node
|
||||
|
||||
def logic_and(self) -> tuple:
|
||||
node = self.bit_or()
|
||||
while self.peek() == "&&":
|
||||
self.take()
|
||||
node = ("logic", "&&", node, self.bit_or())
|
||||
return node
|
||||
|
||||
def bit_or(self) -> tuple:
|
||||
node = self.bit_xor()
|
||||
while self.peek() == "|":
|
||||
self.take()
|
||||
node = ("binop", "|", node, self.bit_xor())
|
||||
return node
|
||||
|
||||
def bit_xor(self) -> tuple:
|
||||
node = self.bit_and()
|
||||
while self.peek() == "^":
|
||||
self.take()
|
||||
node = ("binop", "^", node, self.bit_and())
|
||||
return node
|
||||
|
||||
def bit_and(self) -> tuple:
|
||||
node = self.equality()
|
||||
while self.peek() == "&":
|
||||
self.take()
|
||||
node = ("binop", "&", node, self.equality())
|
||||
return node
|
||||
|
||||
def equality(self) -> tuple:
|
||||
node = self.relational()
|
||||
while self.peek() in ("==", "!="):
|
||||
op = self.take()
|
||||
node = ("binop", op, node, self.relational())
|
||||
return node
|
||||
|
||||
def relational(self) -> tuple:
|
||||
node = self.shift()
|
||||
while self.peek() in ("<", "<=", ">", ">="):
|
||||
op = self.take()
|
||||
node = ("binop", op, node, self.shift())
|
||||
return node
|
||||
|
||||
def shift(self) -> tuple:
|
||||
node = self.additive()
|
||||
while self.peek() in ("<<", ">>"):
|
||||
op = self.take()
|
||||
node = ("binop", op, node, self.additive())
|
||||
return node
|
||||
|
||||
def additive(self) -> tuple:
|
||||
node = self.multiplicative()
|
||||
while self.peek() in ("+", "-"):
|
||||
op = self.take()
|
||||
node = ("binop", op, node, self.multiplicative())
|
||||
return node
|
||||
|
||||
def multiplicative(self) -> tuple:
|
||||
node = self.power()
|
||||
while self.peek() in ("*", "/", "%"):
|
||||
op = self.take()
|
||||
node = ("binop", op, node, self.power())
|
||||
return node
|
||||
|
||||
def power(self) -> tuple:
|
||||
node = self.unary()
|
||||
if self.peek() == "**":
|
||||
self.take()
|
||||
return ("binop", "**", node, self.power())
|
||||
return node
|
||||
|
||||
def unary(self) -> tuple:
|
||||
tok = self.peek()
|
||||
if tok in ("!", "~", "-", "+"):
|
||||
self.take()
|
||||
return ("unary", tok, self.unary())
|
||||
if tok in ("++", "--"):
|
||||
self.take()
|
||||
name = self.take()
|
||||
if not ARITH_NAME.fullmatch(name):
|
||||
raise ArithError(f'syntax error: "{tok}" requires a variable')
|
||||
return ("pre", tok, name)
|
||||
return self.postfix()
|
||||
|
||||
def postfix(self) -> tuple:
|
||||
node = self.primary()
|
||||
if self.peek() in ("++", "--") and node[0] == "var":
|
||||
op = self.take()
|
||||
return ("post", op, node[1])
|
||||
return node
|
||||
|
||||
def primary(self) -> tuple:
|
||||
tok = self.take()
|
||||
if tok == "(":
|
||||
node = self.comma()
|
||||
self.expect(")")
|
||||
return node
|
||||
if ARITH_NAME.fullmatch(tok):
|
||||
return ("var", tok)
|
||||
try:
|
||||
return ("num", _parse_literal(tok))
|
||||
except ValueError:
|
||||
raise ArithError(f'syntax error: unexpected token "{tok}"') \
|
||||
from None
|
||||
|
||||
|
||||
class ArithEvaluator:
|
||||
"""Evaluates the tuple AST against an env, recording assignments.
|
||||
|
||||
Reads resolve through ``updates`` first, then ``env``; every write
|
||||
lands in ``updates`` so the caller decides what to apply to the
|
||||
session (bash arithmetic assignments are real assignments).
|
||||
"""
|
||||
|
||||
def __init__(self, env: Mapping[str, str], updates: dict[str, str],
|
||||
depth: int) -> None:
|
||||
self.env = env
|
||||
self.updates = updates
|
||||
self.depth = depth
|
||||
|
||||
def lookup(self, name: str) -> int:
|
||||
raw = self.updates.get(name)
|
||||
if raw is None:
|
||||
value = self.env.get(name, "")
|
||||
raw = value if isinstance(value, str) else str(value)
|
||||
raw = raw.strip()
|
||||
if not raw:
|
||||
return 0
|
||||
try:
|
||||
return _parse_literal(raw)
|
||||
except (ValueError, ArithError):
|
||||
if self.depth >= ARITH_MAX_DEPTH:
|
||||
raise ArithError(
|
||||
f"expression recursion level exceeded (error token is "
|
||||
f'"{raw}")') from None
|
||||
value, _ = evaluate_arith(raw, {
|
||||
**dict(self.env),
|
||||
**self.updates
|
||||
},
|
||||
depth=self.depth + 1)
|
||||
return value
|
||||
|
||||
def run(self, node: tuple) -> int:
|
||||
kind = node[0]
|
||||
if kind == "num":
|
||||
return node[1]
|
||||
if kind == "var":
|
||||
return self.lookup(node[1])
|
||||
if kind == "comma":
|
||||
value = 0
|
||||
for part in node[1]:
|
||||
value = self.run(part)
|
||||
return value
|
||||
if kind == "assign":
|
||||
_, name, op, rhs = node
|
||||
rhs_val = self.run(rhs)
|
||||
value = (rhs_val if op == "=" else self.apply_binop(
|
||||
op[:-1], self.lookup(name), rhs_val))
|
||||
self.updates[name] = str(value)
|
||||
return value
|
||||
if kind == "ternary":
|
||||
_, cond, then, other = node
|
||||
return self.run(then) if self.run(cond) != 0 else self.run(other)
|
||||
if kind == "logic":
|
||||
_, op, left, right = node
|
||||
lval = self.run(left)
|
||||
if op == "&&":
|
||||
return 1 if lval != 0 and self.run(right) != 0 else 0
|
||||
return 1 if lval != 0 or self.run(right) != 0 else 0
|
||||
if kind == "binop":
|
||||
_, op, left, right = node
|
||||
return self.apply_binop(op, self.run(left), self.run(right))
|
||||
if kind == "unary":
|
||||
_, op, operand = node
|
||||
value = self.run(operand)
|
||||
if op == "!":
|
||||
return 0 if value != 0 else 1
|
||||
if op == "~":
|
||||
return _wrap(~value)
|
||||
if op == "-":
|
||||
return _wrap(-value)
|
||||
return value
|
||||
if kind == "pre":
|
||||
_, op, name = node
|
||||
value = _wrap(self.lookup(name) + (1 if op == "++" else -1))
|
||||
self.updates[name] = str(value)
|
||||
return value
|
||||
if kind == "post":
|
||||
_, op, name = node
|
||||
value = self.lookup(name)
|
||||
self.updates[name] = str(_wrap(value + (1 if op == "++" else -1)))
|
||||
return value
|
||||
raise ArithError(f"unsupported node: {kind}")
|
||||
|
||||
def apply_binop(self, op: str, a: int, b: int) -> int:
|
||||
if op == "+":
|
||||
return _wrap(a + b)
|
||||
if op == "-":
|
||||
return _wrap(a - b)
|
||||
if op == "*":
|
||||
return _wrap(a * b)
|
||||
if op == "/":
|
||||
return _wrap(_trunc_div(a, b))
|
||||
if op == "%":
|
||||
return _wrap(_trunc_mod(a, b))
|
||||
if op == "**":
|
||||
if b < 0:
|
||||
raise ArithError("exponent less than 0")
|
||||
return _wrap(a**b)
|
||||
if op == "<<":
|
||||
return _wrap(a << (b & 63))
|
||||
if op == ">>":
|
||||
return _wrap(a >> (b & 63))
|
||||
if op == "&":
|
||||
return _wrap(a & b)
|
||||
if op == "|":
|
||||
return _wrap(a | b)
|
||||
if op == "^":
|
||||
return _wrap(a ^ b)
|
||||
if op == "==":
|
||||
return 1 if a == b else 0
|
||||
if op == "!=":
|
||||
return 1 if a != b else 0
|
||||
if op == "<":
|
||||
return 1 if a < b else 0
|
||||
if op == "<=":
|
||||
return 1 if a <= b else 0
|
||||
if op == ">":
|
||||
return 1 if a > b else 0
|
||||
if op == ">=":
|
||||
return 1 if a >= b else 0
|
||||
raise ArithError(f'unsupported operator "{op}"')
|
||||
|
||||
|
||||
def evaluate_arith(expr: str,
|
||||
env: Mapping[str, str],
|
||||
depth: int = 0) -> tuple[int, dict[str, str]]:
|
||||
"""Evaluate a bash arithmetic expression.
|
||||
|
||||
Implements bash's arithmetic grammar over 64-bit wrapping integers:
|
||||
comma sequences, assignment operators, the ternary, short-circuit
|
||||
``&&``/``||``, bitwise/relational/shift/additive/multiplicative
|
||||
operators, right-associative ``**``, unary operators, and
|
||||
prefix/postfix ``++``/``--``. Division truncates toward zero and
|
||||
``%`` takes the dividend's sign (C semantics, unlike Python's
|
||||
floor). A variable whose value is not a plain integer literal is
|
||||
evaluated recursively like bash (``x="1+2"; $((x))`` is 3).
|
||||
``base#value`` literals are not supported.
|
||||
|
||||
Args:
|
||||
expr (str): the expression text, already ``$``-expanded.
|
||||
env (Mapping[str, str]): variable environment for reads.
|
||||
depth (int): recursion depth for variable re-evaluation.
|
||||
|
||||
Returns:
|
||||
tuple[int, dict[str, str]]: the value and the assignments made
|
||||
(name to decimal string), for the caller to apply to the session.
|
||||
|
||||
Raises:
|
||||
ArithError: on syntax errors, division by zero, or a negative
|
||||
exponent, with a bash-style message.
|
||||
"""
|
||||
tokens = _tokenize(expr)
|
||||
if not tokens:
|
||||
return 0, {}
|
||||
node = ArithParser(tokens).parse()
|
||||
updates: dict[str, str] = {}
|
||||
value = ArithEvaluator(env, updates, depth).run(node)
|
||||
return value, updates
|
||||
@@ -0,0 +1,52 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from enum import Enum
|
||||
|
||||
from mirage.io import IOResult
|
||||
from mirage.io.stream import drain, materialize
|
||||
from mirage.io.types import ByteSource
|
||||
|
||||
|
||||
class BarrierPolicy(Enum):
|
||||
STREAM = "stream"
|
||||
STATUS = "status"
|
||||
VALUE = "value"
|
||||
|
||||
|
||||
async def apply_barrier(
|
||||
stdout: ByteSource | None,
|
||||
io: IOResult,
|
||||
policy: BarrierPolicy,
|
||||
) -> ByteSource | None:
|
||||
"""Apply a shell-level barrier to finalize a command result.
|
||||
|
||||
Args:
|
||||
stdout (ByteSource | None): lazy or materialized stdout stream
|
||||
io (IOResult): the IOResult whose exit_code may still be provisional
|
||||
policy (BarrierPolicy): which barrier level to enforce
|
||||
|
||||
Returns:
|
||||
ByteSource | None: the (possibly transformed) stdout
|
||||
"""
|
||||
if policy is BarrierPolicy.STREAM:
|
||||
return stdout
|
||||
if policy is BarrierPolicy.STATUS:
|
||||
await drain(stdout)
|
||||
io.sync_exit_code()
|
||||
return None
|
||||
# VALUE
|
||||
result = await materialize(stdout)
|
||||
io.sync_exit_code()
|
||||
return result
|
||||
@@ -0,0 +1,78 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
@dataclass
|
||||
class CallFrame:
|
||||
positional: list[str] = field(default_factory=list)
|
||||
locals: dict[str, str] = field(default_factory=dict)
|
||||
function_name: str = ""
|
||||
loop_level: int = 0
|
||||
|
||||
|
||||
class CallStack:
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._frames: list[CallFrame] = [CallFrame()]
|
||||
|
||||
@property
|
||||
def current(self) -> CallFrame:
|
||||
return self._frames[-1]
|
||||
|
||||
def push(self,
|
||||
positional: list[str] | None = None,
|
||||
function_name: str = "") -> None:
|
||||
self._frames.append(
|
||||
CallFrame(
|
||||
positional=positional or [],
|
||||
function_name=function_name,
|
||||
))
|
||||
|
||||
def pop(self) -> CallFrame:
|
||||
if len(self._frames) <= 1:
|
||||
return self._frames[0]
|
||||
return self._frames.pop()
|
||||
|
||||
@property
|
||||
def depth(self) -> int:
|
||||
return len(self._frames)
|
||||
|
||||
def get_positional(self, index: int) -> str:
|
||||
pos = self.current.positional
|
||||
if 0 < index <= len(pos):
|
||||
return pos[index - 1]
|
||||
return ""
|
||||
|
||||
def get_all_positional(self) -> list[str]:
|
||||
return self.current.positional
|
||||
|
||||
def get_positional_count(self) -> int:
|
||||
return len(self.current.positional)
|
||||
|
||||
def shift(self, n: int = 1) -> None:
|
||||
self.current.positional = self.current.positional[n:]
|
||||
|
||||
def set_positional(self, values: list[str]) -> None:
|
||||
self.current.positional = values
|
||||
|
||||
def set_local(self, name: str, value: str) -> None:
|
||||
self.current.locals[name] = value
|
||||
|
||||
def get_local(self, name: str) -> str | None:
|
||||
for frame in reversed(self._frames):
|
||||
if name in frame.locals:
|
||||
return frame.locals[name]
|
||||
return None
|
||||
@@ -0,0 +1,41 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import re
|
||||
|
||||
# Bash arithmetic tokens: integer literals (decimal/hex/octal), variable
|
||||
# names, then operators longest-first so `<<=` never lexes as `<<` + `=`.
|
||||
ARITH_TOKEN = re.compile(
|
||||
r"""
|
||||
(?P<num>0[xX][0-9a-fA-F]+|\d+)
|
||||
| (?P<name>[A-Za-z_]\w*)
|
||||
| (?P<op><<=|>>=|\*\*|\+\+|--|<<|>>|<=|>=|==|!=|&&|\|\|
|
||||
|\+=|-=|\*=|/=|%=|&=|\^=|\|=
|
||||
|[-+*/%<>=!~&|^?:(),])
|
||||
| (?P<ws>\s+)
|
||||
| (?P<bad>.)
|
||||
""", re.VERBOSE)
|
||||
|
||||
ARITH_NAME = re.compile(r"[A-Za-z_]\w*")
|
||||
|
||||
ARITH_ASSIGN_OPS = frozenset(
|
||||
{"=", "+=", "-=", "*=", "/=", "%=", "<<=", ">>=", "&=", "^=", "|="})
|
||||
|
||||
# 64-bit wrap like bash (intmax_t arithmetic).
|
||||
ARITH_WRAP = 1 << 64
|
||||
ARITH_SIGN = 1 << 63
|
||||
|
||||
# Recursion budget for variables holding expressions (`x="1+2"; $((x))`),
|
||||
# mirroring bash's expression recursion limit.
|
||||
ARITH_MAX_DEPTH = 16
|
||||
@@ -0,0 +1,17 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
|
||||
class ArithError(ValueError):
|
||||
"""A bash arithmetic syntax or evaluation error."""
|
||||
@@ -0,0 +1,479 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from enum import StrEnum
|
||||
|
||||
import tree_sitter
|
||||
|
||||
from mirage.shell.types import NodeType as NT
|
||||
from mirage.shell.types import Redirect, RedirectKind
|
||||
|
||||
|
||||
class ProcessSubDirection(StrEnum):
|
||||
INPUT = "input"
|
||||
OUTPUT = "output"
|
||||
|
||||
|
||||
def get_text(node: tree_sitter.Node) -> str:
|
||||
"""Get the text content of a node."""
|
||||
return node.text.decode()
|
||||
|
||||
|
||||
def get_command_name(node: tree_sitter.Node) -> str:
|
||||
"""Get the command name string."""
|
||||
for c in node.named_children:
|
||||
if c.type == NT.COMMAND_NAME:
|
||||
return c.text.decode()
|
||||
return ""
|
||||
|
||||
|
||||
def get_parts(node: tree_sitter.Node) -> list[tree_sitter.Node]:
|
||||
"""Get command parts as child nodes.
|
||||
|
||||
Preserves expansion nodes for later processing.
|
||||
"""
|
||||
_SKIP = frozenset({NT.FILE_REDIRECT, NT.HERESTRING_REDIRECT})
|
||||
return [c for c in node.named_children if c.type not in _SKIP]
|
||||
|
||||
|
||||
def has_command_substitution(node: tree_sitter.Node) -> bool:
|
||||
"""Whether the node contains a command or process substitution.
|
||||
|
||||
The provision planner suppresses substitution execution, so any
|
||||
word carrying one expands to empty during a plan walk and the
|
||||
affected estimate must degrade to UNKNOWN instead of trusting the
|
||||
incomplete expansion.
|
||||
"""
|
||||
if node.type in (NT.COMMAND_SUBSTITUTION, NT.PROCESS_SUBSTITUTION):
|
||||
return True
|
||||
return any(has_command_substitution(c) for c in node.named_children)
|
||||
|
||||
|
||||
def split_env_prefix(
|
||||
parts: list[tree_sitter.Node],
|
||||
) -> tuple[list[tree_sitter.Node], list[tree_sitter.Node]]:
|
||||
"""Split FOO=1 BAR=2 cmd parts into (assignments, remaining).
|
||||
|
||||
The single structural rule for env-prefixed commands, shared by the
|
||||
executor (which expands and applies the assignments) and the
|
||||
provision planner (which only needs the command parts).
|
||||
"""
|
||||
assignments: list[tree_sitter.Node] = []
|
||||
remaining: list[tree_sitter.Node] = []
|
||||
saw_command_name = False
|
||||
for p in parts:
|
||||
if not saw_command_name and p.type == NT.VARIABLE_ASSIGNMENT:
|
||||
assignments.append(p)
|
||||
continue
|
||||
if p.type == NT.COMMAND_NAME:
|
||||
saw_command_name = True
|
||||
remaining.append(p)
|
||||
return assignments, remaining
|
||||
|
||||
|
||||
def get_pipeline_commands(
|
||||
node: tree_sitter.Node,
|
||||
) -> tuple[list[tree_sitter.Node], list[bool]]: # noqa: E125,E501
|
||||
"""Get (commands, stderr_flags) from pipeline.
|
||||
|
||||
Uses node.children for pipe token detection.
|
||||
"""
|
||||
commands: list[tree_sitter.Node] = []
|
||||
stderr_flags: list[bool] = []
|
||||
for c in node.children:
|
||||
if c.is_named:
|
||||
commands.append(c)
|
||||
elif c.type in (NT.PIPE, NT.PIPE_STDERR):
|
||||
stderr_flags.append(c.type == NT.PIPE_STDERR)
|
||||
return commands, stderr_flags
|
||||
|
||||
|
||||
def get_while_parts(
|
||||
node: tree_sitter.Node,
|
||||
) -> tuple[tree_sitter.Node, list[tree_sitter.Node]]:
|
||||
"""Get (condition, body_commands) from while/until.
|
||||
|
||||
Returns the do_group's children list so multi-statement
|
||||
bodies are preserved.
|
||||
"""
|
||||
nc = node.named_children
|
||||
condition = nc[0]
|
||||
body = list(nc[1].named_children)
|
||||
return condition, body
|
||||
|
||||
|
||||
def get_for_parts(
|
||||
node: tree_sitter.Node,
|
||||
) -> tuple[str, list[tree_sitter.Node], list[tree_sitter.Node]]:
|
||||
"""Get (variable, values, body_commands) from for/select.
|
||||
|
||||
Returns the do_group's children list so multi-statement
|
||||
bodies are preserved.
|
||||
"""
|
||||
nc = node.named_children
|
||||
variable = get_text(nc[0])
|
||||
values = [c for c in nc[1:] if c.type not in (NT.DO_GROUP, "ERROR")]
|
||||
body = list(nc[-1].named_children)
|
||||
return variable, values, body
|
||||
|
||||
|
||||
def get_subshell_body(node: tree_sitter.Node) -> list[tree_sitter.Node]:
|
||||
"""Get body commands from subshell."""
|
||||
return list(node.named_children)
|
||||
|
||||
|
||||
def get_redirect_parts(
|
||||
node: tree_sitter.Node,
|
||||
) -> tuple[tree_sitter.Node, str, bool, RedirectKind]: # noqa: E125,E501
|
||||
"""Get (command, target, append, stream) for the first redirect.
|
||||
|
||||
Legacy helper — use get_redirects for full redirect support.
|
||||
"""
|
||||
command, redirects = get_redirects(node)
|
||||
if not redirects:
|
||||
return command, "", False, RedirectKind.STDOUT
|
||||
r = redirects[0]
|
||||
target = r.target if isinstance(r.target, str) else ""
|
||||
return command, target, r.append, r.kind
|
||||
|
||||
|
||||
def get_redirects(
|
||||
node: tree_sitter.Node, # noqa: E125
|
||||
) -> tuple[tree_sitter.Node, list[Redirect]]:
|
||||
"""Parse all redirects from a redirected_statement."""
|
||||
nc = node.named_children
|
||||
command = nc[0]
|
||||
redirects: list[Redirect] = []
|
||||
|
||||
for child in nc[1:]:
|
||||
if child.type == NT.HEREDOC_REDIRECT:
|
||||
body, _, quoted = get_heredoc_meta(child)
|
||||
pipe_node = None
|
||||
for hc in child.named_children:
|
||||
if hc.type in (NT.PIPELINE, NT.COMMAND):
|
||||
pipe_node = hc
|
||||
break
|
||||
redirects.append(
|
||||
Redirect(fd=0,
|
||||
target=body,
|
||||
kind=RedirectKind.HEREDOC,
|
||||
pipeline=pipe_node,
|
||||
expand_vars=not quoted))
|
||||
continue
|
||||
|
||||
if child.type == NT.HERESTRING_REDIRECT:
|
||||
content = ""
|
||||
for sc in child.children:
|
||||
if sc.is_named and sc.type != "<<<":
|
||||
content = get_text(sc)
|
||||
break
|
||||
redirects.append(
|
||||
Redirect(fd=0, target=content, kind=RedirectKind.HERESTRING))
|
||||
continue
|
||||
|
||||
if child.type != NT.FILE_REDIRECT:
|
||||
continue
|
||||
|
||||
fd = 1
|
||||
target = ""
|
||||
target_node = None
|
||||
kind = RedirectKind.STDOUT
|
||||
append = False
|
||||
dup_fd = None
|
||||
|
||||
for c in child.children:
|
||||
if c.type == NT.FILE_DESCRIPTOR:
|
||||
fd = int(get_text(c))
|
||||
elif c.type == NT.REDIRECT_OUT:
|
||||
pass
|
||||
elif c.type == NT.REDIRECT_APPEND:
|
||||
append = True
|
||||
elif c.type == NT.REDIRECT_IN:
|
||||
kind = RedirectKind.STDIN
|
||||
fd = 0
|
||||
elif c.type == NT.REDIRECT_STDERR:
|
||||
kind = RedirectKind.STDERR_TO_STDOUT
|
||||
elif c.type == NT.REDIRECT_BOTH:
|
||||
kind = RedirectKind.STDOUT
|
||||
fd = -1
|
||||
elif c.type == NT.REDIRECT_BOTH_APPEND:
|
||||
kind = RedirectKind.STDOUT
|
||||
fd = -1
|
||||
append = True
|
||||
elif c.type == NT.NUMBER:
|
||||
dup_fd = int(get_text(c))
|
||||
|
||||
_TARGET_TYPES = frozenset({
|
||||
NT.WORD,
|
||||
NT.CONCATENATION,
|
||||
NT.SIMPLE_EXPANSION,
|
||||
NT.EXPANSION,
|
||||
NT.COMMAND_SUBSTITUTION,
|
||||
NT.STRING,
|
||||
})
|
||||
for c in child.named_children:
|
||||
if c.type in _TARGET_TYPES:
|
||||
target = get_text(c)
|
||||
target_node = c
|
||||
break
|
||||
|
||||
if dup_fd is not None and kind == RedirectKind.STDERR_TO_STDOUT:
|
||||
if fd == 2 and dup_fd == 1:
|
||||
kind = RedirectKind.STDERR_TO_STDOUT
|
||||
target = dup_fd
|
||||
elif fd == 1 and dup_fd == 2:
|
||||
kind = RedirectKind.STDOUT
|
||||
fd = 1
|
||||
target = 2
|
||||
else:
|
||||
target = dup_fd
|
||||
|
||||
if fd == -1:
|
||||
kind = RedirectKind.STDOUT
|
||||
redirects.append(
|
||||
Redirect(fd=-1,
|
||||
target=target,
|
||||
target_node=target_node,
|
||||
kind=kind,
|
||||
append=append))
|
||||
continue
|
||||
|
||||
if fd == 2 and kind != RedirectKind.STDERR_TO_STDOUT:
|
||||
kind = RedirectKind.STDERR
|
||||
|
||||
redirects.append(
|
||||
Redirect(fd=fd,
|
||||
target=target,
|
||||
target_node=target_node,
|
||||
kind=kind,
|
||||
append=append))
|
||||
|
||||
return command, redirects
|
||||
|
||||
|
||||
def get_redirect_target_node(
|
||||
node: tree_sitter.Node) -> tree_sitter.Node | None:
|
||||
"""Get the expandable target node from the first redirect."""
|
||||
_, redirects = get_redirects(node)
|
||||
if not redirects:
|
||||
return None
|
||||
return redirects[0].target_node
|
||||
|
||||
|
||||
def get_list_parts(
|
||||
node: tree_sitter.Node,
|
||||
) -> tuple[tree_sitter.Node, str, tree_sitter.Node]:
|
||||
"""Get (left, op, right) from list node."""
|
||||
left = node.named_children[0]
|
||||
right = node.named_children[1]
|
||||
op = None
|
||||
for c in node.children:
|
||||
if c.type in (NT.AND, NT.OR, NT.SEMI):
|
||||
op = c.type
|
||||
break
|
||||
return left, op, right
|
||||
|
||||
|
||||
def get_if_branches(
|
||||
node: tree_sitter.Node,
|
||||
) -> tuple[list[tuple[tree_sitter.Node, list[tree_sitter.Node]]],
|
||||
list[tree_sitter.Node] | None]:
|
||||
"""Get (branches, else_body) from if_statement.
|
||||
|
||||
Each branch is (condition, body_commands) where
|
||||
body_commands is a list of tree-sitter nodes.
|
||||
else_body is also a list of nodes, or None.
|
||||
"""
|
||||
nc = node.named_children
|
||||
condition = nc[0]
|
||||
body: list[tree_sitter.Node] = []
|
||||
branches: list[tuple[tree_sitter.Node, list[tree_sitter.Node]]] = []
|
||||
else_body = None
|
||||
|
||||
for c in nc[1:]:
|
||||
if c.type == NT.ELIF_CLAUSE:
|
||||
if condition is not None:
|
||||
branches.append((condition, body))
|
||||
ec = c.named_children
|
||||
condition = ec[0]
|
||||
body = list(ec[1:])
|
||||
elif c.type == NT.ELSE_CLAUSE:
|
||||
if condition is not None:
|
||||
branches.append((condition, body))
|
||||
condition = None
|
||||
else_body = list(c.named_children)
|
||||
else:
|
||||
body.append(c)
|
||||
|
||||
if condition is not None:
|
||||
branches.append((condition, body))
|
||||
|
||||
return branches, else_body
|
||||
|
||||
|
||||
def get_case_word(node: tree_sitter.Node) -> tree_sitter.Node:
|
||||
"""Get the word being matched in case."""
|
||||
return node.named_children[0]
|
||||
|
||||
|
||||
def get_case_items(
|
||||
node: tree_sitter.Node,
|
||||
) -> list[tuple[list[str], list[tree_sitter.Node]]]: # noqa: E125,E501
|
||||
"""Get (patterns, body_statements) pairs from case.
|
||||
|
||||
An arm's body is every statement up to its ;; terminator, so
|
||||
multi-statement arms (x) cmd1; cmd2;;) keep all commands.
|
||||
"""
|
||||
items: list[tuple[list[str], list[tree_sitter.Node]]] = []
|
||||
for c in node.named_children:
|
||||
if c.type == NT.CASE_ITEM:
|
||||
patterns = []
|
||||
body: list[tree_sitter.Node] = []
|
||||
for child in c.children:
|
||||
if not body and child.type in (NT.EXTGLOB_PATTERN, NT.WORD,
|
||||
NT.CONCATENATION, NT.STRING):
|
||||
patterns.append(get_text(child))
|
||||
elif child.is_named and child.type != "|":
|
||||
body.append(child)
|
||||
if not patterns:
|
||||
patterns = [get_text(c.named_children[0])]
|
||||
items.append((patterns, body))
|
||||
return items
|
||||
|
||||
|
||||
def get_declaration_assignments(node: tree_sitter.Node) -> list[str]:
|
||||
"""Get assignment strings from declaration_command.
|
||||
|
||||
Works for export, local, declare.
|
||||
"""
|
||||
return [
|
||||
get_text(c) for c in node.named_children
|
||||
if c.type == NT.VARIABLE_ASSIGNMENT
|
||||
]
|
||||
|
||||
|
||||
def get_declaration_keyword(node: tree_sitter.Node) -> str:
|
||||
"""Get keyword (export/local/declare) from declaration."""
|
||||
return node.children[0].type
|
||||
|
||||
|
||||
def get_unset_names(node: tree_sitter.Node) -> list[str]:
|
||||
"""Get variable names from unset_command."""
|
||||
return [
|
||||
get_text(c) for c in node.named_children if c.type == NT.VARIABLE_NAME
|
||||
]
|
||||
|
||||
|
||||
def get_test_argv(node: tree_sitter.Node) -> list[str]:
|
||||
"""Get test arguments from test_command ([ ] or [[ ]]).
|
||||
|
||||
Returns the inner expression text.
|
||||
"""
|
||||
return [get_text(c) for c in node.named_children]
|
||||
|
||||
|
||||
def get_command_assignments(node: tree_sitter.Node) -> list[str]:
|
||||
"""Get prefix assignments from a command (A=1 B=2 cmd).
|
||||
|
||||
Returns list of assignment strings.
|
||||
"""
|
||||
return [
|
||||
get_text(c) for c in node.named_children
|
||||
if c.type == NT.VARIABLE_ASSIGNMENT
|
||||
]
|
||||
|
||||
|
||||
def get_negated_command(node: tree_sitter.Node) -> tree_sitter.Node:
|
||||
"""Get inner command from negated_command (! cmd)."""
|
||||
return node.named_children[0]
|
||||
|
||||
|
||||
def get_heredoc_parts(redirect_node: tree_sitter.Node) -> tuple[str, str]:
|
||||
"""Get (delimiter, body) from heredoc_redirect."""
|
||||
delimiter = ""
|
||||
body = ""
|
||||
for c in redirect_node.named_children:
|
||||
if c.type == NT.HEREDOC_START:
|
||||
delimiter = get_text(c)
|
||||
elif c.type == NT.HEREDOC_BODY:
|
||||
body = get_text(c)
|
||||
return delimiter, body
|
||||
|
||||
|
||||
def get_heredoc_meta(
|
||||
redirect_node: tree_sitter.Node) -> tuple[str, bool, bool]:
|
||||
"""Get (body, dash, quoted) from heredoc_redirect.
|
||||
|
||||
- dash: True if operator was `<<-` (strip leading tabs from body lines)
|
||||
- quoted: True if delimiter was wrapped in quotes (no var expansion)
|
||||
"""
|
||||
delimiter, body = get_heredoc_parts(redirect_node)
|
||||
quoted = (delimiter.startswith("'")
|
||||
and delimiter.endswith("'")) or (delimiter.startswith('"')
|
||||
and delimiter.endswith('"'))
|
||||
dash = False
|
||||
for c in redirect_node.children:
|
||||
if c.type == "<<-":
|
||||
dash = True
|
||||
break
|
||||
if dash and body:
|
||||
body = "\n".join(line.lstrip("\t") for line in body.split("\n"))
|
||||
return body, dash, quoted
|
||||
|
||||
|
||||
def get_herestring_content(node: tree_sitter.Node) -> str:
|
||||
"""Get content from herestring_redirect (<<<)."""
|
||||
for c in node.named_children:
|
||||
if c.type == NT.HERESTRING_REDIRECT:
|
||||
return get_text(c.named_children[0])
|
||||
return ""
|
||||
|
||||
|
||||
def get_process_sub_command(node: tree_sitter.Node) -> tree_sitter.Node:
|
||||
"""Get inner command from process_substitution."""
|
||||
return node.named_children[0]
|
||||
|
||||
|
||||
def get_process_sub_direction(
|
||||
node: tree_sitter.Node) -> ProcessSubDirection | None:
|
||||
"""Return the direction marker on a process_substitution node.
|
||||
|
||||
`<(cmd)` is INPUT (inner stdout feeds our stdin), `>(cmd)` is OUTPUT
|
||||
(our stdout feeds inner stdin). Returns None if the open token is missing.
|
||||
"""
|
||||
if not node.children:
|
||||
return None
|
||||
open_token = node.children[0].type
|
||||
if open_token == "<(":
|
||||
return ProcessSubDirection.INPUT
|
||||
if open_token == ">(":
|
||||
return ProcessSubDirection.OUTPUT
|
||||
return None
|
||||
|
||||
|
||||
def get_function_name(node: tree_sitter.Node) -> str:
|
||||
"""Get function name."""
|
||||
return get_text(node.named_children[0])
|
||||
|
||||
|
||||
def get_function_body(node: tree_sitter.Node) -> list[tree_sitter.Node] | None:
|
||||
"""Get function body commands.
|
||||
|
||||
Returns the compound_statement's children list so
|
||||
multi-statement bodies are preserved.
|
||||
"""
|
||||
for c in node.named_children:
|
||||
if c.type == NT.COMPOUND_STATEMENT:
|
||||
return list(c.named_children)
|
||||
return None
|
||||
@@ -0,0 +1,174 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
|
||||
from mirage.io.types import IOResult
|
||||
from mirage.types import DEFAULT_SESSION_ID
|
||||
from mirage.workspace.types import ExecutionNode
|
||||
|
||||
|
||||
class JobStatus(str, Enum):
|
||||
RUNNING = "running"
|
||||
COMPLETED = "completed"
|
||||
KILLED = "killed"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Job:
|
||||
id: int
|
||||
command: str
|
||||
task: asyncio.Task
|
||||
cwd: str
|
||||
status: JobStatus = JobStatus.RUNNING
|
||||
stdout: bytes = b""
|
||||
stderr: bytes = b""
|
||||
exit_code: int = 0
|
||||
execution_node: ExecutionNode | None = None
|
||||
io_result: IOResult | None = None
|
||||
created_at: float = field(default_factory=time.time)
|
||||
agent: str = "unknown"
|
||||
session_id: str = DEFAULT_SESSION_ID
|
||||
|
||||
|
||||
class JobTable:
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._jobs: dict[int, Job] = {}
|
||||
self._next_id: int = 1
|
||||
|
||||
def submit(
|
||||
self,
|
||||
command: str,
|
||||
task: asyncio.Task,
|
||||
cwd: str,
|
||||
agent: str = "unknown",
|
||||
session_id: str = DEFAULT_SESSION_ID,
|
||||
) -> Job:
|
||||
job = Job(id=self._next_id,
|
||||
command=command,
|
||||
task=task,
|
||||
cwd=cwd,
|
||||
agent=agent,
|
||||
session_id=session_id)
|
||||
self._jobs[job.id] = job
|
||||
self._next_id += 1
|
||||
return job
|
||||
|
||||
def get(self, job_id: int) -> Job | None:
|
||||
job = self._jobs.get(job_id)
|
||||
if job is not None:
|
||||
self._refresh(job)
|
||||
return job
|
||||
|
||||
def list_jobs(self) -> list[Job]:
|
||||
jobs = list(self._jobs.values())
|
||||
for j in jobs:
|
||||
self._refresh(j)
|
||||
return jobs
|
||||
|
||||
def running_jobs(self) -> list[Job]:
|
||||
for j in self._jobs.values():
|
||||
self._refresh(j)
|
||||
return [
|
||||
j for j in self._jobs.values() if j.status == JobStatus.RUNNING
|
||||
]
|
||||
|
||||
def _refresh(self, job: Job) -> None:
|
||||
"""Sync status from the underlying asyncio task without awaiting.
|
||||
|
||||
When the bg task has finished (normally, raised, or was cancelled)
|
||||
but no one has called ``wait``, this updates the job's status,
|
||||
exit_code, and captured streams from the task result. Lets
|
||||
``list_jobs`` / ``running_jobs`` / ``get`` report fresh state.
|
||||
Requires ``_run_bg`` to have already materialized stdout/stderr,
|
||||
so reading the result is purely synchronous.
|
||||
"""
|
||||
if job.status != JobStatus.RUNNING:
|
||||
return
|
||||
if not job.task.done():
|
||||
return
|
||||
if job.task.cancelled():
|
||||
job.status = JobStatus.KILLED
|
||||
job.exit_code = 137
|
||||
job.stderr = b"Killed"
|
||||
return
|
||||
exc = job.task.exception()
|
||||
if exc is not None:
|
||||
job.status = JobStatus.COMPLETED
|
||||
job.exit_code = 1
|
||||
job.stderr = str(exc).encode()
|
||||
return
|
||||
stdout, io_result, exec_node = job.task.result()
|
||||
job.stdout = stdout if isinstance(stdout, bytes) else b""
|
||||
job.io_result = io_result
|
||||
job.execution_node = exec_node
|
||||
io_result.sync_exit_code()
|
||||
job.exit_code = io_result.exit_code
|
||||
if isinstance(io_result.stderr, bytes):
|
||||
job.stderr = io_result.stderr
|
||||
elif io_result.stderr is None:
|
||||
job.stderr = b""
|
||||
job.status = JobStatus.COMPLETED
|
||||
|
||||
def kill(self, job_id: int) -> bool:
|
||||
job = self._jobs.get(job_id)
|
||||
if job is None:
|
||||
return False
|
||||
job.task.cancel()
|
||||
job.status = JobStatus.KILLED
|
||||
job.exit_code = 137
|
||||
job.stderr = b"Killed"
|
||||
return True
|
||||
|
||||
async def wait(self, job_id: int) -> Job:
|
||||
job = self._jobs[job_id]
|
||||
if job.status != JobStatus.RUNNING:
|
||||
return job
|
||||
try:
|
||||
stdout, io_result, exec_node = await job.task
|
||||
job.stdout = stdout if isinstance(stdout, bytes) else b""
|
||||
job.io_result = io_result
|
||||
job.execution_node = exec_node
|
||||
io_result.sync_exit_code()
|
||||
job.exit_code = io_result.exit_code
|
||||
job.stderr = await io_result.materialize_stderr()
|
||||
job.status = JobStatus.COMPLETED
|
||||
except asyncio.CancelledError:
|
||||
job.status = JobStatus.KILLED
|
||||
job.exit_code = 137
|
||||
job.stderr = b"Killed"
|
||||
except Exception as exc:
|
||||
job.status = JobStatus.COMPLETED
|
||||
job.exit_code = 1
|
||||
job.stderr = str(exc).encode()
|
||||
return job
|
||||
|
||||
async def wait_all(self) -> list[Job]:
|
||||
running = self.running_jobs()
|
||||
for job in running:
|
||||
await self.wait(job.id)
|
||||
return running
|
||||
|
||||
def pop_completed(self) -> list[Job]:
|
||||
"""Return completed/killed jobs and remove them from the table."""
|
||||
completed = [
|
||||
j for j in self._jobs.values() if j.status != JobStatus.RUNNING
|
||||
]
|
||||
for j in completed:
|
||||
del self._jobs[j.id]
|
||||
return completed
|
||||
@@ -0,0 +1,96 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from enum import StrEnum
|
||||
from typing import Any
|
||||
|
||||
from mirage.shell.types import NodeType as NT
|
||||
|
||||
|
||||
class NodeKind(StrEnum):
|
||||
"""Statement kinds both tree walkers dispatch on.
|
||||
|
||||
The executor and the provision planner walk the same tree-sitter
|
||||
AST. This enum is the single classification both use, so a
|
||||
construct cannot be supported by one walker and silently
|
||||
unclassified by the other: `node_kind` owns every tree-sitter
|
||||
node-type check, including the lookahead that distinguishes
|
||||
`select` from `for` and `until` from `while`.
|
||||
"""
|
||||
COMMENT = "comment"
|
||||
PROGRAM = "program"
|
||||
COMMAND = "command"
|
||||
PIPELINE = "pipeline"
|
||||
LIST = "list"
|
||||
REDIRECT = "redirect"
|
||||
SUBSHELL = "subshell"
|
||||
COMPOUND = "compound"
|
||||
IF = "if"
|
||||
FOR = "for"
|
||||
SELECT = "select"
|
||||
WHILE = "while"
|
||||
UNTIL = "until"
|
||||
CASE = "case"
|
||||
FUNCTION_DEF = "function_def"
|
||||
DECLARATION = "declaration"
|
||||
UNSET = "unset"
|
||||
TEST = "test"
|
||||
NEGATED = "negated"
|
||||
VAR_ASSIGN = "var_assign"
|
||||
UNSUPPORTED = "unsupported"
|
||||
|
||||
|
||||
_SIMPLE_KINDS = {
|
||||
NT.COMMENT: NodeKind.COMMENT,
|
||||
NT.PROGRAM: NodeKind.PROGRAM,
|
||||
NT.COMMAND: NodeKind.COMMAND,
|
||||
NT.PIPELINE: NodeKind.PIPELINE,
|
||||
NT.LIST: NodeKind.LIST,
|
||||
NT.REDIRECTED_STATEMENT: NodeKind.REDIRECT,
|
||||
NT.SUBSHELL: NodeKind.SUBSHELL,
|
||||
NT.COMPOUND_STATEMENT: NodeKind.COMPOUND,
|
||||
NT.IF_STATEMENT: NodeKind.IF,
|
||||
NT.CASE_STATEMENT: NodeKind.CASE,
|
||||
NT.FUNCTION_DEFINITION: NodeKind.FUNCTION_DEF,
|
||||
NT.DECLARATION_COMMAND: NodeKind.DECLARATION,
|
||||
NT.UNSET_COMMAND: NodeKind.UNSET,
|
||||
NT.TEST_COMMAND: NodeKind.TEST,
|
||||
NT.NEGATED_COMMAND: NodeKind.NEGATED,
|
||||
NT.VARIABLE_ASSIGNMENT: NodeKind.VAR_ASSIGN,
|
||||
}
|
||||
|
||||
|
||||
def node_kind(node: Any) -> NodeKind:
|
||||
"""Classify a tree-sitter node into the shared statement kind.
|
||||
|
||||
Args:
|
||||
node (Any): tree-sitter node.
|
||||
|
||||
Returns:
|
||||
NodeKind: statement kind, or UNSUPPORTED for node types
|
||||
neither walker implements (c-style for, arithmetic, ...).
|
||||
"""
|
||||
ntype = node.type
|
||||
simple = _SIMPLE_KINDS.get(ntype)
|
||||
if simple is not None:
|
||||
return simple
|
||||
if ntype == NT.FOR_STATEMENT:
|
||||
if node.children and node.children[0].type == NT.SELECT:
|
||||
return NodeKind.SELECT
|
||||
return NodeKind.FOR
|
||||
if ntype == NT.WHILE_STATEMENT:
|
||||
if node.children and node.children[0].type == NT.UNTIL:
|
||||
return NodeKind.UNTIL
|
||||
return NodeKind.WHILE
|
||||
return NodeKind.UNSUPPORTED
|
||||
@@ -0,0 +1,99 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import tree_sitter
|
||||
import tree_sitter_bash
|
||||
|
||||
BASH_LANGUAGE = tree_sitter.Language(tree_sitter_bash.language())
|
||||
TS_PARSER = tree_sitter.Parser(BASH_LANGUAGE)
|
||||
|
||||
|
||||
def parse(command: str) -> tree_sitter.Node:
|
||||
"""Parse a shell command string into a tree-sitter AST.
|
||||
|
||||
Returns the root tree-sitter node.
|
||||
"""
|
||||
tree = TS_PARSER.parse(command.encode())
|
||||
return tree.root_node
|
||||
|
||||
|
||||
_BASH_KEYWORDS = frozenset({
|
||||
"if",
|
||||
"then",
|
||||
"else",
|
||||
"elif",
|
||||
"fi",
|
||||
"for",
|
||||
"while",
|
||||
"until",
|
||||
"do",
|
||||
"done",
|
||||
"case",
|
||||
"esac",
|
||||
"in",
|
||||
"function",
|
||||
"select",
|
||||
})
|
||||
|
||||
_STRUCTURAL_TOKENS = frozenset({
|
||||
"(",
|
||||
")",
|
||||
"{",
|
||||
"}",
|
||||
"[",
|
||||
"]",
|
||||
'"',
|
||||
"'",
|
||||
"`",
|
||||
})
|
||||
|
||||
|
||||
def _is_structural_error(node: tree_sitter.Node) -> bool:
|
||||
"""True if an ERROR node represents a real syntactic problem.
|
||||
|
||||
Tree-sitter occasionally emits ERROR nodes for stray statement
|
||||
separators that bash itself accepts (notably ``& ;``). A real
|
||||
syntax error contains a bash keyword, a bracket / quote token,
|
||||
or a named subtree the parser tried to recover; stand-alone
|
||||
statement separators (``;``, ``&``, ``|``) are not enough.
|
||||
"""
|
||||
for child in node.children:
|
||||
if child.is_named:
|
||||
return True
|
||||
if child.type in _BASH_KEYWORDS:
|
||||
return True
|
||||
if child.type in _STRUCTURAL_TOKENS:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def find_syntax_error(node: tree_sitter.Node) -> str | None:
|
||||
"""Locate a top-level structural syntax error in a parsed AST.
|
||||
|
||||
Args:
|
||||
node (tree_sitter.Node): root node from parse().
|
||||
|
||||
Returns:
|
||||
str | None: text of the offending region, or None if the AST is clean.
|
||||
"""
|
||||
if not node.has_error:
|
||||
return None
|
||||
for child in node.children:
|
||||
if child.is_missing:
|
||||
text = child.text
|
||||
return text.decode(errors="replace") if text else ""
|
||||
if child.type == "ERROR" and _is_structural_error(child):
|
||||
text = child.text
|
||||
return text.decode(errors="replace") if text else ""
|
||||
return None
|
||||
@@ -0,0 +1,204 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
from typing import Any
|
||||
|
||||
|
||||
class NodeType(StrEnum):
|
||||
"""Tree-sitter-bash node types."""
|
||||
COMMAND = "command"
|
||||
PIPELINE = "pipeline"
|
||||
LIST = "list"
|
||||
REDIRECTED_STATEMENT = "redirected_statement"
|
||||
SUBSHELL = "subshell"
|
||||
IF_STATEMENT = "if_statement"
|
||||
FOR_STATEMENT = "for_statement"
|
||||
WHILE_STATEMENT = "while_statement"
|
||||
CASE_STATEMENT = "case_statement"
|
||||
CASE_ITEM = "case_item"
|
||||
FUNCTION_DEFINITION = "function_definition"
|
||||
DECLARATION_COMMAND = "declaration_command"
|
||||
UNSET_COMMAND = "unset_command"
|
||||
TEST_COMMAND = "test_command"
|
||||
COMPOUND_STATEMENT = "compound_statement"
|
||||
NEGATED_COMMAND = "negated_command"
|
||||
VARIABLE_ASSIGNMENT = "variable_assignment"
|
||||
FOR = "for"
|
||||
SELECT = "select"
|
||||
WHILE = "while"
|
||||
UNTIL = "until"
|
||||
EXPORT = "export"
|
||||
LOCAL = "local"
|
||||
WORD = "word"
|
||||
NUMBER = "number"
|
||||
COMMAND_NAME = "command_name"
|
||||
VARIABLE_NAME = "variable_name"
|
||||
SIMPLE_EXPANSION = "simple_expansion"
|
||||
EXPANSION = "expansion"
|
||||
COMMAND_SUBSTITUTION = "command_substitution"
|
||||
ARITHMETIC_EXPANSION = "arithmetic_expansion"
|
||||
CONCATENATION = "concatenation"
|
||||
STRING = "string"
|
||||
STRING_CONTENT = "string_content"
|
||||
RAW_STRING = "raw_string"
|
||||
PROCESS_SUBSTITUTION = "process_substitution"
|
||||
EXTGLOB_PATTERN = "extglob_pattern"
|
||||
DO_GROUP = "do_group"
|
||||
ELIF_CLAUSE = "elif_clause"
|
||||
ELSE_CLAUSE = "else_clause"
|
||||
FILE_REDIRECT = "file_redirect"
|
||||
HEREDOC_REDIRECT = "heredoc_redirect"
|
||||
HEREDOC_BODY = "heredoc_body"
|
||||
HEREDOC_START = "heredoc_start"
|
||||
HEREDOC_END = "heredoc_end"
|
||||
HERESTRING_REDIRECT = "herestring_redirect"
|
||||
FILE_DESCRIPTOR = "file_descriptor"
|
||||
ARRAY = "array"
|
||||
AND = "&&"
|
||||
OR = "||"
|
||||
SEMI = ";"
|
||||
BACKGROUND = "&"
|
||||
PIPE = "|"
|
||||
PIPE_STDERR = "|&"
|
||||
REDIRECT_OUT = ">"
|
||||
REDIRECT_APPEND = ">>"
|
||||
REDIRECT_IN = "<"
|
||||
REDIRECT_STDERR = ">&"
|
||||
REDIRECT_BOTH = "&>"
|
||||
REDIRECT_BOTH_APPEND = "&>>"
|
||||
HEREDOC_START_TOKEN = "<<"
|
||||
HERESTRING_TOKEN = "<<<"
|
||||
OPEN_PAREN = "("
|
||||
CLOSE_PAREN = ")"
|
||||
OPEN_BRACE = "{"
|
||||
CLOSE_BRACE = "}"
|
||||
OPEN_BRACKET = "["
|
||||
CLOSE_BRACKET = "]"
|
||||
DOUBLE_OPEN_PAREN = "(("
|
||||
DOUBLE_CLOSE_PAREN = "))"
|
||||
DOUBLE_SEMICOLON = ";;"
|
||||
DQUOTE = '"'
|
||||
IF = "if"
|
||||
THEN = "then"
|
||||
ELIF = "elif"
|
||||
ELSE = "else"
|
||||
FI = "fi"
|
||||
IN = "in"
|
||||
DO = "do"
|
||||
DONE = "done"
|
||||
CASE = "case"
|
||||
ESAC = "esac"
|
||||
FUNCTION = "function"
|
||||
PROGRAM = "program"
|
||||
BINARY_EXPRESSION = "binary_expression"
|
||||
UNARY_EXPRESSION = "unary_expression"
|
||||
NEGATION_EXPRESSION = "negation_expression"
|
||||
PARENTHESIZED_EXPRESSION = "parenthesized_expression"
|
||||
TERNARY_EXPRESSION = "ternary_expression"
|
||||
POSTFIX_EXPRESSION = "postfix_expression"
|
||||
ARITH_OPEN = "(("
|
||||
TEST_OPERATOR = "test_operator"
|
||||
SPECIAL_VARIABLE_NAME = "special_variable_name"
|
||||
COMMENT = "comment"
|
||||
ERROR = "ERROR"
|
||||
|
||||
|
||||
ERREXIT_EXEMPT_TYPES = frozenset({
|
||||
NodeType.LIST,
|
||||
NodeType.NEGATED_COMMAND,
|
||||
})
|
||||
|
||||
SET_FLAG_TO_OPTION = {
|
||||
"e": "errexit",
|
||||
"u": "nounset",
|
||||
"x": "xtrace",
|
||||
"f": "noglob",
|
||||
}
|
||||
|
||||
|
||||
class RedirectKind(StrEnum):
|
||||
STDOUT = "stdout"
|
||||
STDERR = "stderr"
|
||||
STDIN = "stdin"
|
||||
STDERR_TO_STDOUT = "stderr_to_stdout"
|
||||
HEREDOC = "heredoc"
|
||||
HERESTRING = "herestring"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Redirect:
|
||||
"""Parsed redirect from a redirected_statement."""
|
||||
fd: int
|
||||
target: Any
|
||||
target_node: Any = None
|
||||
kind: RedirectKind = RedirectKind.STDOUT
|
||||
append: bool = False
|
||||
pipeline: Any = None
|
||||
expand_vars: bool = True
|
||||
|
||||
|
||||
class ShellBuiltin(StrEnum):
|
||||
"""Shell builtin command names.
|
||||
|
||||
Commands that don't touch the filesystem.
|
||||
Handled directly by the executor, not dispatched
|
||||
to mounts.
|
||||
"""
|
||||
# session state
|
||||
PWD = "pwd"
|
||||
CD = "cd"
|
||||
EXPORT = "export"
|
||||
UNSET = "unset"
|
||||
LOCAL = "local"
|
||||
SET = "set"
|
||||
PRINTENV = "printenv"
|
||||
WHOAMI = "whoami"
|
||||
MAN = "man"
|
||||
HISTORY = "history"
|
||||
# control
|
||||
TRUE = "true"
|
||||
FALSE = "false"
|
||||
SOURCE = "source"
|
||||
DOT = "."
|
||||
EVAL = "eval"
|
||||
READ = "read"
|
||||
SHIFT = "shift"
|
||||
TRAP = "trap"
|
||||
TEST = "test"
|
||||
BRACKET = "["
|
||||
DOUBLE_BRACKET = "[["
|
||||
# job control
|
||||
WAIT = "wait"
|
||||
FG = "fg"
|
||||
KILL = "kill"
|
||||
JOBS = "jobs"
|
||||
PS = "ps"
|
||||
# output / text processing (no filesystem)
|
||||
ECHO = "echo"
|
||||
PRINTF = "printf"
|
||||
SLEEP = "sleep"
|
||||
# nested shells
|
||||
BASH = "bash"
|
||||
SH = "sh"
|
||||
# python exec
|
||||
PYTHON = "python"
|
||||
PYTHON3 = "python3"
|
||||
# commands handled by executor
|
||||
XARGS = "xargs"
|
||||
TIMEOUT = "timeout"
|
||||
BREAK = "break"
|
||||
CONTINUE = "continue"
|
||||
RETURN = "return"
|
||||
Reference in New Issue
Block a user