mirror of
https://github.com/infinition/Bjorn.git
synced 2026-03-15 17:01:58 +00:00
- Implemented LokiUtils class with GET and POST endpoints for managing scripts, jobs, and payloads. - Added SentinelUtils class with GET and POST endpoints for managing events, rules, devices, and notifications. - Both classes include error handling and JSON response formatting.
749 lines
26 KiB
Python
749 lines
26 KiB
Python
"""
|
|
HIDScript parser and executor for Loki.
|
|
|
|
Supports P4wnP1-compatible HIDScript syntax:
|
|
- Function calls: type("hello"); press("GUI r"); delay(500);
|
|
- var declarations: var x = 1;
|
|
- for / while loops
|
|
- if / else conditionals
|
|
- // and /* */ comments
|
|
- String concatenation with +
|
|
- Basic arithmetic (+, -, *, /)
|
|
- console.log() for job output
|
|
|
|
Zero external dependencies — pure Python DSL parser.
|
|
"""
|
|
import re
|
|
import time
|
|
import logging
|
|
from threading import Event
|
|
|
|
from logger import Logger
|
|
|
|
logger = Logger(name="loki.hidscript", level=logging.DEBUG)
|
|
|
|
# ── LED constants (available in scripts) ──────────────────────
|
|
NUM = 0x01
|
|
CAPS = 0x02
|
|
SCROLL = 0x04
|
|
ANY = 0xFF
|
|
|
|
# ── Mouse button constants ────────────────────────────────────
|
|
BT1 = 1 # Left
|
|
BT2 = 2 # Right
|
|
BT3 = 4 # Middle
|
|
BTNONE = 0
|
|
|
|
|
|
class HIDScriptError(Exception):
|
|
"""Error during HIDScript execution."""
|
|
def __init__(self, message, line=None):
|
|
self.line = line
|
|
super().__init__(f"Line {line}: {message}" if line else message)
|
|
|
|
|
|
class HIDScriptParser:
|
|
"""Parse and execute P4wnP1-compatible HIDScript."""
|
|
|
|
def __init__(self, hid_controller, layout="us"):
|
|
self.hid = hid_controller
|
|
self._default_layout = layout
|
|
self._output = [] # console.log output
|
|
|
|
def execute(self, source: str, stop_event: Event = None, job_id: str = ""):
|
|
"""Parse and execute a HIDScript source string.
|
|
|
|
Returns list of console.log output lines.
|
|
"""
|
|
self._output = []
|
|
self._stop = stop_event or Event()
|
|
self._vars = {
|
|
# Built-in constants
|
|
"NUM": NUM, "CAPS": CAPS, "SCROLL": SCROLL, "ANY": ANY,
|
|
"BT1": BT1, "BT2": BT2, "BT3": BT3, "BTNONE": BTNONE,
|
|
"true": True, "false": False, "null": None,
|
|
}
|
|
|
|
# Strip comments
|
|
source = self._strip_comments(source)
|
|
# Tokenize into statements
|
|
stmts = self._parse_block(source)
|
|
# Execute
|
|
self._exec_stmts(stmts)
|
|
|
|
return self._output
|
|
|
|
# ── Comment stripping ──────────────────────────────────────
|
|
|
|
def _strip_comments(self, source: str) -> str:
|
|
"""Remove // and /* */ comments."""
|
|
# Block comments first
|
|
source = re.sub(r'/\*.*?\*/', '', source, flags=re.DOTALL)
|
|
# Line comments
|
|
source = re.sub(r'//[^\n]*', '', source)
|
|
return source
|
|
|
|
# ── Parser ─────────────────────────────────────────────────
|
|
|
|
def _parse_block(self, source: str) -> list:
|
|
"""Parse source into a list of statement dicts."""
|
|
stmts = []
|
|
pos = 0
|
|
source = source.strip()
|
|
|
|
while pos < len(source):
|
|
if self._stop.is_set():
|
|
break
|
|
pos = self._skip_ws(source, pos)
|
|
if pos >= len(source):
|
|
break
|
|
|
|
# var declaration
|
|
if source[pos:pos+4] == 'var ' or source[pos:pos+4] == 'let ':
|
|
end = source.find(';', pos)
|
|
if end == -1:
|
|
end = len(source)
|
|
decl = source[pos+4:end].strip()
|
|
eq = decl.find('=')
|
|
if eq >= 0:
|
|
name = decl[:eq].strip()
|
|
value_expr = decl[eq+1:].strip()
|
|
stmts.append({"type": "assign", "name": name, "expr": value_expr})
|
|
else:
|
|
stmts.append({"type": "assign", "name": decl.strip(), "expr": "null"})
|
|
pos = end + 1
|
|
|
|
# for loop
|
|
elif source[pos:pos+4] == 'for ' or source[pos:pos+4] == 'for(':
|
|
stmt, pos = self._parse_for(source, pos)
|
|
stmts.append(stmt)
|
|
|
|
# while loop
|
|
elif source[pos:pos+6] == 'while ' or source[pos:pos+6] == 'while(':
|
|
stmt, pos = self._parse_while(source, pos)
|
|
stmts.append(stmt)
|
|
|
|
# if statement
|
|
elif source[pos:pos+3] == 'if ' or source[pos:pos+3] == 'if(':
|
|
stmt, pos = self._parse_if(source, pos)
|
|
stmts.append(stmt)
|
|
|
|
# Block: { ... }
|
|
elif source[pos] == '{':
|
|
end = self._find_matching_brace(source, pos)
|
|
inner = source[pos+1:end]
|
|
stmts.extend(self._parse_block(inner))
|
|
pos = end + 1
|
|
|
|
# Expression statement (function call or assignment)
|
|
else:
|
|
end = source.find(';', pos)
|
|
if end == -1:
|
|
end = len(source)
|
|
expr = source[pos:end].strip()
|
|
if expr:
|
|
# Check for assignment: name = expr
|
|
m = re.match(r'^([a-zA-Z_]\w*)\s*=\s*(.+)$', expr)
|
|
if m and not expr.startswith('=='):
|
|
stmts.append({"type": "assign", "name": m.group(1), "expr": m.group(2)})
|
|
else:
|
|
stmts.append({"type": "expr", "expr": expr})
|
|
pos = end + 1
|
|
|
|
return stmts
|
|
|
|
def _parse_for(self, source, pos):
|
|
"""Parse: for (init; cond; incr) { body }"""
|
|
# Find parenthesized header
|
|
p_start = source.index('(', pos)
|
|
p_end = self._find_matching_paren(source, p_start)
|
|
header = source[p_start+1:p_end]
|
|
|
|
parts = header.split(';')
|
|
if len(parts) != 3:
|
|
raise HIDScriptError("Invalid for loop header")
|
|
init_expr = parts[0].strip()
|
|
cond_expr = parts[1].strip()
|
|
incr_expr = parts[2].strip()
|
|
|
|
# Remove var/let prefix from init
|
|
for prefix in ('var ', 'let '):
|
|
if init_expr.startswith(prefix):
|
|
init_expr = init_expr[len(prefix):]
|
|
|
|
# Find body
|
|
body_start = self._skip_ws(source, p_end + 1)
|
|
if body_start < len(source) and source[body_start] == '{':
|
|
body_end = self._find_matching_brace(source, body_start)
|
|
body = source[body_start+1:body_end]
|
|
next_pos = body_end + 1
|
|
else:
|
|
semi = source.find(';', body_start)
|
|
if semi == -1:
|
|
semi = len(source)
|
|
body = source[body_start:semi]
|
|
next_pos = semi + 1
|
|
|
|
return {
|
|
"type": "for",
|
|
"init": init_expr,
|
|
"cond": cond_expr,
|
|
"incr": incr_expr,
|
|
"body": body,
|
|
}, next_pos
|
|
|
|
def _parse_while(self, source, pos):
|
|
"""Parse: while (cond) { body }"""
|
|
p_start = source.index('(', pos)
|
|
p_end = self._find_matching_paren(source, p_start)
|
|
cond = source[p_start+1:p_end].strip()
|
|
|
|
body_start = self._skip_ws(source, p_end + 1)
|
|
if body_start < len(source) and source[body_start] == '{':
|
|
body_end = self._find_matching_brace(source, body_start)
|
|
body = source[body_start+1:body_end]
|
|
next_pos = body_end + 1
|
|
else:
|
|
semi = source.find(';', body_start)
|
|
if semi == -1:
|
|
semi = len(source)
|
|
body = source[body_start:semi]
|
|
next_pos = semi + 1
|
|
|
|
return {"type": "while", "cond": cond, "body": body}, next_pos
|
|
|
|
def _parse_if(self, source, pos):
|
|
"""Parse: if (cond) { body } [else { body }]"""
|
|
p_start = source.index('(', pos)
|
|
p_end = self._find_matching_paren(source, p_start)
|
|
cond = source[p_start+1:p_end].strip()
|
|
|
|
body_start = self._skip_ws(source, p_end + 1)
|
|
if body_start < len(source) and source[body_start] == '{':
|
|
body_end = self._find_matching_brace(source, body_start)
|
|
body = source[body_start+1:body_end]
|
|
next_pos = body_end + 1
|
|
else:
|
|
semi = source.find(';', body_start)
|
|
if semi == -1:
|
|
semi = len(source)
|
|
body = source[body_start:semi]
|
|
next_pos = semi + 1
|
|
|
|
# Check for else
|
|
else_body = None
|
|
check = self._skip_ws(source, next_pos)
|
|
if source[check:check+4] == 'else':
|
|
after_else = self._skip_ws(source, check + 4)
|
|
if after_else < len(source) and source[after_else] == '{':
|
|
eb_end = self._find_matching_brace(source, after_else)
|
|
else_body = source[after_else+1:eb_end]
|
|
next_pos = eb_end + 1
|
|
elif source[after_else:after_else+2] == 'if':
|
|
# else if — parse recursively
|
|
inner_if, next_pos = self._parse_if(source, after_else)
|
|
else_body = inner_if # will be a dict, handle in exec
|
|
else:
|
|
semi = source.find(';', after_else)
|
|
if semi == -1:
|
|
semi = len(source)
|
|
else_body = source[after_else:semi]
|
|
next_pos = semi + 1
|
|
|
|
return {"type": "if", "cond": cond, "body": body, "else": else_body}, next_pos
|
|
|
|
# ── Executor ───────────────────────────────────────────────
|
|
|
|
def _exec_stmts(self, stmts: list):
|
|
"""Execute a list of parsed statements."""
|
|
for stmt in stmts:
|
|
if self._stop.is_set():
|
|
return
|
|
stype = stmt["type"]
|
|
|
|
if stype == "assign":
|
|
self._vars[stmt["name"]] = self._eval_expr(stmt["expr"])
|
|
|
|
elif stype == "expr":
|
|
self._eval_expr(stmt["expr"])
|
|
|
|
elif stype == "for":
|
|
self._exec_for(stmt)
|
|
|
|
elif stype == "while":
|
|
self._exec_while(stmt)
|
|
|
|
elif stype == "if":
|
|
self._exec_if(stmt)
|
|
|
|
def _exec_for(self, stmt):
|
|
"""Execute a for loop."""
|
|
# Parse init as assignment
|
|
init = stmt["init"]
|
|
eq = init.find('=')
|
|
if eq >= 0:
|
|
name = init[:eq].strip()
|
|
self._vars[name] = self._eval_expr(init[eq+1:].strip())
|
|
|
|
max_iterations = 100000
|
|
i = 0
|
|
while i < max_iterations:
|
|
if self._stop.is_set():
|
|
return
|
|
if not self._eval_expr(stmt["cond"]):
|
|
break
|
|
self._exec_stmts(self._parse_block(stmt["body"]))
|
|
# Execute increment
|
|
incr = stmt["incr"]
|
|
if "++" in incr:
|
|
var_name = incr.replace("++", "").strip()
|
|
self._vars[var_name] = self._vars.get(var_name, 0) + 1
|
|
elif "--" in incr:
|
|
var_name = incr.replace("--", "").strip()
|
|
self._vars[var_name] = self._vars.get(var_name, 0) - 1
|
|
else:
|
|
eq = incr.find('=')
|
|
if eq >= 0:
|
|
name = incr[:eq].strip()
|
|
self._vars[name] = self._eval_expr(incr[eq+1:].strip())
|
|
i += 1
|
|
|
|
def _exec_while(self, stmt):
|
|
"""Execute a while loop."""
|
|
max_iterations = 1000000
|
|
i = 0
|
|
while i < max_iterations:
|
|
if self._stop.is_set():
|
|
return
|
|
if not self._eval_expr(stmt["cond"]):
|
|
break
|
|
self._exec_stmts(self._parse_block(stmt["body"]))
|
|
i += 1
|
|
|
|
def _exec_if(self, stmt):
|
|
"""Execute an if/else statement."""
|
|
if self._eval_expr(stmt["cond"]):
|
|
self._exec_stmts(self._parse_block(stmt["body"]))
|
|
elif stmt.get("else"):
|
|
else_part = stmt["else"]
|
|
if isinstance(else_part, dict):
|
|
# else if
|
|
self._exec_if(else_part)
|
|
else:
|
|
self._exec_stmts(self._parse_block(else_part))
|
|
|
|
# ── Expression Evaluator ───────────────────────────────────
|
|
|
|
def _eval_expr(self, expr):
|
|
"""Evaluate an expression string and return its value."""
|
|
if isinstance(expr, (int, float, bool)):
|
|
return expr
|
|
if not isinstance(expr, str):
|
|
return expr
|
|
|
|
expr = expr.strip()
|
|
if not expr:
|
|
return None
|
|
|
|
# String literal
|
|
if (expr.startswith('"') and expr.endswith('"')) or \
|
|
(expr.startswith("'") and expr.endswith("'")):
|
|
return self._unescape(expr[1:-1])
|
|
|
|
# Numeric literal
|
|
try:
|
|
if '.' in expr:
|
|
return float(expr)
|
|
return int(expr)
|
|
except ValueError:
|
|
pass
|
|
|
|
# Boolean / null
|
|
if expr == 'true':
|
|
return True
|
|
if expr == 'false':
|
|
return False
|
|
if expr == 'null':
|
|
return None
|
|
|
|
# String concatenation with +
|
|
if self._has_top_level_op(expr, '+') and self._contains_string(expr):
|
|
parts = self._split_top_level(expr, '+')
|
|
result = ""
|
|
for p in parts:
|
|
val = self._eval_expr(p.strip())
|
|
result += str(val) if val is not None else ""
|
|
return result
|
|
|
|
# Comparison operators
|
|
for op in ['===', '!==', '==', '!=', '>=', '<=', '>', '<']:
|
|
if self._has_top_level_op(expr, op):
|
|
parts = self._split_top_level(expr, op, max_splits=1)
|
|
if len(parts) == 2:
|
|
left = self._eval_expr(parts[0].strip())
|
|
right = self._eval_expr(parts[1].strip())
|
|
if op in ('==', '==='):
|
|
return left == right
|
|
elif op in ('!=', '!=='):
|
|
return left != right
|
|
elif op == '>':
|
|
return left > right
|
|
elif op == '<':
|
|
return left < right
|
|
elif op == '>=':
|
|
return left >= right
|
|
elif op == '<=':
|
|
return left <= right
|
|
|
|
# Logical operators
|
|
if self._has_top_level_op(expr, '&&'):
|
|
parts = self._split_top_level(expr, '&&', max_splits=1)
|
|
return self._eval_expr(parts[0]) and self._eval_expr(parts[1])
|
|
if self._has_top_level_op(expr, '||'):
|
|
parts = self._split_top_level(expr, '||', max_splits=1)
|
|
return self._eval_expr(parts[0]) or self._eval_expr(parts[1])
|
|
|
|
# Arithmetic
|
|
for op in ['+', '-']:
|
|
if self._has_top_level_op(expr, op) and not self._contains_string(expr):
|
|
parts = self._split_top_level(expr, op)
|
|
result = self._eval_expr(parts[0].strip())
|
|
for p in parts[1:]:
|
|
val = self._eval_expr(p.strip())
|
|
if op == '+':
|
|
result = (result or 0) + (val or 0)
|
|
else:
|
|
result = (result or 0) - (val or 0)
|
|
return result
|
|
|
|
for op in ['*', '/']:
|
|
if self._has_top_level_op(expr, op):
|
|
parts = self._split_top_level(expr, op)
|
|
result = self._eval_expr(parts[0].strip())
|
|
for p in parts[1:]:
|
|
val = self._eval_expr(p.strip())
|
|
if op == '*':
|
|
result = (result or 0) * (val or 0)
|
|
else:
|
|
result = (result or 0) / (val or 1)
|
|
return result
|
|
|
|
# Modulo
|
|
if self._has_top_level_op(expr, '%'):
|
|
parts = self._split_top_level(expr, '%')
|
|
result = self._eval_expr(parts[0].strip())
|
|
for p in parts[1:]:
|
|
val = self._eval_expr(p.strip())
|
|
result = (result or 0) % (val or 1)
|
|
return result
|
|
|
|
# Negation
|
|
if expr.startswith('!'):
|
|
return not self._eval_expr(expr[1:])
|
|
|
|
# Parenthesized expression
|
|
if expr.startswith('(') and self._find_matching_paren(expr, 0) == len(expr) - 1:
|
|
return self._eval_expr(expr[1:-1])
|
|
|
|
# Function call
|
|
m = re.match(r'^([a-zA-Z_][\w.]*)\s*\(', expr)
|
|
if m:
|
|
func_name = m.group(1)
|
|
p_start = expr.index('(')
|
|
p_end = self._find_matching_paren(expr, p_start)
|
|
args_str = expr[p_start+1:p_end]
|
|
args = self._parse_args(args_str)
|
|
return self._call_func(func_name, args)
|
|
|
|
# Variable reference
|
|
if re.match(r'^[a-zA-Z_]\w*$', expr):
|
|
return self._vars.get(expr, 0)
|
|
|
|
# Increment/decrement as expression
|
|
if expr.endswith('++'):
|
|
name = expr[:-2].strip()
|
|
val = self._vars.get(name, 0)
|
|
self._vars[name] = val + 1
|
|
return val
|
|
if expr.endswith('--'):
|
|
name = expr[:-2].strip()
|
|
val = self._vars.get(name, 0)
|
|
self._vars[name] = val - 1
|
|
return val
|
|
|
|
logger.warning("Cannot evaluate expression: %r", expr)
|
|
return 0
|
|
|
|
# ── Built-in Functions ─────────────────────────────────────
|
|
|
|
def _call_func(self, name: str, args: list):
|
|
"""Dispatch a built-in function call."""
|
|
# Evaluate all arguments
|
|
evaled = [self._eval_expr(a) for a in args]
|
|
|
|
if name == "type":
|
|
text = str(evaled[0]) if evaled else ""
|
|
self.hid.type_string(text, stop_event=self._stop)
|
|
|
|
elif name == "press":
|
|
combo = str(evaled[0]) if evaled else ""
|
|
self.hid.press_combo(combo)
|
|
|
|
elif name == "delay":
|
|
ms = int(evaled[0]) if evaled else 0
|
|
if ms > 0:
|
|
self._stop.wait(ms / 1000.0)
|
|
|
|
elif name == "layout":
|
|
name_val = str(evaled[0]) if evaled else self._default_layout
|
|
self.hid.set_layout(name_val)
|
|
|
|
elif name == "typingSpeed":
|
|
min_ms = int(evaled[0]) if len(evaled) > 0 else 0
|
|
max_ms = int(evaled[1]) if len(evaled) > 1 else min_ms
|
|
self.hid.set_typing_speed(min_ms, max_ms)
|
|
|
|
elif name == "move":
|
|
x = int(evaled[0]) if len(evaled) > 0 else 0
|
|
y = int(evaled[1]) if len(evaled) > 1 else 0
|
|
self.hid.mouse_move(x, y)
|
|
|
|
elif name == "moveTo":
|
|
x = int(evaled[0]) if len(evaled) > 0 else 0
|
|
y = int(evaled[1]) if len(evaled) > 1 else 0
|
|
self.hid.mouse_move_stepped(x, y, step=5)
|
|
|
|
elif name == "moveStepped":
|
|
x = int(evaled[0]) if len(evaled) > 0 else 0
|
|
y = int(evaled[1]) if len(evaled) > 1 else 0
|
|
step = int(evaled[2]) if len(evaled) > 2 else 10
|
|
self.hid.mouse_move_stepped(x, y, step=step)
|
|
|
|
elif name == "click":
|
|
btn = int(evaled[0]) if evaled else BT1
|
|
self.hid.mouse_click(btn)
|
|
|
|
elif name == "doubleClick":
|
|
btn = int(evaled[0]) if evaled else BT1
|
|
self.hid.mouse_double_click(btn)
|
|
|
|
elif name == "button":
|
|
mask = int(evaled[0]) if evaled else 0
|
|
self.hid.send_mouse_report(mask, 0, 0)
|
|
|
|
elif name == "waitLED":
|
|
mask = int(evaled[0]) if evaled else ANY
|
|
timeout = float(evaled[1]) / 1000 if len(evaled) > 1 else 0
|
|
return self.hid.wait_led(mask, self._stop, timeout)
|
|
|
|
elif name == "waitLEDRepeat":
|
|
mask = int(evaled[0]) if evaled else ANY
|
|
count = int(evaled[1]) if len(evaled) > 1 else 1
|
|
return self.hid.wait_led_repeat(mask, count, self._stop)
|
|
|
|
elif name == "console.log" or name == "log":
|
|
msg = " ".join(str(a) for a in evaled)
|
|
self._output.append(msg)
|
|
logger.debug("[HIDScript] %s", msg)
|
|
|
|
elif name in ("parseInt", "Number"):
|
|
try:
|
|
return int(float(evaled[0])) if evaled else 0
|
|
except (ValueError, TypeError):
|
|
return 0
|
|
|
|
elif name == "String":
|
|
return str(evaled[0]) if evaled else ""
|
|
|
|
elif name == "Math.random":
|
|
import random
|
|
return random.random()
|
|
|
|
elif name == "Math.floor":
|
|
import math
|
|
return math.floor(evaled[0]) if evaled else 0
|
|
|
|
else:
|
|
logger.warning("Unknown function: %s", name)
|
|
return None
|
|
|
|
return None
|
|
|
|
# ── Helpers ────────────────────────────────────────────────
|
|
|
|
def _parse_args(self, args_str: str) -> list:
|
|
"""Split function arguments respecting string literals and parens."""
|
|
args = []
|
|
depth = 0
|
|
current = ""
|
|
in_str = None
|
|
|
|
for ch in args_str:
|
|
if in_str:
|
|
current += ch
|
|
if ch == in_str and (len(current) < 2 or current[-2] != '\\'):
|
|
in_str = None
|
|
elif ch in ('"', "'"):
|
|
in_str = ch
|
|
current += ch
|
|
elif ch == '(':
|
|
depth += 1
|
|
current += ch
|
|
elif ch == ')':
|
|
depth -= 1
|
|
current += ch
|
|
elif ch == ',' and depth == 0:
|
|
if current.strip():
|
|
args.append(current.strip())
|
|
current = ""
|
|
else:
|
|
current += ch
|
|
|
|
if current.strip():
|
|
args.append(current.strip())
|
|
return args
|
|
|
|
def _unescape(self, s: str) -> str:
|
|
"""Process escape sequences in a string."""
|
|
return s.replace('\\n', '\n').replace('\\t', '\t').replace('\\r', '\r') \
|
|
.replace('\\"', '"').replace("\\'", "'").replace('\\\\', '\\')
|
|
|
|
def _skip_ws(self, source: str, pos: int) -> int:
|
|
"""Skip whitespace."""
|
|
while pos < len(source) and source[pos] in ' \t\n\r':
|
|
pos += 1
|
|
return pos
|
|
|
|
def _find_matching_brace(self, source: str, pos: int) -> int:
|
|
"""Find matching } for { at pos."""
|
|
depth = 1
|
|
i = pos + 1
|
|
in_str = None
|
|
while i < len(source):
|
|
ch = source[i]
|
|
if in_str:
|
|
if ch == in_str and source[i-1] != '\\':
|
|
in_str = None
|
|
elif ch in ('"', "'"):
|
|
in_str = ch
|
|
elif ch == '{':
|
|
depth += 1
|
|
elif ch == '}':
|
|
depth -= 1
|
|
if depth == 0:
|
|
return i
|
|
i += 1
|
|
return len(source) - 1
|
|
|
|
def _find_matching_paren(self, source: str, pos: int) -> int:
|
|
"""Find matching ) for ( at pos."""
|
|
depth = 1
|
|
i = pos + 1
|
|
in_str = None
|
|
while i < len(source):
|
|
ch = source[i]
|
|
if in_str:
|
|
if ch == in_str and source[i-1] != '\\':
|
|
in_str = None
|
|
elif ch in ('"', "'"):
|
|
in_str = ch
|
|
elif ch == '(':
|
|
depth += 1
|
|
elif ch == ')':
|
|
depth -= 1
|
|
if depth == 0:
|
|
return i
|
|
i += 1
|
|
return len(source) - 1
|
|
|
|
def _has_top_level_op(self, expr: str, op: str) -> bool:
|
|
"""Check if operator exists at top level (not inside parens/strings)."""
|
|
depth = 0
|
|
in_str = None
|
|
i = 0
|
|
while i < len(expr):
|
|
ch = expr[i]
|
|
if in_str:
|
|
if ch == in_str and (i == 0 or expr[i-1] != '\\'):
|
|
in_str = None
|
|
elif ch in ('"', "'"):
|
|
in_str = ch
|
|
elif ch == '(':
|
|
depth += 1
|
|
elif ch == ')':
|
|
depth -= 1
|
|
elif depth == 0 and expr[i:i+len(op)] == op:
|
|
# Don't match multi-char ops that are substrings of longer ones
|
|
if len(op) == 1 and op in '+-':
|
|
# Skip if part of ++ or --
|
|
if i + 1 < len(expr) and expr[i+1] == op:
|
|
i += 2
|
|
continue
|
|
if i > 0 and expr[i-1] == op:
|
|
i += 1
|
|
continue
|
|
return True
|
|
i += 1
|
|
return False
|
|
|
|
def _split_top_level(self, expr: str, op: str, max_splits: int = -1) -> list:
|
|
"""Split expression by operator at top level only."""
|
|
parts = []
|
|
depth = 0
|
|
in_str = None
|
|
current = ""
|
|
i = 0
|
|
splits = 0
|
|
|
|
while i < len(expr):
|
|
ch = expr[i]
|
|
if in_str:
|
|
current += ch
|
|
if ch == in_str and (i == 0 or expr[i-1] != '\\'):
|
|
in_str = None
|
|
elif ch in ('"', "'"):
|
|
in_str = ch
|
|
current += ch
|
|
elif ch == '(':
|
|
depth += 1
|
|
current += ch
|
|
elif ch == ')':
|
|
depth -= 1
|
|
current += ch
|
|
elif depth == 0 and expr[i:i+len(op)] == op and (max_splits < 0 or splits < max_splits):
|
|
# Don't split on ++ or -- when looking for + or -
|
|
if len(op) == 1 and op in '+-':
|
|
if i + 1 < len(expr) and expr[i+1] == op:
|
|
current += ch
|
|
i += 1
|
|
current += expr[i]
|
|
i += 1
|
|
continue
|
|
parts.append(current)
|
|
current = ""
|
|
i += len(op)
|
|
splits += 1
|
|
continue
|
|
else:
|
|
current += ch
|
|
i += 1
|
|
|
|
parts.append(current)
|
|
return parts
|
|
|
|
def _contains_string(self, expr: str) -> bool:
|
|
"""Check if expression contains a string literal at top level."""
|
|
depth = 0
|
|
in_str = None
|
|
for ch in expr:
|
|
if in_str:
|
|
if ch == in_str:
|
|
return True # Found complete string
|
|
elif ch in ('"', "'"):
|
|
in_str = ch
|
|
elif ch == '(':
|
|
depth += 1
|
|
elif ch == ')':
|
|
depth -= 1
|
|
return False
|