/*
* HexDroidIRC - An IRC Client for Android
* Copyright (C) 2026 boxlabs
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.boxlabs.hexdroid.script
/**
* HexDroid's scripting interpreter, a custom mIRC-flavoured DSL, implementing [ScriptBackend] so
* it implements the same ScriptBackend interface (the engine is backend-neutral):
* ScriptEngine(host, HexScriptBackend())
*
* Runs on Main, synchronously, See the `.hex` language spec.
*
* this is v1 of the interpreter.
*/
class HexScriptBackend : ScriptBackend {
override val scriptExtension
: String = "hex"
private lateinit var cb: EngineCallbacks
private var budget: Budget = Budget(200_000, 200)
/** Global %vars persist across events within a session; cleared on reset(). */
private val globals
= HashMap
<String, HexVal
>()
/** User-defined aliases, kept locally so they can be invoked as value functions $name(...). */
private val aliases
= HashMap
<String, HexBlock
>()
override fun start(callbacks: EngineCallbacks, budget: Budget) {
cb = callbacks; this.budget = budget
}
override fun reset() { globals.clear(); aliases.clear() }
override fun shutdown() { globals.clear() }
val blocks = try {
HexParser(source).parse()
} catch (e: HexError) {
val msg = "parse error: ${e.message}"; cb.log("[$name] $msg"); return msg
}
for (b in blocks) when (b.kind) {
HexBlock.Kind.EVENT -> cb.registerEvent(b.name, b) // engine uppercases the key
HexBlock.Kind.ALIAS -> { aliases[b.name.lowercase()] = b; cb.registerCommand(b.name.lowercase(), b) }
}
return null
}
// ScriptBackend dispatch
override fun dispatchTransform(handlers: List<Any>, event: EventData): TextResult {
var text = event.text
var halted = false
for (h in handlers) {
val block = h as? HexBlock ?: continue
if (!filterMatches(block, text)) continue
val env = envForEvent(event.copy(text = text), cb)
val flow = runBody(block.body, env)
text = env.fields["text"] ?: text // `rewrite` updates env text
if (flow == Flow.HALT) { halted = true; break }
}
return TextResult(text, halted)
}
override fun dispatchNotify(handlers: List<Any>, event: EventData) {
for (h in handlers) {
val block = h as? HexBlock ?: continue
if (!filterMatches(block, event.text)) continue
runBody(block.body, envForEvent(event, cb))
}
}
val block = handler as? HexBlock ?: return
val event = EventData(
network = network ?: "", buffer = buffer ?: "", text = args,
args = if (args.isBlank()) emptyList() else args.trim().split(Regex("\\s+")),
)
runBody(block.body, envForEvent(event, cb))
}
private fun filterMatches
(block
: HexBlock, text
: String): Boolean {
val f = block.filter ?: return true
return glob(f, text)
}
// evaluator
private enum class Flow { NORMAL, BREAK, CONTINUE, HALT, RETURN }
/** Shared per-top-level-dispatch budget/recursion state (one Frame across nested calls). */
private inner
class Frame {
var steps = 0
val deadline
= System.
currentTimeMillis() + budget.
maxMillis
var depth = 0
}
/** Per-call scope. Nested function calls share the [frame] (so one budget bounds the whole tree). */
private inner class Env(
val fields
: HashMap
<String, String
>,
val args: List<String>,
val locals
: HashMap
<String, HexVal
>,
) {
var returnValue: HexVal? = null
fun tick() {
if (++frame.steps > budget.maxInstructions) throw HexAbort("step budget exceeded")
if (System.
currentTimeMillis() > frame.
deadline) throw HexAbort
("time budget exceeded")
}
/** A child scope for a function call: fresh locals + bound args, shared frame/fields. */
fun childForCall
(callArgs
: List
<String
>): Env
= Env
(frame, fields, callArgs,
HashMap())
}
private fun runBody(body: List<HexStmt>, env: Env): Flow {
for (s in body) {
env.tick()
when (s) {
is HexStmt.Command -> if (execCommand(s, env) == Flow.HALT) return Flow.HALT
is HexStmt.If -> {
var ran = false
for ((cond, b) in s.branches) {
if (evalCond(cond, env)) { val fl = runBody(b, env); if (fl != Flow.NORMAL) return fl; ran = true; break }
}
if (!ran && s.elseBody != null) { val fl = runBody(s.elseBody, env); if (fl != Flow.NORMAL) return fl }
}
is HexStmt.While -> {
while (evalCond(s.cond, env)) {
env.tick()
when (runBody(s.body, env)) {
Flow.BREAK -> break
Flow.CONTINUE, Flow.NORMAL -> {}
Flow.HALT -> return Flow.HALT
Flow.RETURN -> return Flow.RETURN
}
}
}
is HexStmt.ViewStmt -> {
val tree
= try { HexViewParser
(renderViewBody
(s.
rawBody, env
)).
parse() } catch (_
: Throwable) { null }
if (tree != null) cb.mountView(tree)
}
is HexStmt.ForEach -> {
val coll = evalVal(s.collExpr, env)
val items: List<HexVal> = when (coll) {
is HexVal.Lst -> coll.items.toList()
is HexVal.Mp -> coll.map.keys.map { HexVal.Str(it) }
is HexVal.Str -> if (coll.s.isEmpty()) emptyList() else coll.s.split(" ").map { HexVal.Str(it) }
}
val nm = varName(s.itemVar)
loop@ for (it in items) {
env.tick(); env.locals[nm] = it
when (runBody(s.body, env)) {
Flow.BREAK -> break@loop
Flow.CONTINUE, Flow.NORMAL -> {}
Flow.HALT -> return Flow.HALT
Flow.RETURN -> return Flow.RETURN
}
}
}
HexStmt.Break -> return Flow.BREAK
HexStmt.Continue -> return Flow.CONTINUE
HexStmt.Halt -> return Flow.HALT
is HexStmt.Return -> { if (s.expr != null) env.returnValue = evalVal(s.expr, env); return Flow.RETURN }
}
}
return Flow.NORMAL
}
private fun execCommand(c: HexStmt.Command, env: Env): Flow {
val verb = c.verb.lowercase()
val raw = c.rawArgs
when (verb) {
"set" -> doSet(raw, env)
"unset" -> { val n = varName(expand(raw, env).trim()); env.locals.remove(n); globals.remove(n) }
"inc" -> bumpVar(raw, env, +1)
"dec" -> bumpVar(raw, env, -1)
"push" -> { val (nm, rest) = head(raw); varRef(varName(nm), env)?.let { HexValues.push(it, evalVal(rest, env)) } }
"setat" -> doSetAt(raw, env)
"rewrite" -> env.fields["text"] = expand(raw, env)
"echo" -> { val (t, txt) = splitTarget(expand(raw, env)); cb.echo(t, null, txt) }
"msg" -> { val (t, txt) = splitTarget(expand(raw, env)); if (t != null) cb.sendMessage(t, txt) }
"raw" -> cb.sendRaw(expand(raw, env))
"signal" -> { val a = expand(raw, env).trim().split(' '); if (a.isNotEmpty()) cb.raiseEvent("SIGNAL:${a[0].uppercase()}", emptyMap(), a.drop(1)) }
"timer" -> doTimer(raw, env)
"toast", "decorate", "action", "sidebar" -> cb.uiIntent(verb, expand(raw, env).trim().split(' '))
"http.get" -> doHttp(raw, env, post = false)
"http.post" -> doHttp(raw, env, post = true)
else -> when {
verb.startsWith("age.") || verb.startsWith("media.") ->
cb.capability(verb, expand(raw, env).trim().split(' ').filter { it.isNotEmpty() })
aliases.containsKey(verb) -> return runAliasCommand(verb, raw, env)
else -> cb.appCommand("${c.verb} ${expand(raw, env)}".trim()) // unknown -> slash pipeline
}
}
return Flow.NORMAL
}
/** Invoke a user alias as a statement: bind space-separated args to $1.., run body. */
private fun runAliasCommand
(verb
: String, raw
: String, env
: Env
): Flow
{
val block = aliases[verb] ?: return Flow.NORMAL
if (env.frame.depth >= MAX_CALL_DEPTH) throw HexAbort("call depth exceeded")
val argExprs = splitTop(raw.trim(), " ").map { it.trim() }.filter { it.isNotEmpty() }
val callArgs = argExprs.map { evalVal(it, env).asStr() }
val child = env.childForCall(callArgs)
env.frame.depth++
val fl = try { runBody(block.body, child) } finally { env.frame.depth-- }
return if (fl == Flow.HALT) Flow.HALT else Flow.NORMAL
}
private fun doSet
(raw
: String, env
: Env
) {
var s = raw.trim(); var local = false
if (s.startsWith("-l ")) { local = true; s = s.removePrefix("-l ").trim() }
val sp = s.indexOf(' ')
val name = varName(if (sp < 0) s else s.substring(0, sp))
val value = if (sp < 0) HexVal.EMPTY else evalVal(s.substring(sp + 1), env)
if (local) env.locals[name] = value else globals[name] = value
}
private fun bumpVar
(raw
: String, env
: Env, by
: Int
) {
val name = varName(expand(raw, env).trim())
val cur = (varRef(name, env)?.asNum() ?: 0.0)
val nv = HexVal.num(cur + by)
if (env.locals.containsKey(name)) env.locals[name] = nv else globals[name] = nv
}
private fun doTimer
(raw
: String, env
: Env
) {
val a = expand(raw, env).trim().split(' ')
if (a.size < 2) return
val ms = a[0].toLongOrNull() ?: return
cb.scheduleSignal(ms, a[1].uppercase(), a.drop(2))
}
/** http.get <url> <signal> [ctx...] / http.post <url> <body> <signal> [ctx...] */
val parts = expand(raw, env).trim().split(' ')
if (post && parts.size < 3) return
if (!post && parts.size < 2) return
val url = parts[0]
val onDone: (HttpResult) -> Unit = { res ->
// raised on Main by the engine; surface body/status + passthrough ctx as $1-
val ctxStart = if (post) 3 else 2
val sigName = (if (post) parts[2] else parts[1]).uppercase()
val fields = mapOf(
"httpok" to res.ok.toString(), "httpstatus" to res.status.toString(), "httpbody" to res.body,
)
cb.raiseEvent("SIGNAL:$sigName", fields, parts.drop(ctxStart))
}
if (post) cb.httpPost(url, parts[1], onDone) else cb.httpGet(url, onDone)
}
// conditions
// v1: left-to-right || then &&; no nested parens inside the condition.
for (orPart in splitTop(cond, "||")) {
if (splitTop(orPart, "&&").all { evalComparison(it.trim(), env) }) return true
}
return false
}
var e = expr.trim(); var negate = false
if (e.startsWith("!")) { negate = true; e = e.substring(1).trim() }
val ops = listOf("==", "!=", "<=", ">=", "<", ">", "isin", "iswm")
for (op in ops) {
val pat = if (op[0].isLetter()) " $op " else op
val i = e.indexOf(pat)
if (i >= 0) {
val l = expand(e.substring(0, i).trim(), env)
val r = expand(e.substring(i + pat.length).trim(), env)
val res = compare(l, r, op)
return res != negate
}
}
// bare value: truthy if non-empty and not "false"/"0"
val v = expand(e, env).trim()
val truthy = v.isNotEmpty() && v != "false" && v != "0"
return truthy != negate
}
val ln = l.toDoubleOrNull(); val rn = r.toDoubleOrNull()
return when (op) {
"==" -> l == r
"!=" -> l != r
"isin" -> r.contains(l)
"iswm" -> glob(r, l)
"<" -> if (ln != null && rn != null) ln < rn else l < r
">" -> if (ln != null && rn != null) ln > rn else l > r
"<=" -> if (ln != null && rn != null) ln <= rn else l <= r
">=" -> if (ln != null && rn != null) ln >= rn else l >= r
else -> false
}
}
// substitution
/** Expand $... and %... in [s]. */
val out = StringBuilder(s.length + 16)
var i = 0
while (i < s.length) {
val ch = s[i]
when {
ch == '$' && i + 1 < s.length && s[i + 1] == '$' -> { out.append('$'); i += 2 }
ch == '%' && i + 1 < s.length && s[i + 1] == '%' -> { out.append('%'); i += 2 }
ch == '%' -> { val (name, n) = readIdent(s, i + 1); out.append(varRef(name, env)?.asStr() ?: ""); i = n }
ch == '$' -> { val (val0, n) = readDollar(s, i + 1, env); out.append(val0); i = n }
else -> { out.append(ch); i++ }
}
}
return out.toString()
}
private fun readIdent
(s
: String, start
: Int
): Pair
<String, Int
> {
var i = start
while (i < s.length && (s[i].isLetterOrDigit() || s[i] == '_' || s[i] == '.')) i++
return s.substring(start, i) to i
}
private fun readDollar
(s
: String, start
: Int, env
: Env
): Pair
<String, Int
> {
// positional: $0, $N, $N-, $N-M
if (start < s.length && s[start].isDigit()) {
var i = start
while (i < s.length && s[i].isDigit()) i++
val first = s.substring(start, i).toInt()
if (i < s.length && s[i] == '-') {
i++
if (i < s.length && s[i].isDigit()) {
val js = i; while (i < s.length && s[i].isDigit()) i++
val last = s.substring(js, i).toInt()
return positional(env, first, last) to i
}
return positional(env, first, env.args.size) to i // $N-
}
return positional(env, first, first) to i // $N
}
// identifier, optionally with (args)
val (name, after) = readIdent(s, start)
if (after < s.length && s[after] == '(') {
val close = matchParen(s, after)
val inner = s.substring(after + 1, close)
val argExprs = if (inner.isBlank()) emptyList() else splitTop(inner, ",").map { it.trim() }
return callIdVal(name, argExprs, env).asStr() to close + 1
}
if (name.startsWith("age.") || name.startsWith("media.")) return cb.capability(name, emptyList()) to after
if (aliases.containsKey(name.lowercase())) return (callUser(name, emptyList(), env)?.asStr() ?: "") to after
if (name == "0") return env.args.size.toString() to after
return (env.fields[name] ?: "") to after
}
private fun positional
(env
: Env, from
: Int, to
: Int
): String {
if (from == 0) return env.args.size.toString()
if (from < 1 || from > env.args.size) return ""
val end = minOf(to, env.args.size)
return env.args.subList(from - 1, end).joinToString(" ")
}
/** Closed built-in identifier set (spec §2). */
private fun builtin
(name
: String, a
: List
<String
>, env
: Env
): String = when
(name
) {
"len" -> (a.getOrNull(0)?.length ?: 0).toString()
"lower" -> a.getOrNull(0)?.lowercase() ?: ""
"upper" -> a.getOrNull(0)?.uppercase() ?: ""
"left" -> a.getOrNull(0)?.take(a.getOrNull(1)?.toIntOrNull() ?: 0) ?: ""
"right" -> a.getOrNull(0)?.takeLast(a.getOrNull(1)?.toIntOrNull() ?: 0) ?: ""
"replace" -> (a.getOrNull(0) ?: "").replace(a.getOrNull(1) ?: "", a.getOrNull(2) ?: "")
"trim" -> (a.getOrNull(0) ?: "").trim()
"contains" -> (a.getOrNull(0) ?: "").contains(a.getOrNull(1) ?: "").toString()
"indexof" -> (a.getOrNull(0) ?: "").indexOf(a.getOrNull(1) ?: "").toString()
"substr" -> { val t = a.getOrNull(0) ?: ""; val st = (a.getOrNull(1)?.toIntOrNull() ?: 0).coerceIn(0, t.length); val ln = a.getOrNull(2)?.toIntOrNull() ?: (t.length - st); t.substring(st, (st + ln).coerceIn(st, t.length)) }
"repeat" -> (a.getOrNull(0) ?: "").repeat((a.getOrNull(1)?.toIntOrNull() ?: 0).coerceIn(0, 1000))
"abs" -> HexVal.num(kotlin.math.abs(a.getOrNull(0)?.toDoubleOrNull() ?: 0.0)).asStr()
"min" -> HexVal.num(a.mapNotNull { it.toDoubleOrNull() }.minOrNull() ?: 0.0).asStr()
"max" -> HexVal.num(a.mapNotNull { it.toDoubleOrNull() }.maxOrNull() ?: 0.0).asStr()
"setting" -> cb.setting(a.getOrNull(0) ?: "") ?: ""
"json" -> jsonFlat(a.getOrNull(0) ?: "", a.getOrNull(1) ?: "")
else -> "" // unknown identifier => empty (closed set)
}
/** Flat top-level JSON string extractor: $json(body, key). v1 = flat keys only. */
val m = Regex("\"" + Regex.escape(key) + "\"\\s*:\\s*\"((?:[^\"\\\\]|\\\\.)*)\"").find(body)
return m?.groupValues?.get(1)?.replace("\\\"", "\"")?.replace("\\\\", "\\") ?: ""
}
private val MAX_CALL_DEPTH = 64
/** Invoke a user alias as a value function: bind args to $1.., run body, yield `return`. */
private fun callUser
(name
: String, argExprs
: List
<String
>, env
: Env
): HexVal
? {
val block = aliases[name.lowercase()] ?: return null
if (env.frame.depth >= MAX_CALL_DEPTH) throw HexAbort("call depth exceeded")
val callArgs = argExprs.map { evalVal(it, env).asStr() }
val child = env.childForCall(callArgs)
env.frame.depth++
try { runBody(block.body, child) } finally { env.frame.depth-- }
return child.returnValue ?: HexVal.EMPTY
}
/** Build the root scope for an event dispatch (fresh Frame). */
private fun envForEvent(e: EventData, cb: EngineCallbacks): Env {
val f
= HashMap
<String, String
>()
f["network"] = e.network; f["buffer"] = e.buffer; f["chan"] = e.buffer
f["target"] = e.buffer; f["text"] = e.text
e.from?.let { f["nick"] = it }
f["me"] = cb.nick().orEmpty()
f["isme"] = e.isMine.toString()
f.putAll(e.fields)
val args = if (e.args.isNotEmpty()) e.args
else if (e.text.isBlank()) emptyList() else e.text.split(' ')
}
/**
* Render a `view{}` body to its final DSL text. Expands `foreach %x %coll { .. }`
* by repetition (so a script can lay out a data-driven list) and applies
* $/% substitution per iteration.
*/
val sb = StringBuilder()
var i = 0
while (i < s.length) {
val ctl = nextControl(s, i)
if (ctl == null) { sb.append(expand(s.substring(i), env)); break }
val (at, kw) = ctl
sb.append(expand(s.substring(i, at), env))
i = if (kw == "foreach") renderForeach(s, at, env, sb) else renderIf(s, at, env, sb)
}
return sb.toString()
}
/** Earliest word-boundary `foreach %x ..` or `if (..)` at/after [from]. */
private fun nextControl
(s
: String, from
: Int
): Pair
<Int, String
>? {
var best = -1; var kw = ""
for (k in listOf("foreach", "if")) {
var p = from
while (true) {
val idx = s.indexOf(k, p); if (idx < 0) break
val before = if (idx == 0) ' ' else s[idx - 1]
var a = idx + k.length
while (a < s.length && s[a].isWhitespace()) a++
val nextCh = if (a < s.length) s[a] else ' '
val ok = before.isWhitespace() && (if (k == "if") nextCh == '(' else nextCh == '%')
if (ok) { if (best < 0 || idx < best) { best = idx; kw = k }; break }
p = idx + k.length
}
}
return if (best < 0) null else best to kw
}
/** Inner of `{ .. }` from the next `{` at/after [from]; returns (inner, indexAfterClose). */
private fun readBraceBlock
(s
: String, from
: Int
): Pair
<String, Int
> {
var j = from
while (j < s.length && s[j] != '{') j++
if (j >= s.length) return "" to s.length
var depth = 0; val open = j
while (j < s.length) { if (s[j] == '{') depth++ else if (s[j] == '}') { depth--; if (depth == 0) { j++; break } }; j++ }
return s.substring(open + 1, j - 1) to j
}
/** Inner of `(..)` from the next `(` at/after [from]; returns (inner, indexAfterClose). */
private fun readParenGroup
(s
: String, from
: Int
): Pair
<String, Int
> {
var j = from
while (j < s.length && s[j] != '(') j++
if (j >= s.length) return "" to s.length
var depth = 0; val open = j
while (j < s.length) { if (s[j] == '(') depth++ else if (s[j] == ')') { depth--; if (depth == 0) { j++; break } }; j++ }
return s.substring(open + 1, j - 1) to j
}
private fun renderForeach
(s
: String, at
: Int, env
: Env, sb
: StringBuilder
): Int
{
var j = at + "foreach".length
fun skip() { while (j < s.length && s[j].isWhitespace()) j++ }
fun word
(): String { skip
(); val st
= j
; while (j
< s.
length && !s
[j
].
isWhitespace() && s
[j
] != '{') j
++; return s.
substring(st, j
) }
val itemVar = varName(word())
val collTok = word()
val (inner, after) = readBraceBlock(s, j)
val coll = evalVal(collTok, env)
val items: List<HexVal> = when (coll) {
is HexVal.Lst -> coll.items.toList()
is HexVal.Mp -> coll.map.keys.map { HexVal.Str(it) }
is HexVal.Str -> if (coll.s.isBlank()) emptyList() else coll.s.split(" ").map { HexVal.Str(it) }
}
val saved = env.locals[itemVar]
for (it in items) { env.tick(); env.locals[itemVar] = it; sb.append(renderViewBody(inner, env)); sb.append('\n') }
if (saved != null) env.locals[itemVar] = saved else env.locals.remove(itemVar)
return after
}
/** `if (cond) { ..} [elseif (cond) { .. }]* [else { .. }]` emit the first matching branch. */
private fun renderIf
(s
: String, at
: Int, env
: Env, sb
: StringBuilder
): Int
{
val (cond, afterCond) = readParenGroup(s, at + "if".length)
val (body, afterBody) = readBraceBlock(s, afterCond)
var chosen
: String? = if (evalCond
(cond, env
)) body
else null
var j = afterBody
while (true) {
var k = j
while (k < s.length && s[k].isWhitespace()) k++
if (s.startsWith("elseif", k)) {
val (c2, ac2) = readParenGroup(s, k + "elseif".length)
val (b2, ab2) = readBraceBlock(s, ac2)
if (chosen == null && evalCond(c2, env)) chosen = b2
j = ab2
} else if (s.startsWith("else", k)) {
val (b3, ab3) = readBraceBlock(s, k + "else".length)
if (chosen == null) chosen = b3
j = ab3
} else break
}
chosen?.let { sb.append(renderViewBody(it, env)); sb.append('\n') }
return j
}
// v2: lists/maps/arithmetic)
private fun varRef
(name
: String, env
: Env
): HexVal
? = env.
locals[name
] ?: globals
[name
]
/** Evaluate a value expression to a [HexVal]: a bare %var (container-preserving), a
* whole $id(...) call, or otherwise a stringified scalar. */
private fun evalVal
(expr
: String, env
: Env
): HexVal
{
val t = expr.trim()
if (t.length > 1 && t[0] == '%' && t.drop(1).all { it.isLetterOrDigit() || it == '_' })
return varRef(t.substring(1), env) ?: HexVal.EMPTY
if (t.startsWith("$")) {
val (name, after) = readIdent(t, 1)
if (after < t.length && t[after] == '(') {
val close = runCatching { matchParen(t, after) }.getOrNull() ?: -1
if (close == t.length - 1) {
val inner2 = t.substring(after + 1, close)
val argExprs = if (inner2.isBlank()) emptyList() else splitTop(inner2, ",").map { it.trim() }
return callIdVal(name, argExprs, env)
}
}
}
return HexVal.Str(expand(t, env))
}
private fun doSetAt
(raw
: String, env
: Env
) {
val nm = raw.trim().substringBefore(' ')
val afterColl = raw.trim().substringAfter(' ', "").trim()
val keyTok = afterColl.substringBefore(' ')
val valExpr = afterColl.substringAfter(' ', "")
val coll = varRef(varName(nm), env) ?: return
HexValues.setAt(coll, evalVal(keyTok, env), evalVal(valExpr, env))
}
val t = raw.trim(); val sp = t.indexOf(' ')
return if (sp < 0) t to "" else t.substring(0, sp) to t.substring(sp + 1)
}
/** Value-returning built-ins. Container ops evaluate args as HexVal; scalar string
* built-ins fall through to [builtin]. */
private fun callIdVal
(name
: String, argExprs
: List
<String
>, env
: Env
): HexVal
{
fun v(i: Int) = if (i < argExprs.size) evalVal(argExprs[i], env) else HexVal.EMPTY
fun str
(i
: Int, def
: String = "") = if (i
< argExprs.
size) expand
(argExprs
[i
], env
) else def
if (name.startsWith("age.") || name.startsWith("media."))
return HexVal.Str(cb.capability(name, argExprs.map { expand(it, env) }))
if (aliases.containsKey(name.lowercase()))
return callUser(name, argExprs, env) ?: HexVal.EMPTY
return when (name) {
"list" -> HexValues.list(argExprs.map { evalVal(it, env) })
"map" -> {
val a = argExprs.map { evalVal(it, env) }
val pairs
= ArrayList
<Pair
<String, HexVal
>>()
var i = 0; while (i + 1 < a.size + 1 && i + 1 <= a.size - 1) { pairs.add(a[i].asStr() to a[i + 1]); i += 2 }
HexValues.map(pairs)
}
"get" -> HexValues.get(v(0), v(1))
"len" -> HexVal.num(HexValues.len(v(0)).toDouble())
"keys" -> HexValues.keys(v(0))
"has" -> HexVal.Str(HexValues.has(v(0), v(1)).toString())
"sort" -> HexValues.sort(v(0))
"join" -> HexVal.Str(HexValues.join(v(0), str(1, " ")))
"split" -> HexValues.split(str(0), str(1, " "))
"slice" -> HexValues.slice(v(0), str(1, "0").toIntOrNull() ?: 0, str(2, "0").toIntOrNull() ?: 0)
"calc" -> HexVal.num(HexValues.calc(str(0, "0")))
"range" -> { val lo = str(0, "0").toIntOrNull() ?: 0; val hiRaw = str(1, "0").toIntOrNull() ?: 0; val hi = if (hiRaw - lo > 10000) lo + 10000 else hiRaw; HexVal.Lst((lo..hi).map { HexVal.Str(it.toString()) }.toMutableList()) }
"values" -> (v(0) as? HexVal.Mp)?.let { HexVal.Lst(it.map.values.toMutableList()) } ?: HexVal.Lst()
else -> HexVal.Str(builtin(name, argExprs.map { expand(it, env) }, env))
}
}
private fun varName
(token
: String): String = token.
removePrefix("%").
trim()
val t = s.trim()
val sp = t.indexOf(' ')
if (sp < 0) return null to t
return t.substring(0, sp) to t.substring(sp + 1)
}
private fun matchParen
(s
: String, open
: Int
): Int
{
var depth = 0
var i = open
while (i < s.length) { if (s[i] == '(') depth++ else if (s[i] == ')') { depth--; if (depth == 0) return i }; i++ }
throw HexError("unbalanced ( )")
}
/** Split [s] on top-level [sep] (not inside ()/{}). */
private fun splitTop
(s
: String, sep
: String): List
<String
> {
val parts = ArrayList<String>(); var depth = 0; var i = 0; var last = 0
while (i < s.length) {
val c = s[i]
if (c == '(' || c == '{') depth++
else if (c == ')' || c == '}') depth--
else if (depth == 0 && s.startsWith(sep, i)) { parts.add(s.substring(last, i)); i += sep.length; last = i; continue }
i++
}
parts.add(s.substring(last))
return parts
}
val rx = StringBuilder("(?i)^")
for (c in pattern) when (c) {
'*' -> rx.append(".*"); '?' -> rx.append('.')
else -> rx.append(Regex.escape(c.toString()))
}
rx.append('$')
return Regex(rx.toString()).matches(text)
}
}
/** Abort for the step/time budget (uncatchable by scripts since they have no try/catch). */