@volter/twin-upstash 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +202 -0
- package/api/src/fetch.ts +54 -0
- package/api/src/generated/surface.gen.json +1 -0
- package/api/src/generated/ui.gen.json +1 -0
- package/api/src/index.ts +19 -0
- package/api/src/key-gate.ts +30 -0
- package/api/src/manifest.ts +103 -0
- package/api/src/screens/developer-api.tsx +106 -0
- package/api/src/screens/qstash.tsx +99 -0
- package/api/src/screens/session.tsx +125 -0
- package/api/src/screens/teams.tsx +114 -0
- package/api/src/semantics/backups.ts +90 -0
- package/api/src/semantics/index.ts +191 -0
- package/api/src/semantics/shared.ts +42 -0
- package/api/src/semantics/teams.ts +108 -0
- package/api/src/semantics/time.ts +40 -0
- package/dist/api/src/fetch.d.ts +15 -0
- package/dist/api/src/fetch.js +44 -0
- package/dist/api/src/fetch.ts +54 -0
- package/dist/api/src/generated/surface.gen.json +1 -0
- package/dist/api/src/generated/ui.gen.json +1 -0
- package/dist/api/src/index.ts +19 -0
- package/dist/api/src/key-gate.d.ts +3 -0
- package/dist/api/src/key-gate.js +30 -0
- package/dist/api/src/key-gate.ts +30 -0
- package/dist/api/src/manifest.d.ts +2 -0
- package/dist/api/src/manifest.js +81 -0
- package/dist/api/src/manifest.ts +103 -0
- package/dist/api/src/screens/developer-api.d.ts +3 -0
- package/dist/api/src/screens/developer-api.js +101 -0
- package/dist/api/src/screens/developer-api.tsx +106 -0
- package/dist/api/src/screens/qstash.d.ts +3 -0
- package/dist/api/src/screens/qstash.js +92 -0
- package/dist/api/src/screens/qstash.tsx +99 -0
- package/dist/api/src/screens/session.d.ts +9 -0
- package/dist/api/src/screens/session.js +118 -0
- package/dist/api/src/screens/session.tsx +125 -0
- package/dist/api/src/screens/teams.d.ts +3 -0
- package/dist/api/src/screens/teams.js +99 -0
- package/dist/api/src/screens/teams.tsx +114 -0
- package/dist/api/src/semantics/backups.d.ts +7 -0
- package/dist/api/src/semantics/backups.js +75 -0
- package/dist/api/src/semantics/backups.ts +90 -0
- package/dist/api/src/semantics/index.d.ts +10 -0
- package/dist/api/src/semantics/index.js +191 -0
- package/dist/api/src/semantics/index.ts +191 -0
- package/dist/api/src/semantics/shared.d.ts +21 -0
- package/dist/api/src/semantics/shared.js +34 -0
- package/dist/api/src/semantics/shared.ts +42 -0
- package/dist/api/src/semantics/teams.d.ts +13 -0
- package/dist/api/src/semantics/teams.js +100 -0
- package/dist/api/src/semantics/teams.ts +108 -0
- package/dist/api/src/semantics/time.d.ts +2 -0
- package/dist/api/src/semantics/time.js +34 -0
- package/dist/api/src/semantics/time.ts +40 -0
- package/dist/qstash/src/doors.d.ts +6 -0
- package/dist/qstash/src/doors.js +33 -0
- package/dist/qstash/src/doors.ts +51 -0
- package/dist/qstash/src/egress.d.ts +7 -0
- package/dist/qstash/src/egress.js +66 -0
- package/dist/qstash/src/egress.ts +58 -0
- package/dist/qstash/src/fetch.d.ts +7 -0
- package/dist/qstash/src/fetch.js +48 -0
- package/dist/qstash/src/fetch.ts +46 -0
- package/dist/qstash/src/generated/surface.gen.json +1 -0
- package/dist/qstash/src/generated/ui.gen.json +1 -0
- package/dist/qstash/src/index.ts +35 -0
- package/dist/qstash/src/manifest.d.ts +10 -0
- package/dist/qstash/src/manifest.js +105 -0
- package/dist/qstash/src/manifest.ts +134 -0
- package/dist/qstash/src/semantics/account.d.ts +29 -0
- package/dist/qstash/src/semantics/account.js +91 -0
- package/dist/qstash/src/semantics/account.ts +98 -0
- package/dist/qstash/src/semantics/delivery.d.ts +17 -0
- package/dist/qstash/src/semantics/delivery.js +274 -0
- package/dist/qstash/src/semantics/delivery.ts +264 -0
- package/dist/qstash/src/semantics/dlq.d.ts +4 -0
- package/dist/qstash/src/semantics/dlq.js +51 -0
- package/dist/qstash/src/semantics/dlq.ts +61 -0
- package/dist/qstash/src/semantics/index.d.ts +2 -0
- package/dist/qstash/src/semantics/index.js +10 -0
- package/dist/qstash/src/semantics/index.ts +13 -0
- package/dist/qstash/src/semantics/keys.d.ts +2 -0
- package/dist/qstash/src/semantics/keys.js +9 -0
- package/dist/qstash/src/semantics/keys.ts +14 -0
- package/dist/qstash/src/semantics/messages.d.ts +74 -0
- package/dist/qstash/src/semantics/messages.js +233 -0
- package/dist/qstash/src/semantics/messages.ts +249 -0
- package/dist/qstash/src/semantics/queues.d.ts +2 -0
- package/dist/qstash/src/semantics/queues.js +60 -0
- package/dist/qstash/src/semantics/queues.ts +66 -0
- package/dist/qstash/src/semantics/schedules.d.ts +19 -0
- package/dist/qstash/src/semantics/schedules.js +125 -0
- package/dist/qstash/src/semantics/schedules.ts +132 -0
- package/dist/qstash/src/semantics/shared.d.ts +45 -0
- package/dist/qstash/src/semantics/shared.js +115 -0
- package/dist/qstash/src/semantics/shared.ts +121 -0
- package/dist/qstash/src/semantics/urlgroups.d.ts +2 -0
- package/dist/qstash/src/semantics/urlgroups.js +58 -0
- package/dist/qstash/src/semantics/urlgroups.ts +69 -0
- package/dist/qstash/src/semantics/workflows.d.ts +44 -0
- package/dist/qstash/src/semantics/workflows.js +379 -0
- package/dist/qstash/src/semantics/workflows.ts +401 -0
- package/dist/qstash/src/signing.d.ts +4 -0
- package/dist/qstash/src/signing.js +16 -0
- package/dist/qstash/src/signing.ts +19 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +35 -0
- package/dist/src/generated/surface.gen.json +1 -0
- package/dist/src/index.d.ts +18 -0
- package/dist/src/index.js +124 -0
- package/dist/src/manifest.d.ts +14 -0
- package/dist/src/manifest.js +8 -0
- package/dist/src/upstash-budget.d.ts +85 -0
- package/dist/src/upstash-budget.js +440 -0
- package/dist/src/upstash-capabilities.d.ts +4 -0
- package/dist/src/upstash-capabilities.js +1286 -0
- package/dist/src/upstash-conformance.d.ts +7 -0
- package/dist/src/upstash-conformance.js +119 -0
- package/dist/src/upstash-connector.d.ts +115 -0
- package/dist/src/upstash-connector.js +309 -0
- package/dist/src/upstash-lua.d.ts +140 -0
- package/dist/src/upstash-lua.js +1229 -0
- package/dist/src/upstash-server.d.ts +29 -0
- package/dist/src/upstash-server.js +81 -0
- package/dist/src/upstash-store.d.ts +114 -0
- package/dist/src/upstash-store.js +1663 -0
- package/dist/src/upstash-twin.d.ts +73 -0
- package/dist/src/upstash-twin.js +437 -0
- package/package.json +59 -0
- package/qstash/src/doors.ts +51 -0
- package/qstash/src/egress.ts +58 -0
- package/qstash/src/fetch.ts +46 -0
- package/qstash/src/generated/surface.gen.json +1 -0
- package/qstash/src/generated/ui.gen.json +1 -0
- package/qstash/src/index.ts +35 -0
- package/qstash/src/manifest.ts +134 -0
- package/qstash/src/semantics/account.ts +98 -0
- package/qstash/src/semantics/delivery.ts +264 -0
- package/qstash/src/semantics/dlq.ts +61 -0
- package/qstash/src/semantics/index.ts +13 -0
- package/qstash/src/semantics/keys.ts +14 -0
- package/qstash/src/semantics/messages.ts +249 -0
- package/qstash/src/semantics/queues.ts +66 -0
- package/qstash/src/semantics/schedules.ts +132 -0
- package/qstash/src/semantics/shared.ts +121 -0
- package/qstash/src/semantics/urlgroups.ts +69 -0
- package/qstash/src/semantics/workflows.ts +401 -0
- package/qstash/src/signing.ts +19 -0
- package/src/cli.ts +36 -0
- package/src/generated/surface.gen.json +1 -0
- package/src/index.ts +203 -0
- package/src/manifest.ts +26 -0
- package/src/upstash-budget.ts +486 -0
- package/src/upstash-capabilities.ts +1418 -0
- package/src/upstash-conformance.ts +131 -0
- package/src/upstash-connector.ts +340 -0
- package/src/upstash-lua.ts +1120 -0
- package/src/upstash-server.ts +103 -0
- package/src/upstash-store.ts +1437 -0
- package/src/upstash-twin.ts +465 -0
|
@@ -0,0 +1,1120 @@
|
|
|
1
|
+
// A TINY LUA-SUBSET INTERPRETER — the engine behind this twin's EVAL/EVALSHA.
|
|
2
|
+
//
|
|
3
|
+
// ── WHY AN INTERPRETER AND NOT SCRIPT-RECOGNITION (the honest choice, stated up front) ──────────
|
|
4
|
+
// The build brief allowed either (a) recognising known script SHA1s and dispatching to hand-written
|
|
5
|
+
// TypeScript equivalents, or (b) actually EXECUTING the Lua. (a) is a fake-success generator: it
|
|
6
|
+
// passes for the exact bytes it memorised and silently mis-answers for any script one character
|
|
7
|
+
// different — and the whole point of `EVAL` is that the caller supplies the program. So this file
|
|
8
|
+
// takes (b): it PARSES and EVALUATES the script text, calling back into the twin's real Redis
|
|
9
|
+
// command core for every `redis.call`. Nothing about @upstash/ratelimit's scripts is special-cased
|
|
10
|
+
// anywhere in this pack; they run because the interpreter runs Lua, and their SHA1s are only ever
|
|
11
|
+
// used the way real Redis uses them (the EVALSHA script cache).
|
|
12
|
+
//
|
|
13
|
+
// ── WHAT IS AND IS NOT SUPPORTED (loud failure, never a fake success) ───────────────────────────
|
|
14
|
+
// SUPPORTED: local/global assignment (incl. multiple assignment and `local x` with no value),
|
|
15
|
+
// numeric `for`, generic `for ... in ipairs/pairs`, `while`, `repeat/until`, `if/elseif/else`,
|
|
16
|
+
// `break`, `return`, table constructors (array-style and `[k]=v`/`k=v`), indexing (`t[i]`, `t.k`),
|
|
17
|
+
// the full operator set Lua 5.1 has (`+ - * / % ^ .. == ~= < <= > >= and or not #` and unary minus),
|
|
18
|
+
// user-defined `function` values, and the standard-library subset real Redis scripts actually use:
|
|
19
|
+
// `tonumber tostring type error assert unpack ipairs pairs rawget rawset select`,
|
|
20
|
+
// `math.floor/ceil/min/max/abs/huge/random/randomseed/pow/sqrt/fmod/modf`,
|
|
21
|
+
// `table.insert/remove/getn/concat`, `string.format/len/sub/lower/upper/rep/find/gsub/byte/char`,
|
|
22
|
+
// plus `redis.call`, `redis.pcall`, `redis.error_reply`, `redis.status_reply`, `redis.sha1hex`,
|
|
23
|
+
// `redis.breakpoint`/`redis.debug` (no-ops), `redis.LOG_*`, `redis.log` (no-op), `KEYS`, `ARGV`.
|
|
24
|
+
//
|
|
25
|
+
// NOT SUPPORTED — and every one of these throws a LOUD `LuaError` naming the construct rather than
|
|
26
|
+
// degrading to a wrong answer: `goto`/labels, coroutines, metatables/`setmetatable`, varargs `...`
|
|
27
|
+
// in a user function, `cjson`/`cmsgpack`/`bit`/`struct`/`os`/`io`, string methods called with `:`
|
|
28
|
+
// syntax on a value (`s:sub(1,2)`), and any identifier the environment does not define. An
|
|
29
|
+
// unsupported script therefore fails the way an unmodeled operation must — visibly.
|
|
30
|
+
//
|
|
31
|
+
// ── LUA SEMANTICS THAT ARE EASY TO GET WRONG AND MATTER HERE ────────────────────────────────────
|
|
32
|
+
// 1. ONLY `nil` and `false` are falsy. `0` and `""` are TRUE. (`if requestsInCurrentWindow == false`
|
|
33
|
+
// in the sliding-window script depends on the Redis nil→false conversion, not on falsiness.)
|
|
34
|
+
// 2. `==` does NOT coerce across types: `"3" == 3` is FALSE. This is load-bearing — @upstash/
|
|
35
|
+
// ratelimit's `cachedFixedWindowLimitScript` compares a number against a raw `ARGV` STRING
|
|
36
|
+
// (`if r == incrementBy`), which in real Redis never matches and therefore never arms the
|
|
37
|
+
// PEXPIRE. Coercing here would make this twin MORE "correct" than the vendor and hide the
|
|
38
|
+
// caller's real behaviour, so the comparison is kept strict.
|
|
39
|
+
// 3. Arithmetic DOES coerce numeric strings (`"5" + 1 == 6`); a non-numeric string is an error.
|
|
40
|
+
// 4. `/` is float division; `math.floor` rounds toward -inf; `%` is `a - floor(a/b)*b` (sign of the
|
|
41
|
+
// divisor), which differs from JavaScript's `%` for negative operands.
|
|
42
|
+
// 5. Tables are 1-based and `#t` is the array length.
|
|
43
|
+
// 6. Lua→Redis conversion truncates a number toward ZERO (`3.7` → `3`, `-3.7` → `-3`) and stops
|
|
44
|
+
// an array reply at the first `nil`.
|
|
45
|
+
import { createHash } from 'node:crypto';
|
|
46
|
+
|
|
47
|
+
/** A Lua runtime/parse error. Surfaces to the caller as a Redis script error, never as a result. */
|
|
48
|
+
export class LuaError extends Error {
|
|
49
|
+
constructor(message: string) {
|
|
50
|
+
super(message);
|
|
51
|
+
this.name = 'LuaError';
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** A Lua table. Array part + hash part, exactly like the real thing (1-based array indices). */
|
|
56
|
+
export class LuaTable {
|
|
57
|
+
readonly map = new Map<string | number | boolean, LuaValue>();
|
|
58
|
+
get(k: LuaValue): LuaValue {
|
|
59
|
+
if (k === null) return null;
|
|
60
|
+
return this.map.get(k as string | number | boolean) ?? null;
|
|
61
|
+
}
|
|
62
|
+
set(k: LuaValue, v: LuaValue): void {
|
|
63
|
+
if (k === null) throw new LuaError('table index is nil');
|
|
64
|
+
if (v === null) this.map.delete(k as string | number | boolean); else this.store(k, v);
|
|
65
|
+
}
|
|
66
|
+
/** A non-nil value at a non-nil key. */
|
|
67
|
+
private store(k: LuaValue, v: LuaValue): void {
|
|
68
|
+
this.map.set(k as string | number | boolean, v);
|
|
69
|
+
}
|
|
70
|
+
/** `#t` — the border of the array part: the largest n where t[1..n] are all non-nil. */
|
|
71
|
+
get length(): number {
|
|
72
|
+
let n = 0;
|
|
73
|
+
while (this.map.has(n + 1)) n++;
|
|
74
|
+
return n;
|
|
75
|
+
}
|
|
76
|
+
static fromArray(values: LuaValue[]): LuaTable {
|
|
77
|
+
const t = new LuaTable();
|
|
78
|
+
values.forEach((v, i) => { if (v !== null) t.map.set(i + 1, v); });
|
|
79
|
+
return t;
|
|
80
|
+
}
|
|
81
|
+
toArray(): LuaValue[] {
|
|
82
|
+
const out: LuaValue[] = [];
|
|
83
|
+
for (let i = 1; this.map.has(i); i++) out.push(this.map.get(i)!);
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export type LuaFunction = (args: LuaValue[]) => LuaValue[];
|
|
89
|
+
/** `null` stands for Lua `nil` (JS `undefined` is never used as a value in this interpreter). */
|
|
90
|
+
export type LuaValue = null | boolean | number | string | LuaTable | LuaFunction;
|
|
91
|
+
|
|
92
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
93
|
+
// LEXER
|
|
94
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
type TokKind = 'name' | 'number' | 'string' | 'op' | 'keyword' | 'eof';
|
|
97
|
+
type Token = { kind: TokKind; value: string; line: number };
|
|
98
|
+
|
|
99
|
+
const KEYWORDS = new Set([
|
|
100
|
+
'and', 'break', 'do', 'else', 'elseif', 'end', 'false', 'for', 'function', 'if', 'in',
|
|
101
|
+
'local', 'nil', 'not', 'or', 'repeat', 'return', 'then', 'true', 'until', 'while',
|
|
102
|
+
]);
|
|
103
|
+
/** Reserved words this interpreter deliberately refuses rather than silently mis-running. */
|
|
104
|
+
const UNSUPPORTED_KEYWORDS = new Set(['goto']);
|
|
105
|
+
|
|
106
|
+
// Longest-first: `...` must win over `..`, `==` over `=`, etc.
|
|
107
|
+
const OPERATORS = [
|
|
108
|
+
'...', '..', '==', '~=', '<=', '>=',
|
|
109
|
+
'+', '-', '*', '/', '%', '^', '#', '<', '>', '=', '(', ')', '{', '}', '[', ']', ';', ':', ',', '.',
|
|
110
|
+
];
|
|
111
|
+
|
|
112
|
+
export function tokenize(src: string): Token[] {
|
|
113
|
+
const out: Token[] = [];
|
|
114
|
+
let i = 0;
|
|
115
|
+
let line = 1;
|
|
116
|
+
const isDigit = (c: string) => c >= '0' && c <= '9';
|
|
117
|
+
const isNameStart = (c: string) => /[A-Za-z_]/.test(c);
|
|
118
|
+
const isNamePart = (c: string) => /[A-Za-z0-9_]/.test(c);
|
|
119
|
+
|
|
120
|
+
while (i < src.length) {
|
|
121
|
+
const c = src[i]!;
|
|
122
|
+
if (c === '\n') { line++; i++; continue; }
|
|
123
|
+
if (c === ' ' || c === '\t' || c === '\r') { i++; continue; }
|
|
124
|
+
// long comment --[[ ... ]] / line comment -- ...
|
|
125
|
+
if (c === '-' && src[i + 1] === '-') {
|
|
126
|
+
if (src[i + 2] === '[' && src[i + 3] === '[') {
|
|
127
|
+
const end = src.indexOf(']]', i + 4);
|
|
128
|
+
if (end < 0) throw new LuaError(`unfinished long comment at line ${line}`);
|
|
129
|
+
for (let k = i; k < end; k++) if (src[k] === '\n') line++;
|
|
130
|
+
i = end + 2;
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
while (i < src.length && src[i] !== '\n') i++;
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
// long string [[ ... ]]
|
|
137
|
+
if (c === '[' && src[i + 1] === '[') {
|
|
138
|
+
const end = src.indexOf(']]', i + 2);
|
|
139
|
+
if (end < 0) throw new LuaError(`unfinished long string at line ${line}`);
|
|
140
|
+
const value = src.slice(i + 2, end);
|
|
141
|
+
for (const ch of value) if (ch === '\n') line++;
|
|
142
|
+
out.push({ kind: 'string', value, line });
|
|
143
|
+
i = end + 2;
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
if (c === '"' || c === "'") {
|
|
147
|
+
const quote = c;
|
|
148
|
+
let value = '';
|
|
149
|
+
i++;
|
|
150
|
+
while (i < src.length && src[i] !== quote) {
|
|
151
|
+
if (src[i] === '\\') {
|
|
152
|
+
const esc = src[i + 1]!;
|
|
153
|
+
const simple: Record<string, string> = { n: '\n', t: '\t', r: '\r', a: '\x07', b: '\b', f: '\f', v: '\v', '\\': '\\', '"': '"', "'": "'", '\n': '\n' };
|
|
154
|
+
if (esc in simple) { value += simple[esc]; i += 2; continue; }
|
|
155
|
+
if (isDigit(esc)) {
|
|
156
|
+
let digits = '';
|
|
157
|
+
i++;
|
|
158
|
+
while (digits.length < 3 && isDigit(src[i] ?? '')) { digits += src[i]; i++; }
|
|
159
|
+
value += String.fromCharCode(Number(digits));
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
throw new LuaError(`unsupported string escape \\${esc} at line ${line}`);
|
|
163
|
+
}
|
|
164
|
+
if (src[i] === '\n') line++;
|
|
165
|
+
value += src[i];
|
|
166
|
+
i++;
|
|
167
|
+
}
|
|
168
|
+
if (i >= src.length) throw new LuaError(`unfinished string at line ${line}`);
|
|
169
|
+
i++; // closing quote
|
|
170
|
+
out.push({ kind: 'string', value, line });
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
if (isDigit(c) || (c === '.' && isDigit(src[i + 1] ?? ''))) {
|
|
174
|
+
let j = i;
|
|
175
|
+
if (c === '0' && (src[i + 1] === 'x' || src[i + 1] === 'X')) {
|
|
176
|
+
j = i + 2;
|
|
177
|
+
while (j < src.length && /[0-9a-fA-F]/.test(src[j]!)) j++;
|
|
178
|
+
out.push({ kind: 'number', value: src.slice(i, j), line });
|
|
179
|
+
i = j;
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
while (j < src.length && /[0-9.]/.test(src[j]!)) j++;
|
|
183
|
+
if (src[j] === 'e' || src[j] === 'E') {
|
|
184
|
+
j++;
|
|
185
|
+
if (src[j] === '+' || src[j] === '-') j++;
|
|
186
|
+
while (j < src.length && isDigit(src[j]!)) j++;
|
|
187
|
+
}
|
|
188
|
+
out.push({ kind: 'number', value: src.slice(i, j), line });
|
|
189
|
+
i = j;
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
if (isNameStart(c)) {
|
|
193
|
+
let j = i;
|
|
194
|
+
while (j < src.length && isNamePart(src[j]!)) j++;
|
|
195
|
+
const word = src.slice(i, j);
|
|
196
|
+
if (UNSUPPORTED_KEYWORDS.has(word)) throw new LuaError(`unsupported Lua construct '${word}' at line ${line} — this twin runs a documented Lua SUBSET (see upstash-lua.ts)`);
|
|
197
|
+
out.push({ kind: KEYWORDS.has(word) ? 'keyword' : 'name', value: word, line });
|
|
198
|
+
i = j;
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
const op = OPERATORS.find((o) => src.startsWith(o, i));
|
|
202
|
+
if (!op) throw new LuaError(`unexpected character '${c}' at line ${line}`);
|
|
203
|
+
out.push({ kind: 'op', value: op, line });
|
|
204
|
+
i += op.length;
|
|
205
|
+
}
|
|
206
|
+
out.push({ kind: 'eof', value: '<eof>', line });
|
|
207
|
+
return out;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
211
|
+
// AST + PARSER (recursive descent, Lua 5.1 precedence)
|
|
212
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
213
|
+
|
|
214
|
+
type Expr =
|
|
215
|
+
| { t: 'nil' } | { t: 'true' } | { t: 'false' }
|
|
216
|
+
| { t: 'num'; v: number } | { t: 'str'; v: string }
|
|
217
|
+
| { t: 'name'; v: string }
|
|
218
|
+
| { t: 'index'; obj: Expr; key: Expr }
|
|
219
|
+
| { t: 'call'; fn: Expr; args: Expr[]; line?: number }
|
|
220
|
+
| { t: 'bin'; op: string; l: Expr; r: Expr }
|
|
221
|
+
| { t: 'un'; op: string; e: Expr }
|
|
222
|
+
| { t: 'table'; array: Expr[]; pairs: Array<{ k: Expr; v: Expr }> }
|
|
223
|
+
| { t: 'func'; params: string[]; body: Stat[] };
|
|
224
|
+
|
|
225
|
+
type Stat =
|
|
226
|
+
| { t: 'local'; names: string[]; exprs: Expr[] }
|
|
227
|
+
| { t: 'assign'; targets: Expr[]; exprs: Expr[] }
|
|
228
|
+
| { t: 'callstat'; call: Expr }
|
|
229
|
+
| { t: 'if'; branches: Array<{ cond: Expr; body: Stat[] }>; orelse: Stat[] | null }
|
|
230
|
+
| { t: 'while'; cond: Expr; body: Stat[] }
|
|
231
|
+
| { t: 'repeat'; body: Stat[]; cond: Expr }
|
|
232
|
+
| { t: 'fornum'; name: string; from: Expr; to: Expr; step: Expr | null; body: Stat[] }
|
|
233
|
+
| { t: 'forin'; names: string[]; exprs: Expr[]; body: Stat[] }
|
|
234
|
+
| { t: 'do'; body: Stat[] }
|
|
235
|
+
| { t: 'return'; exprs: Expr[] }
|
|
236
|
+
| { t: 'break' };
|
|
237
|
+
|
|
238
|
+
class Parser {
|
|
239
|
+
private pos = 0;
|
|
240
|
+
constructor(private readonly toks: Token[]) {}
|
|
241
|
+
|
|
242
|
+
private peek(): Token { return this.toks[this.pos]!; }
|
|
243
|
+
private next(): Token { return this.toks[this.pos++]!; }
|
|
244
|
+
private check(kind: TokKind, value?: string): boolean {
|
|
245
|
+
const t = this.peek();
|
|
246
|
+
return t.kind === kind && (value === undefined || t.value === value);
|
|
247
|
+
}
|
|
248
|
+
private accept(kind: TokKind, value?: string): boolean {
|
|
249
|
+
if (this.check(kind, value)) { this.pos++; return true; }
|
|
250
|
+
return false;
|
|
251
|
+
}
|
|
252
|
+
private expect(kind: TokKind, value?: string): Token {
|
|
253
|
+
if (!this.check(kind, value)) {
|
|
254
|
+
const t = this.peek();
|
|
255
|
+
throw new LuaError(`syntax error near '${t.value}' at line ${t.line} — expected ${value ?? kind}`);
|
|
256
|
+
}
|
|
257
|
+
return this.next();
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
parseChunk(): Stat[] {
|
|
261
|
+
const body = this.parseBlock();
|
|
262
|
+
this.expect('eof');
|
|
263
|
+
return body;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
private blockEnds(): boolean {
|
|
267
|
+
const t = this.peek();
|
|
268
|
+
return t.kind === 'eof' || (t.kind === 'keyword' && ['end', 'else', 'elseif', 'until'].includes(t.value));
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
private parseBlock(): Stat[] {
|
|
272
|
+
const out: Stat[] = [];
|
|
273
|
+
while (!this.blockEnds()) {
|
|
274
|
+
if (this.accept('op', ';')) continue;
|
|
275
|
+
const stat = this.parseStatement();
|
|
276
|
+
out.push(stat);
|
|
277
|
+
if (stat.t === 'return' || stat.t === 'break') {
|
|
278
|
+
this.accept('op', ';');
|
|
279
|
+
break; // `return`/`break` must be the last statement of a block in Lua 5.1
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return out;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
private parseStatement(): Stat {
|
|
286
|
+
const t = this.peek();
|
|
287
|
+
if (t.kind === 'keyword') {
|
|
288
|
+
switch (t.value) {
|
|
289
|
+
case 'local': return this.parseLocal();
|
|
290
|
+
case 'if': return this.parseIf();
|
|
291
|
+
case 'while': return this.parseWhile();
|
|
292
|
+
case 'repeat': return this.parseRepeat();
|
|
293
|
+
case 'for': return this.parseFor();
|
|
294
|
+
case 'do': { this.next(); const body = this.parseBlock(); this.expect('keyword', 'end'); return { t: 'do', body }; }
|
|
295
|
+
case 'return': {
|
|
296
|
+
this.next();
|
|
297
|
+
const exprs = this.blockEnds() || this.check('op', ';') ? [] : this.parseExprList();
|
|
298
|
+
return { t: 'return', exprs };
|
|
299
|
+
}
|
|
300
|
+
case 'break': this.next(); return { t: 'break' };
|
|
301
|
+
case 'function': {
|
|
302
|
+
this.next();
|
|
303
|
+
// `function a.b.c(...)` — sugar for `a.b.c = function(...)`
|
|
304
|
+
let target: Expr = { t: 'name', v: this.expect('name').value };
|
|
305
|
+
while (this.accept('op', '.')) target = { t: 'index', obj: target, key: { t: 'str', v: this.expect('name').value } };
|
|
306
|
+
if (this.check('op', ':')) throw new LuaError(`unsupported Lua construct 'function ...:method' at line ${t.line} — this twin runs a documented Lua SUBSET`);
|
|
307
|
+
const fn = this.parseFunctionBody();
|
|
308
|
+
return { t: 'assign', targets: [target], exprs: [fn] };
|
|
309
|
+
}
|
|
310
|
+
default: throw new LuaError(`unexpected '${t.value}' at line ${t.line}`);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
// expression statement: either a call, or an assignment
|
|
314
|
+
const first = this.parseSuffixed();
|
|
315
|
+
if (this.check('op', '=') || this.check('op', ',')) {
|
|
316
|
+
const targets = [first];
|
|
317
|
+
while (this.accept('op', ',')) targets.push(this.parseSuffixed());
|
|
318
|
+
this.expect('op', '=');
|
|
319
|
+
const exprs = this.parseExprList();
|
|
320
|
+
for (const target of targets) {
|
|
321
|
+
if (target.t !== 'name' && target.t !== 'index') throw new LuaError(`cannot assign to this expression at line ${t.line}`);
|
|
322
|
+
}
|
|
323
|
+
return { t: 'assign', targets, exprs };
|
|
324
|
+
}
|
|
325
|
+
if (first.t !== 'call') throw new LuaError(`syntax error at line ${t.line} — statement is neither a call nor an assignment`);
|
|
326
|
+
return { t: 'callstat', call: first };
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
private parseLocal(): Stat {
|
|
330
|
+
this.expect('keyword', 'local');
|
|
331
|
+
if (this.check('keyword', 'function')) {
|
|
332
|
+
this.next();
|
|
333
|
+
const name = this.expect('name').value;
|
|
334
|
+
const fn = this.parseFunctionBody();
|
|
335
|
+
return { t: 'local', names: [name], exprs: [fn] };
|
|
336
|
+
}
|
|
337
|
+
const names = [this.expect('name').value];
|
|
338
|
+
while (this.accept('op', ',')) names.push(this.expect('name').value);
|
|
339
|
+
const exprs = this.accept('op', '=') ? this.parseExprList() : [];
|
|
340
|
+
return { t: 'local', names, exprs };
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
private parseIf(): Stat {
|
|
344
|
+
this.expect('keyword', 'if');
|
|
345
|
+
const branches: Array<{ cond: Expr; body: Stat[] }> = [];
|
|
346
|
+
const cond = this.parseExpr();
|
|
347
|
+
this.expect('keyword', 'then');
|
|
348
|
+
branches.push({ cond, body: this.parseBlock() });
|
|
349
|
+
let orelse: Stat[] | null = null;
|
|
350
|
+
for (;;) {
|
|
351
|
+
if (this.accept('keyword', 'elseif')) {
|
|
352
|
+
const c = this.parseExpr();
|
|
353
|
+
this.expect('keyword', 'then');
|
|
354
|
+
branches.push({ cond: c, body: this.parseBlock() });
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
if (this.accept('keyword', 'else')) orelse = this.parseBlock();
|
|
358
|
+
break;
|
|
359
|
+
}
|
|
360
|
+
this.expect('keyword', 'end');
|
|
361
|
+
return { t: 'if', branches, orelse };
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
private parseWhile(): Stat {
|
|
365
|
+
this.expect('keyword', 'while');
|
|
366
|
+
const cond = this.parseExpr();
|
|
367
|
+
this.expect('keyword', 'do');
|
|
368
|
+
const body = this.parseBlock();
|
|
369
|
+
this.expect('keyword', 'end');
|
|
370
|
+
return { t: 'while', cond, body };
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
private parseRepeat(): Stat {
|
|
374
|
+
this.expect('keyword', 'repeat');
|
|
375
|
+
const body = this.parseBlock();
|
|
376
|
+
this.expect('keyword', 'until');
|
|
377
|
+
return { t: 'repeat', body, cond: this.parseExpr() };
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
private parseFor(): Stat {
|
|
381
|
+
this.expect('keyword', 'for');
|
|
382
|
+
const first = this.expect('name').value;
|
|
383
|
+
if (this.accept('op', '=')) {
|
|
384
|
+
const from = this.parseExpr();
|
|
385
|
+
this.expect('op', ',');
|
|
386
|
+
const to = this.parseExpr();
|
|
387
|
+
const step = this.accept('op', ',') ? this.parseExpr() : null;
|
|
388
|
+
this.expect('keyword', 'do');
|
|
389
|
+
const body = this.parseBlock();
|
|
390
|
+
this.expect('keyword', 'end');
|
|
391
|
+
return { t: 'fornum', name: first, from, to, step, body };
|
|
392
|
+
}
|
|
393
|
+
const names = [first];
|
|
394
|
+
while (this.accept('op', ',')) names.push(this.expect('name').value);
|
|
395
|
+
this.expect('keyword', 'in');
|
|
396
|
+
const exprs = this.parseExprList();
|
|
397
|
+
this.expect('keyword', 'do');
|
|
398
|
+
const body = this.parseBlock();
|
|
399
|
+
this.expect('keyword', 'end');
|
|
400
|
+
return { t: 'forin', names, exprs, body };
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
private parseFunctionBody(): Expr {
|
|
404
|
+
this.expect('op', '(');
|
|
405
|
+
const params: string[] = [];
|
|
406
|
+
if (!this.check('op', ')')) {
|
|
407
|
+
do {
|
|
408
|
+
if (this.check('op', '...')) throw new LuaError('unsupported Lua construct: varargs `...` in a function definition — this twin runs a documented Lua SUBSET');
|
|
409
|
+
params.push(this.expect('name').value);
|
|
410
|
+
} while (this.accept('op', ','));
|
|
411
|
+
}
|
|
412
|
+
this.expect('op', ')');
|
|
413
|
+
const body = this.parseBlock();
|
|
414
|
+
this.expect('keyword', 'end');
|
|
415
|
+
return { t: 'func', params, body };
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
private parseExprList(): Expr[] {
|
|
419
|
+
const out = [this.parseExpr()];
|
|
420
|
+
while (this.accept('op', ',')) out.push(this.parseExpr());
|
|
421
|
+
return out;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// Lua 5.1 binary precedence. `..` and `^` are RIGHT-associative.
|
|
425
|
+
private static readonly BINARY: Record<string, [number, number]> = {
|
|
426
|
+
or: [1, 1], and: [2, 2],
|
|
427
|
+
'<': [3, 3], '>': [3, 3], '<=': [3, 3], '>=': [3, 3], '~=': [3, 3], '==': [3, 3],
|
|
428
|
+
'..': [5, 4],
|
|
429
|
+
'+': [6, 6], '-': [6, 6],
|
|
430
|
+
'*': [7, 7], '/': [7, 7], '%': [7, 7],
|
|
431
|
+
'^': [10, 9],
|
|
432
|
+
};
|
|
433
|
+
private static readonly UNARY_PRIORITY = 8;
|
|
434
|
+
|
|
435
|
+
private parseExpr(limit = 0): Expr {
|
|
436
|
+
let left: Expr;
|
|
437
|
+
const t = this.peek();
|
|
438
|
+
if ((t.kind === 'op' && (t.value === '-' || t.value === '#')) || (t.kind === 'keyword' && t.value === 'not')) {
|
|
439
|
+
this.next();
|
|
440
|
+
left = { t: 'un', op: t.value, e: this.parseExpr(Parser.UNARY_PRIORITY) };
|
|
441
|
+
} else {
|
|
442
|
+
left = this.parseSimple();
|
|
443
|
+
}
|
|
444
|
+
for (;;) {
|
|
445
|
+
const op = this.peek();
|
|
446
|
+
const isBin = (op.kind === 'op' || op.kind === 'keyword') && Parser.BINARY[op.value] !== undefined;
|
|
447
|
+
if (!isBin) break;
|
|
448
|
+
const [leftPri, rightPri] = Parser.BINARY[op.value]!;
|
|
449
|
+
if (leftPri <= limit) break;
|
|
450
|
+
this.next();
|
|
451
|
+
left = { t: 'bin', op: op.value, l: left, r: this.parseExpr(rightPri) };
|
|
452
|
+
}
|
|
453
|
+
return left;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
private parseSimple(): Expr {
|
|
457
|
+
const t = this.peek();
|
|
458
|
+
if (t.kind === 'number') { this.next(); return { t: 'num', v: parseLuaNumber(t.value) }; }
|
|
459
|
+
if (t.kind === 'string') { this.next(); return { t: 'str', v: t.value }; }
|
|
460
|
+
if (t.kind === 'keyword') {
|
|
461
|
+
if (t.value === 'nil') { this.next(); return { t: 'nil' }; }
|
|
462
|
+
if (t.value === 'true') { this.next(); return { t: 'true' }; }
|
|
463
|
+
if (t.value === 'false') { this.next(); return { t: 'false' }; }
|
|
464
|
+
if (t.value === 'function') { this.next(); return this.parseFunctionBody(); }
|
|
465
|
+
}
|
|
466
|
+
if (t.kind === 'op' && t.value === '{') return this.parseTable();
|
|
467
|
+
if (t.kind === 'op' && t.value === '...') throw new LuaError(`unsupported Lua construct '...' at line ${t.line} — this twin runs a documented Lua SUBSET`);
|
|
468
|
+
return this.parseSuffixed();
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
private parseTable(): Expr {
|
|
472
|
+
this.expect('op', '{');
|
|
473
|
+
const array: Expr[] = [];
|
|
474
|
+
const pairs: Array<{ k: Expr; v: Expr }> = [];
|
|
475
|
+
while (!this.check('op', '}')) {
|
|
476
|
+
if (this.check('op', '[')) {
|
|
477
|
+
this.next();
|
|
478
|
+
const k = this.parseExpr();
|
|
479
|
+
this.expect('op', ']');
|
|
480
|
+
this.expect('op', '=');
|
|
481
|
+
pairs.push({ k, v: this.parseExpr() });
|
|
482
|
+
} else if (this.check('name') && this.toks[this.pos + 1]?.kind === 'op' && this.toks[this.pos + 1]?.value === '=') {
|
|
483
|
+
const k = this.next().value;
|
|
484
|
+
this.next(); // '='
|
|
485
|
+
pairs.push({ k: { t: 'str', v: k }, v: this.parseExpr() });
|
|
486
|
+
} else {
|
|
487
|
+
array.push(this.parseExpr());
|
|
488
|
+
}
|
|
489
|
+
if (!this.accept('op', ',') && !this.accept('op', ';')) break;
|
|
490
|
+
}
|
|
491
|
+
this.expect('op', '}');
|
|
492
|
+
return { t: 'table', array, pairs };
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/** primary + any chain of `.name`, `[expr]`, `(args)`, `"str"`, `{table}` suffixes. */
|
|
496
|
+
private parseSuffixed(): Expr {
|
|
497
|
+
let e: Expr;
|
|
498
|
+
if (this.accept('op', '(')) {
|
|
499
|
+
e = this.parseExpr();
|
|
500
|
+
this.expect('op', ')');
|
|
501
|
+
} else {
|
|
502
|
+
e = { t: 'name', v: this.expect('name').value };
|
|
503
|
+
}
|
|
504
|
+
for (;;) {
|
|
505
|
+
if (this.accept('op', '.')) { e = { t: 'index', obj: e, key: { t: 'str', v: this.expect('name').value } }; continue; }
|
|
506
|
+
if (this.accept('op', '[')) { const k = this.parseExpr(); this.expect('op', ']'); e = { t: 'index', obj: e, key: k }; continue; }
|
|
507
|
+
if (this.check('op', ':')) throw new LuaError(`unsupported Lua construct 'method call with :' at line ${this.peek().line} — this twin runs a documented Lua SUBSET`);
|
|
508
|
+
if (this.check('op', '(')) {
|
|
509
|
+
const line = this.next().line;
|
|
510
|
+
const args: Expr[] = this.check('op', ')') ? [] : this.parseExprList();
|
|
511
|
+
this.expect('op', ')');
|
|
512
|
+
e = { t: 'call', fn: e, args, line };
|
|
513
|
+
continue;
|
|
514
|
+
}
|
|
515
|
+
if (this.check('string')) { e = { t: 'call', fn: e, args: [{ t: 'str', v: this.next().value }] }; continue; }
|
|
516
|
+
if (this.check('op', '{')) { e = { t: 'call', fn: e, args: [this.parseTable()] }; continue; }
|
|
517
|
+
break;
|
|
518
|
+
}
|
|
519
|
+
return e;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function parseLuaNumber(text: string): number {
|
|
524
|
+
const n = /^0[xX]/.test(text) ? Number.parseInt(text, 16) : Number(text);
|
|
525
|
+
if (!Number.isFinite(n) && text !== 'inf') throw new LuaError(`malformed number near '${text}'`);
|
|
526
|
+
return n;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/** Parse Lua source into a chunk. Exported so a test can prove a bad script fails at PARSE time. */
|
|
530
|
+
export function parseLua(src: string): Stat[] {
|
|
531
|
+
return new Parser(tokenize(src)).parseChunk();
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
535
|
+
// EVALUATOR
|
|
536
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
537
|
+
|
|
538
|
+
class Scope {
|
|
539
|
+
private readonly vars = new Map<string, LuaValue>();
|
|
540
|
+
constructor(private readonly parent: Scope | null) {}
|
|
541
|
+
declare(name: string, value: LuaValue): void { this.vars.set(name, value); }
|
|
542
|
+
has(name: string): boolean { return this.vars.has(name) || (this.parent?.has(name) ?? false); }
|
|
543
|
+
get(name: string): LuaValue {
|
|
544
|
+
if (this.vars.has(name)) return this.vars.get(name)!;
|
|
545
|
+
return this.parent ? this.parent.get(name) : null;
|
|
546
|
+
}
|
|
547
|
+
/** Assign to the nearest enclosing declaration; otherwise declare at the ROOT (a Lua global). */
|
|
548
|
+
set(name: string, value: LuaValue): void {
|
|
549
|
+
if (this.vars.has(name)) { this.vars.set(name, value); return; }
|
|
550
|
+
if (this.parent) { this.parent.set(name, value); return; }
|
|
551
|
+
this.vars.set(name, value);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
const BREAK = Symbol('lua-break');
|
|
556
|
+
type Signal = typeof BREAK | { ret: LuaValue[] } | null;
|
|
557
|
+
|
|
558
|
+
export function luaTruthy(v: LuaValue): boolean {
|
|
559
|
+
return v !== null && v !== false;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
export function luaTypeName(v: LuaValue): string {
|
|
563
|
+
if (v === null) return 'nil';
|
|
564
|
+
if (typeof v === 'boolean') return 'boolean';
|
|
565
|
+
if (typeof v === 'number') return 'number';
|
|
566
|
+
if (typeof v === 'string') return 'string';
|
|
567
|
+
if (v instanceof LuaTable) return 'table';
|
|
568
|
+
return 'function';
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/** Lua's arithmetic string→number coercion. Throws (as Lua does) on a non-numeric operand. */
|
|
572
|
+
function arith(v: LuaValue, op: string): number {
|
|
573
|
+
if (typeof v === 'number') return v;
|
|
574
|
+
if (typeof v === 'string') {
|
|
575
|
+
const n = v.trim() === '' ? Number.NaN : Number(v);
|
|
576
|
+
if (Number.isFinite(n)) return n;
|
|
577
|
+
}
|
|
578
|
+
throw new LuaError(`attempt to perform arithmetic (${op}) on a ${luaTypeName(v)} value`);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/** Lua's `..` coercion: numbers stringify, everything else but a string is an error. */
|
|
582
|
+
function concatOperand(v: LuaValue): string {
|
|
583
|
+
if (typeof v === 'string') return v;
|
|
584
|
+
if (typeof v === 'number') return numberToLuaString(v);
|
|
585
|
+
throw new LuaError(`attempt to concatenate a ${luaTypeName(v)} value`);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/** Lua 5.1's `%.14g` number formatting — `3` prints as "3", not "3.0". */
|
|
589
|
+
export function numberToLuaString(n: number): string {
|
|
590
|
+
if (Number.isInteger(n) && Math.abs(n) < 1e15) return String(n);
|
|
591
|
+
if (!Number.isFinite(n)) return n > 0 ? 'inf' : Number.isNaN(n) ? 'nan' : '-inf';
|
|
592
|
+
const g = n.toPrecision(14);
|
|
593
|
+
return g.includes('e') ? String(Number(g)) : String(Number(g));
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/** `tonumber`, exactly: a number passes through, a numeric string converts, anything else is nil. */
|
|
597
|
+
export function luaToNumber(v: LuaValue, base?: number): LuaValue {
|
|
598
|
+
if (base !== undefined && base !== 10) {
|
|
599
|
+
if (typeof v !== 'string') return null;
|
|
600
|
+
const n = Number.parseInt(v.trim(), base);
|
|
601
|
+
return Number.isNaN(n) ? null : n;
|
|
602
|
+
}
|
|
603
|
+
if (typeof v === 'number') return v;
|
|
604
|
+
if (typeof v !== 'string') return null;
|
|
605
|
+
const s = v.trim();
|
|
606
|
+
if (s === '') return null;
|
|
607
|
+
const n = /^0[xX][0-9a-fA-F]+$/.test(s) ? Number.parseInt(s, 16) : Number(s);
|
|
608
|
+
return Number.isFinite(n) ? n : null;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/** What the host must supply: a synchronous `redis.call`, plus KEYS/ARGV. */
|
|
612
|
+
export type LuaHost = {
|
|
613
|
+
/** Run one Redis command, called from the script's line `line`. MUST throw to abort the script the way `redis.call` does. */
|
|
614
|
+
call: (args: string[], line: number) => LuaValue;
|
|
615
|
+
keys: string[];
|
|
616
|
+
argv: string[];
|
|
617
|
+
};
|
|
618
|
+
|
|
619
|
+
class Interpreter {
|
|
620
|
+
private steps = 0;
|
|
621
|
+
/** The script line of the call being evaluated: where a `redis.call` error says it happened. */
|
|
622
|
+
private line = 1;
|
|
623
|
+
/** A runaway script must not hang the twin. Real Redis has `busy-reply-threshold`; this is the
|
|
624
|
+
* deterministic equivalent — a hard step ceiling that fails LOUDLY rather than spinning. */
|
|
625
|
+
private static readonly MAX_STEPS = 2_000_000;
|
|
626
|
+
|
|
627
|
+
constructor(private readonly host: LuaHost) {}
|
|
628
|
+
|
|
629
|
+
private tick(): void {
|
|
630
|
+
if (++this.steps > Interpreter.MAX_STEPS) throw new LuaError('script exceeded the interpreter step limit (possible infinite loop)');
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
run(chunk: Stat[]): LuaValue {
|
|
634
|
+
const globals = new Scope(null);
|
|
635
|
+
this.installStdlib(globals);
|
|
636
|
+
const signal = this.execBlock(chunk, new Scope(globals));
|
|
637
|
+
if (signal === BREAK) throw new LuaError("'break' outside a loop");
|
|
638
|
+
return signal && 'ret' in signal ? (signal.ret[0] ?? null) : null;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
private installStdlib(g: Scope): void {
|
|
642
|
+
const host = this.host;
|
|
643
|
+
g.declare('KEYS', LuaTable.fromArray(host.keys));
|
|
644
|
+
g.declare('ARGV', LuaTable.fromArray(host.argv));
|
|
645
|
+
|
|
646
|
+
const one = (v: LuaValue): LuaValue[] => [v];
|
|
647
|
+
g.declare('tonumber', (a) => one(luaToNumber(a[0] ?? null, a[1] === undefined || a[1] === null ? undefined : Number(arith(a[1], 'tonumber')))));
|
|
648
|
+
function luaTostring(a: LuaValue[]): LuaValue[] {
|
|
649
|
+
const v = a[0] ?? null;
|
|
650
|
+
if (typeof v === 'string') return one(v);
|
|
651
|
+
if (typeof v === 'number') return one(numberToLuaString(v));
|
|
652
|
+
if (v === null) return one('nil');
|
|
653
|
+
if (typeof v === 'boolean') return one(String(v));
|
|
654
|
+
return one(luaTypeName(v));
|
|
655
|
+
}
|
|
656
|
+
g.declare('tostring', luaTostring);
|
|
657
|
+
g.declare('type', (a) => one(luaTypeName(a[0] ?? null)));
|
|
658
|
+
function luaError(a: LuaValue[]): LuaValue[] {
|
|
659
|
+
const v = a[0] ?? null;
|
|
660
|
+
if (v instanceof LuaTable) {
|
|
661
|
+
const err = v.get('err');
|
|
662
|
+
throw new LuaError(typeof err === 'string' ? err : 'script raised a table error');
|
|
663
|
+
}
|
|
664
|
+
throw new LuaError(typeof v === 'string' ? v : `script error: ${luaTypeName(v)}`);
|
|
665
|
+
}
|
|
666
|
+
g.declare('error', luaError);
|
|
667
|
+
function luaAssert(a: LuaValue[]): LuaValue[] {
|
|
668
|
+
if (!luaTruthy(a[0] ?? null)) throw new LuaError(typeof a[1] === 'string' ? a[1] : 'assertion failed!');
|
|
669
|
+
return a;
|
|
670
|
+
}
|
|
671
|
+
g.declare('assert', luaAssert);
|
|
672
|
+
function luaSelect(a: LuaValue[]): LuaValue[] {
|
|
673
|
+
const sel = a[0] ?? null;
|
|
674
|
+
const rest = a.slice(1);
|
|
675
|
+
if (sel === '#') return one(rest.length);
|
|
676
|
+
const n = Number(arith(sel, 'select'));
|
|
677
|
+
return rest.slice(n - 1);
|
|
678
|
+
}
|
|
679
|
+
g.declare('select', luaSelect);
|
|
680
|
+
g.declare('rawget', (a) => one(a[0] instanceof LuaTable ? a[0].get(a[1] ?? null) : null));
|
|
681
|
+
g.declare('rawset', (a) => { if (a[0] instanceof LuaTable) a[0].set(a[1] ?? null, a[2] ?? null); return one(a[0] ?? null); });
|
|
682
|
+
g.declare('unpack', (a) => (a[0] instanceof LuaTable ? a[0].toArray() : []));
|
|
683
|
+
function luaIpairs(a: LuaValue[]): LuaValue[] {
|
|
684
|
+
const t = a[0];
|
|
685
|
+
if (!(t instanceof LuaTable)) throw new LuaError('bad argument #1 to \'ipairs\' (table expected)');
|
|
686
|
+
const iter: LuaFunction = (args) => {
|
|
687
|
+
const i = Number(arith(args[1] ?? 0, 'ipairs')) + 1;
|
|
688
|
+
const v = t.get(i);
|
|
689
|
+
return v === null ? [null] : [i, v];
|
|
690
|
+
};
|
|
691
|
+
return [iter, t, 0];
|
|
692
|
+
}
|
|
693
|
+
g.declare('ipairs', luaIpairs);
|
|
694
|
+
function luaPairs(a: LuaValue[]): LuaValue[] {
|
|
695
|
+
const t = a[0];
|
|
696
|
+
if (!(t instanceof LuaTable)) throw new LuaError('bad argument #1 to \'pairs\' (table expected)');
|
|
697
|
+
const entries = [...t.map.entries()];
|
|
698
|
+
let idx = 0;
|
|
699
|
+
const iter: LuaFunction = () => {
|
|
700
|
+
if (idx >= entries.length) return [null];
|
|
701
|
+
const [k, v] = entries[idx++]!;
|
|
702
|
+
return [k as LuaValue, v];
|
|
703
|
+
};
|
|
704
|
+
return [iter, t, null];
|
|
705
|
+
}
|
|
706
|
+
g.declare('pairs', luaPairs);
|
|
707
|
+
|
|
708
|
+
const math = new LuaTable();
|
|
709
|
+
const num = (a: LuaValue[], i: number, who: string) => Number(arith(a[i] ?? null, who));
|
|
710
|
+
math.set('floor', (a) => one(Math.floor(num(a, 0, 'math.floor'))));
|
|
711
|
+
math.set('ceil', (a) => one(Math.ceil(num(a, 0, 'math.ceil'))));
|
|
712
|
+
math.set('abs', (a) => one(Math.abs(num(a, 0, 'math.abs'))));
|
|
713
|
+
math.set('sqrt', (a) => one(Math.sqrt(num(a, 0, 'math.sqrt'))));
|
|
714
|
+
math.set('pow', (a) => one(num(a, 0, 'math.pow') ** num(a, 1, 'math.pow')));
|
|
715
|
+
math.set('fmod', (a) => one(num(a, 0, 'math.fmod') % num(a, 1, 'math.fmod')));
|
|
716
|
+
math.set('modf', (a) => { const v = num(a, 0, 'math.modf'); const i = v < 0 ? Math.ceil(v) : Math.floor(v); return [i, v - i]; });
|
|
717
|
+
math.set('min', (a) => one(Math.min(...a.map((_, i) => num(a, i, 'math.min')))));
|
|
718
|
+
math.set('max', (a) => one(Math.max(...a.map((_, i) => num(a, i, 'math.max')))));
|
|
719
|
+
math.set('huge', Number.POSITIVE_INFINITY);
|
|
720
|
+
// DETERMINISM: real Redis seeds Lua's RNG identically for every script run so replicas agree.
|
|
721
|
+
// This twin does the same with a fixed LCG — a script that calls math.random gets a repeatable
|
|
722
|
+
// sequence rather than the twin's determinism being silently broken by Math.random().
|
|
723
|
+
let seed = 0x2545_f491;
|
|
724
|
+
const nextRandom = (): number => { seed = (seed * 1_103_515_245 + 12_345) >>> 0; return seed / 0x1_0000_0000; };
|
|
725
|
+
math.set('randomseed', (a) => { seed = (num(a, 0, 'math.randomseed') >>> 0) || 1; return []; });
|
|
726
|
+
function luaMathRandom(a: LuaValue[]): LuaValue[] {
|
|
727
|
+
if (a.length === 0) return one(nextRandom());
|
|
728
|
+
const lo = a.length === 1 ? 1 : num(a, 0, 'math.random');
|
|
729
|
+
const hi = a.length === 1 ? num(a, 0, 'math.random') : num(a, 1, 'math.random');
|
|
730
|
+
return one(lo + Math.floor(nextRandom() * (hi - lo + 1)));
|
|
731
|
+
}
|
|
732
|
+
math.set('random', luaMathRandom);
|
|
733
|
+
g.declare('math', math);
|
|
734
|
+
|
|
735
|
+
const table = new LuaTable();
|
|
736
|
+
function luaTableInsert(a: LuaValue[]): LuaValue[] {
|
|
737
|
+
const t = a[0];
|
|
738
|
+
if (!(t instanceof LuaTable)) throw new LuaError("bad argument #1 to 'table.insert' (table expected)");
|
|
739
|
+
if (a.length >= 3) {
|
|
740
|
+
const at = Number(arith(a[1] ?? null, 'table.insert'));
|
|
741
|
+
for (let i = t.length; i >= at; i--) t.set(i + 1, t.get(i));
|
|
742
|
+
t.set(at, a[2] ?? null);
|
|
743
|
+
} else {
|
|
744
|
+
t.set(t.length + 1, a[1] ?? null);
|
|
745
|
+
}
|
|
746
|
+
return [];
|
|
747
|
+
}
|
|
748
|
+
table.set('insert', luaTableInsert);
|
|
749
|
+
function luaTableRemove(a: LuaValue[]): LuaValue[] {
|
|
750
|
+
const t = a[0];
|
|
751
|
+
if (!(t instanceof LuaTable)) throw new LuaError("bad argument #1 to 'table.remove' (table expected)");
|
|
752
|
+
const n = t.length;
|
|
753
|
+
const at = a.length >= 2 ? Number(arith(a[1] ?? null, 'table.remove')) : n;
|
|
754
|
+
const removed = t.get(at);
|
|
755
|
+
for (let i = at; i < n; i++) t.set(i, t.get(i + 1));
|
|
756
|
+
t.set(n, null);
|
|
757
|
+
return one(removed);
|
|
758
|
+
}
|
|
759
|
+
table.set('remove', luaTableRemove);
|
|
760
|
+
table.set('getn', (a) => one(a[0] instanceof LuaTable ? a[0].length : 0));
|
|
761
|
+
function luaTableConcat(a: LuaValue[]): LuaValue[] {
|
|
762
|
+
const t = a[0];
|
|
763
|
+
if (!(t instanceof LuaTable)) throw new LuaError("bad argument #1 to 'table.concat' (table expected)");
|
|
764
|
+
const sep = typeof a[1] === 'string' ? a[1] : a[1] === undefined || a[1] === null ? '' : concatOperand(a[1]);
|
|
765
|
+
return one(t.toArray().map(concatOperand).join(sep));
|
|
766
|
+
}
|
|
767
|
+
table.set('concat', luaTableConcat);
|
|
768
|
+
g.declare('table', table);
|
|
769
|
+
|
|
770
|
+
const string = new LuaTable();
|
|
771
|
+
const str = (a: LuaValue[], i: number, who: string): string => {
|
|
772
|
+
const v = a[i] ?? null;
|
|
773
|
+
if (typeof v === 'string') return v;
|
|
774
|
+
if (typeof v === 'number') return numberToLuaString(v);
|
|
775
|
+
throw new LuaError(`bad argument #${i + 1} to '${who}' (string expected, got ${luaTypeName(v)})`);
|
|
776
|
+
};
|
|
777
|
+
string.set('len', (a) => one(str(a, 0, 'string.len').length));
|
|
778
|
+
string.set('lower', (a) => one(str(a, 0, 'string.lower').toLowerCase()));
|
|
779
|
+
string.set('upper', (a) => one(str(a, 0, 'string.upper').toUpperCase()));
|
|
780
|
+
string.set('rep', (a) => one(str(a, 0, 'string.rep').repeat(Math.max(0, Number(arith(a[1] ?? 0, 'string.rep'))))));
|
|
781
|
+
function luaStringSub(a: LuaValue[]): LuaValue[] {
|
|
782
|
+
const s = str(a, 0, 'string.sub');
|
|
783
|
+
let i = a.length >= 2 ? Number(arith(a[1] ?? 1, 'string.sub')) : 1;
|
|
784
|
+
let j = a.length >= 3 && a[2] !== null ? Number(arith(a[2]!, 'string.sub')) : -1;
|
|
785
|
+
if (i < 0) i = Math.max(s.length + i + 1, 1); else if (i === 0) i = 1;
|
|
786
|
+
if (j < 0) j = s.length + j + 1; else if (j > s.length) j = s.length;
|
|
787
|
+
return one(i > j ? '' : s.slice(i - 1, j));
|
|
788
|
+
}
|
|
789
|
+
string.set('sub', luaStringSub);
|
|
790
|
+
string.set('byte', (a) => one(str(a, 0, 'string.byte').charCodeAt(Math.max(0, (a.length >= 2 ? Number(arith(a[1]!, 'string.byte')) : 1) - 1)) || 0));
|
|
791
|
+
string.set('char', (a) => one(a.map((v) => String.fromCharCode(Number(arith(v, 'string.char')))).join('')));
|
|
792
|
+
function luaStringFormat(a: LuaValue[]): LuaValue[] {
|
|
793
|
+
const fmt = str(a, 0, 'string.format');
|
|
794
|
+
let argIndex = 1;
|
|
795
|
+
return one(fmt.replace(/%[-+ #0]*\d*(?:\.\d+)?[sdifgqxX%]/g, (spec) => {
|
|
796
|
+
if (spec.endsWith('%')) return '%';
|
|
797
|
+
const v = a[argIndex++] ?? null;
|
|
798
|
+
const verb = spec[spec.length - 1]!;
|
|
799
|
+
if (verb === 's' || verb === 'q') return typeof v === 'string' ? v : concatOperand(v);
|
|
800
|
+
if (verb === 'd' || verb === 'i') return String(Math.trunc(arith(v, 'string.format')));
|
|
801
|
+
if (verb === 'x') return Math.trunc(arith(v, 'string.format')).toString(16);
|
|
802
|
+
if (verb === 'X') return Math.trunc(arith(v, 'string.format')).toString(16).toUpperCase();
|
|
803
|
+
return numberToLuaString(arith(v, 'string.format'));
|
|
804
|
+
}));
|
|
805
|
+
}
|
|
806
|
+
string.set('format', luaStringFormat);
|
|
807
|
+
// `find`/`gsub` are supported ONLY in their plain-text form. Lua PATTERNS are a different
|
|
808
|
+
// language from JS regexes and a silent mistranslation would be exactly the fake success this
|
|
809
|
+
// pack forbids, so a pattern with any magic character is REFUSED by name.
|
|
810
|
+
const PLAIN_ONLY = /[.%^$*+?()[\]-]/;
|
|
811
|
+
function luaStringFind(a: LuaValue[]): LuaValue[] {
|
|
812
|
+
const s = str(a, 0, 'string.find');
|
|
813
|
+
const pattern = str(a, 1, 'string.find');
|
|
814
|
+
const plain = a.length >= 4 && luaTruthy(a[3] ?? null);
|
|
815
|
+
if (!plain && PLAIN_ONLY.test(pattern)) throw new LuaError(`unsupported Lua construct: string.find with a Lua PATTERN ('${pattern}') — this twin supports plain-text find only`);
|
|
816
|
+
const from = a.length >= 3 && a[2] !== null ? Math.max(1, Number(arith(a[2]!, 'string.find'))) : 1;
|
|
817
|
+
const idx = s.indexOf(pattern, from - 1);
|
|
818
|
+
return idx < 0 ? [null] : [idx + 1, idx + pattern.length];
|
|
819
|
+
}
|
|
820
|
+
string.set('find', luaStringFind);
|
|
821
|
+
function luaStringGsub(a: LuaValue[]): LuaValue[] {
|
|
822
|
+
const s = str(a, 0, 'string.gsub');
|
|
823
|
+
const pattern = str(a, 1, 'string.gsub');
|
|
824
|
+
if (PLAIN_ONLY.test(pattern)) throw new LuaError(`unsupported Lua construct: string.gsub with a Lua PATTERN ('${pattern}') — this twin supports plain-text substitution only`);
|
|
825
|
+
const repl = str(a, 2, 'string.gsub');
|
|
826
|
+
const parts = s.split(pattern);
|
|
827
|
+
return [parts.join(repl), parts.length - 1];
|
|
828
|
+
}
|
|
829
|
+
string.set('gsub', luaStringGsub);
|
|
830
|
+
g.declare('string', string);
|
|
831
|
+
|
|
832
|
+
// ── the `redis` table: the bridge back into this twin's real command core ──────────────────
|
|
833
|
+
const redis = new LuaTable();
|
|
834
|
+
const toArg = (v: LuaValue): string => {
|
|
835
|
+
if (typeof v === 'string') return v;
|
|
836
|
+
// Real Redis converts a Lua NUMBER argument to a string by TRUNCATING it toward zero
|
|
837
|
+
// (lua_cmsgpack-free path in scripting.c) — 3.7 becomes "3", not "3.7".
|
|
838
|
+
if (typeof v === 'number') return String(Math.trunc(v));
|
|
839
|
+
throw new LuaError('Lua redis lib command arguments must be strings or integers');
|
|
840
|
+
};
|
|
841
|
+
redis.set('call', (a) => {
|
|
842
|
+
if (a.length === 0) throw new LuaError('Please specify at least one argument for redis.call()');
|
|
843
|
+
return one(host.call(a.map(toArg), this.line));
|
|
844
|
+
});
|
|
845
|
+
const redisPcall = (a: LuaValue[]): LuaValue[] => {
|
|
846
|
+
if (a.length === 0) throw new LuaError('Please specify at least one argument for redis.pcall()');
|
|
847
|
+
try {
|
|
848
|
+
return one(host.call(a.map(toArg), this.line));
|
|
849
|
+
} catch (e) {
|
|
850
|
+
const t = new LuaTable();
|
|
851
|
+
t.set('err', e instanceof Error ? e.message : String(e));
|
|
852
|
+
return one(t);
|
|
853
|
+
}
|
|
854
|
+
};
|
|
855
|
+
redis.set('pcall', redisPcall);
|
|
856
|
+
redis.set('error_reply', (a) => { const t = new LuaTable(); t.set('err', typeof a[0] === 'string' ? a[0] : 'ERR'); return one(t); });
|
|
857
|
+
redis.set('status_reply', (a) => { const t = new LuaTable(); t.set('ok', typeof a[0] === 'string' ? a[0] : 'OK'); return one(t); });
|
|
858
|
+
redis.set('sha1hex', (a) => one(createHash('sha1').update(typeof a[0] === 'string' ? a[0] : concatOperand(a[0] ?? '')).digest('hex')));
|
|
859
|
+
redis.set('log', () => []);
|
|
860
|
+
redis.set('breakpoint', () => one(false));
|
|
861
|
+
redis.set('debug', () => []);
|
|
862
|
+
redis.set('setresp', () => []);
|
|
863
|
+
redis.set('replicate_commands', () => one(true));
|
|
864
|
+
redis.set('LOG_DEBUG', 0);
|
|
865
|
+
redis.set('LOG_VERBOSE', 1);
|
|
866
|
+
redis.set('LOG_NOTICE', 2);
|
|
867
|
+
redis.set('LOG_WARNING', 3);
|
|
868
|
+
g.declare('redis', redis);
|
|
869
|
+
g.declare('server', redis); // Redis 7 alias
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
private execBlock(stats: Stat[], scope: Scope): Signal {
|
|
873
|
+
for (const s of stats) {
|
|
874
|
+
const sig = this.execStat(s, scope);
|
|
875
|
+
if (sig) return sig;
|
|
876
|
+
}
|
|
877
|
+
return null;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
private execStat(s: Stat, scope: Scope): Signal {
|
|
881
|
+
this.tick();
|
|
882
|
+
switch (s.t) {
|
|
883
|
+
case 'local': {
|
|
884
|
+
const values = this.evalList(s.exprs, scope, s.names.length);
|
|
885
|
+
s.names.forEach((n, i) => scope.declare(n, values[i] ?? null));
|
|
886
|
+
return null;
|
|
887
|
+
}
|
|
888
|
+
case 'assign': {
|
|
889
|
+
const values = this.evalList(s.exprs, scope, s.targets.length);
|
|
890
|
+
s.targets.forEach((target, i) => {
|
|
891
|
+
const v = values[i] ?? null;
|
|
892
|
+
if (target.t === 'name') { scope.set(target.v, v); return; }
|
|
893
|
+
if (target.t === 'index') {
|
|
894
|
+
const obj = this.evalExpr(target.obj, scope);
|
|
895
|
+
if (!(obj instanceof LuaTable)) throw new LuaError(`attempt to index a ${luaTypeName(obj)} value`);
|
|
896
|
+
obj.set(this.evalExpr(target.key, scope), v);
|
|
897
|
+
return;
|
|
898
|
+
}
|
|
899
|
+
throw new LuaError('invalid assignment target');
|
|
900
|
+
});
|
|
901
|
+
return null;
|
|
902
|
+
}
|
|
903
|
+
case 'callstat': this.evalMulti(s.call, scope); return null;
|
|
904
|
+
case 'do': return this.execBlock(s.body, new Scope(scope));
|
|
905
|
+
case 'if': {
|
|
906
|
+
for (const b of s.branches) {
|
|
907
|
+
if (luaTruthy(this.evalExpr(b.cond, scope))) return this.execBlock(b.body, new Scope(scope));
|
|
908
|
+
}
|
|
909
|
+
return s.orelse ? this.execBlock(s.orelse, new Scope(scope)) : null;
|
|
910
|
+
}
|
|
911
|
+
case 'while': {
|
|
912
|
+
while (luaTruthy(this.evalExpr(s.cond, scope))) {
|
|
913
|
+
this.tick();
|
|
914
|
+
const sig = this.execBlock(s.body, new Scope(scope));
|
|
915
|
+
if (sig === BREAK) break;
|
|
916
|
+
if (sig) return sig;
|
|
917
|
+
}
|
|
918
|
+
return null;
|
|
919
|
+
}
|
|
920
|
+
case 'repeat': {
|
|
921
|
+
for (;;) {
|
|
922
|
+
this.tick();
|
|
923
|
+
// Lua scopes the `until` condition INSIDE the loop body's scope.
|
|
924
|
+
const inner = new Scope(scope);
|
|
925
|
+
const sig = this.execBlock(s.body, inner);
|
|
926
|
+
if (sig === BREAK) break;
|
|
927
|
+
if (sig) return sig;
|
|
928
|
+
if (luaTruthy(this.evalExpr(s.cond, inner))) break;
|
|
929
|
+
}
|
|
930
|
+
return null;
|
|
931
|
+
}
|
|
932
|
+
case 'fornum': {
|
|
933
|
+
const from = arith(this.evalExpr(s.from, scope), "'for' initial value");
|
|
934
|
+
const to = arith(this.evalExpr(s.to, scope), "'for' limit");
|
|
935
|
+
const step = s.step ? arith(this.evalExpr(s.step, scope), "'for' step") : 1;
|
|
936
|
+
if (step === 0) throw new LuaError("'for' step is zero");
|
|
937
|
+
for (let i = from; step > 0 ? i <= to : i >= to; i += step) {
|
|
938
|
+
this.tick();
|
|
939
|
+
const inner = new Scope(scope);
|
|
940
|
+
inner.declare(s.name, i);
|
|
941
|
+
const sig = this.execBlock(s.body, inner);
|
|
942
|
+
if (sig === BREAK) break;
|
|
943
|
+
if (sig) return sig;
|
|
944
|
+
}
|
|
945
|
+
return null;
|
|
946
|
+
}
|
|
947
|
+
case 'forin': {
|
|
948
|
+
const [iter, state, initial] = this.evalList(s.exprs, scope, 3);
|
|
949
|
+
if (typeof iter !== 'function') throw new LuaError(`attempt to call a ${luaTypeName(iter ?? null)} value in a generic for`);
|
|
950
|
+
let control: LuaValue = initial ?? null;
|
|
951
|
+
for (;;) {
|
|
952
|
+
this.tick();
|
|
953
|
+
const results = iter([state ?? null, control]);
|
|
954
|
+
if ((results[0] ?? null) === null) break;
|
|
955
|
+
control = results[0]!;
|
|
956
|
+
const inner = new Scope(scope);
|
|
957
|
+
s.names.forEach((n, i) => inner.declare(n, results[i] ?? null));
|
|
958
|
+
const sig = this.execBlock(s.body, inner);
|
|
959
|
+
if (sig === BREAK) break;
|
|
960
|
+
if (sig) return sig;
|
|
961
|
+
}
|
|
962
|
+
return null;
|
|
963
|
+
}
|
|
964
|
+
case 'return': return { ret: this.evalList(s.exprs, scope, s.exprs.length) };
|
|
965
|
+
case 'break': return BREAK;
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
/** Evaluate an expression list, expanding the LAST expression's multiple returns (Lua rule). */
|
|
970
|
+
private evalList(exprs: Expr[], scope: Scope, want: number): LuaValue[] {
|
|
971
|
+
const out: LuaValue[] = [];
|
|
972
|
+
exprs.forEach((e, i) => {
|
|
973
|
+
if (i === exprs.length - 1) out.push(...this.evalMulti(e, scope));
|
|
974
|
+
else out.push(this.evalExpr(e, scope));
|
|
975
|
+
});
|
|
976
|
+
while (out.length < want) out.push(null);
|
|
977
|
+
return out;
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
/** Evaluate an expression in "multiple results" position. */
|
|
981
|
+
private evalMulti(e: Expr, scope: Scope): LuaValue[] {
|
|
982
|
+
if (e.t !== 'call') return [this.evalExpr(e, scope)];
|
|
983
|
+
const fn = this.evalExpr(e.fn, scope);
|
|
984
|
+
if (typeof fn !== 'function') {
|
|
985
|
+
const name = e.fn.t === 'name' ? ` (global '${e.fn.v}')` : e.fn.t === 'index' && e.fn.key.t === 'str' ? ` (field '${e.fn.key.v}')` : '';
|
|
986
|
+
throw new LuaError(`attempt to call a ${luaTypeName(fn)} value${name} — unsupported or undefined in this twin's Lua subset`);
|
|
987
|
+
}
|
|
988
|
+
const args = this.evalList(e.args, scope, e.args.length);
|
|
989
|
+
this.tick();
|
|
990
|
+
if (e.line !== undefined) this.line = e.line;
|
|
991
|
+
return fn(args);
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
private evalExpr(e: Expr, scope: Scope): LuaValue {
|
|
995
|
+
this.tick();
|
|
996
|
+
switch (e.t) {
|
|
997
|
+
case 'nil': return null;
|
|
998
|
+
case 'true': return true;
|
|
999
|
+
case 'false': return false;
|
|
1000
|
+
case 'num': return e.v;
|
|
1001
|
+
case 'str': return e.v;
|
|
1002
|
+
case 'name': {
|
|
1003
|
+
if (!scope.has(e.v)) {
|
|
1004
|
+
// A Lua global that was never assigned reads as nil. That is correct Lua — but for the
|
|
1005
|
+
// handful of names that are LIBRARIES this interpreter deliberately does not ship, a
|
|
1006
|
+
// silent nil turns into "attempt to call a nil value" three lines later, which reads as
|
|
1007
|
+
// a script bug rather than a twin limitation. Name them here instead.
|
|
1008
|
+
if (['cjson', 'cmsgpack', 'bit', 'struct', 'os', 'io', 'setmetatable', 'getmetatable', 'pcall', 'xpcall', 'loadstring', 'require', 'coroutine'].includes(e.v)) {
|
|
1009
|
+
throw new LuaError(`unsupported Lua library '${e.v}' — this twin runs a documented Lua SUBSET (see upstash-lua.ts)`);
|
|
1010
|
+
}
|
|
1011
|
+
return null;
|
|
1012
|
+
}
|
|
1013
|
+
return scope.get(e.v);
|
|
1014
|
+
}
|
|
1015
|
+
case 'index': {
|
|
1016
|
+
const obj = this.evalExpr(e.obj, scope);
|
|
1017
|
+
if (!(obj instanceof LuaTable)) {
|
|
1018
|
+
if (typeof obj === 'string') throw new LuaError('unsupported Lua construct: indexing a string value (string metatable methods are not modeled)');
|
|
1019
|
+
throw new LuaError(`attempt to index a ${luaTypeName(obj)} value`);
|
|
1020
|
+
}
|
|
1021
|
+
return obj.get(this.evalExpr(e.key, scope));
|
|
1022
|
+
}
|
|
1023
|
+
case 'call': return this.evalMulti(e, scope)[0] ?? null;
|
|
1024
|
+
case 'table': {
|
|
1025
|
+
const t = new LuaTable();
|
|
1026
|
+
e.array.forEach((item, i) => {
|
|
1027
|
+
// The LAST array item expands its multiple returns, exactly as Lua does.
|
|
1028
|
+
if (i === e.array.length - 1) this.evalMulti(item, scope).forEach((v, j) => t.set(i + 1 + j, v));
|
|
1029
|
+
else t.set(i + 1, this.evalExpr(item, scope));
|
|
1030
|
+
});
|
|
1031
|
+
for (const p of e.pairs) t.set(this.evalExpr(p.k, scope), this.evalExpr(p.v, scope));
|
|
1032
|
+
return t;
|
|
1033
|
+
}
|
|
1034
|
+
case 'un': {
|
|
1035
|
+
if (e.op === 'not') return !luaTruthy(this.evalExpr(e.e, scope));
|
|
1036
|
+
const v = this.evalExpr(e.e, scope);
|
|
1037
|
+
if (e.op === '-') return -arith(v, 'unary -');
|
|
1038
|
+
if (e.op === '#') {
|
|
1039
|
+
if (typeof v === 'string') return v.length;
|
|
1040
|
+
if (v instanceof LuaTable) return v.length;
|
|
1041
|
+
throw new LuaError(`attempt to get length of a ${luaTypeName(v)} value`);
|
|
1042
|
+
}
|
|
1043
|
+
throw new LuaError(`unsupported unary operator '${e.op}'`);
|
|
1044
|
+
}
|
|
1045
|
+
case 'bin': return this.evalBinary(e, scope);
|
|
1046
|
+
case 'func': {
|
|
1047
|
+
const params = e.params;
|
|
1048
|
+
const body = e.body;
|
|
1049
|
+
const closure: LuaFunction = (args) => {
|
|
1050
|
+
const inner = new Scope(scope);
|
|
1051
|
+
params.forEach((p, i) => inner.declare(p, args[i] ?? null));
|
|
1052
|
+
const sig = this.execBlock(body, inner);
|
|
1053
|
+
if (sig === BREAK) throw new LuaError("'break' outside a loop");
|
|
1054
|
+
return sig && 'ret' in sig ? sig.ret : [];
|
|
1055
|
+
};
|
|
1056
|
+
return closure;
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
private evalBinary(e: Extract<Expr, { t: 'bin' }>, scope: Scope): LuaValue {
|
|
1062
|
+
// `and`/`or` short-circuit and return an OPERAND, not a boolean.
|
|
1063
|
+
if (e.op === 'and') {
|
|
1064
|
+
const l = this.evalExpr(e.l, scope);
|
|
1065
|
+
return luaTruthy(l) ? this.evalExpr(e.r, scope) : l;
|
|
1066
|
+
}
|
|
1067
|
+
if (e.op === 'or') {
|
|
1068
|
+
const l = this.evalExpr(e.l, scope);
|
|
1069
|
+
return luaTruthy(l) ? l : this.evalExpr(e.r, scope);
|
|
1070
|
+
}
|
|
1071
|
+
const l = this.evalExpr(e.l, scope);
|
|
1072
|
+
const r = this.evalExpr(e.r, scope);
|
|
1073
|
+
switch (e.op) {
|
|
1074
|
+
case '+': return arith(l, '+') + arith(r, '+');
|
|
1075
|
+
case '-': return arith(l, '-') - arith(r, '-');
|
|
1076
|
+
case '*': return arith(l, '*') * arith(r, '*');
|
|
1077
|
+
case '/': return arith(l, '/') / arith(r, '/');
|
|
1078
|
+
case '^': return arith(l, '^') ** arith(r, '^');
|
|
1079
|
+
case '%': {
|
|
1080
|
+
// Lua's `%` is `a - floor(a/b)*b` — the result takes the SIGN OF THE DIVISOR, unlike JS.
|
|
1081
|
+
const a = arith(l, '%');
|
|
1082
|
+
const b = arith(r, '%');
|
|
1083
|
+
return a - Math.floor(a / b) * b;
|
|
1084
|
+
}
|
|
1085
|
+
case '..': return concatOperand(l) + concatOperand(r);
|
|
1086
|
+
// STRICT equality across types — `"3" == 3` is FALSE in Lua. See the file header (2).
|
|
1087
|
+
case '==': return luaEquals(l, r);
|
|
1088
|
+
case '~=': return !luaEquals(l, r);
|
|
1089
|
+
case '<': case '<=': case '>': case '>=': {
|
|
1090
|
+
if (typeof l === 'number' && typeof r === 'number') {
|
|
1091
|
+
return e.op === '<' ? l < r : e.op === '<=' ? l <= r : e.op === '>' ? l > r : l >= r;
|
|
1092
|
+
}
|
|
1093
|
+
if (typeof l === 'string' && typeof r === 'string') {
|
|
1094
|
+
const c = l < r ? -1 : l > r ? 1 : 0;
|
|
1095
|
+
return e.op === '<' ? c < 0 : e.op === '<=' ? c <= 0 : e.op === '>' ? c > 0 : c >= 0;
|
|
1096
|
+
}
|
|
1097
|
+
throw new LuaError(`attempt to compare ${luaTypeName(l)} with ${luaTypeName(r)}`);
|
|
1098
|
+
}
|
|
1099
|
+
default: throw new LuaError(`unsupported binary operator '${e.op}'`);
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
/** Lua `==`: same type required (no string/number coercion); tables compare by identity. */
|
|
1105
|
+
export function luaEquals(l: LuaValue, r: LuaValue): boolean {
|
|
1106
|
+
if (l === null || r === null) return l === r;
|
|
1107
|
+
if (typeof l !== typeof r) return false;
|
|
1108
|
+
return l === r;
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
/** Parse + run a script. Returns the Lua value the chunk returned (or `nil`). */
|
|
1112
|
+
export function runLua(script: string, host: LuaHost): LuaValue {
|
|
1113
|
+
const chunk = parseLua(script);
|
|
1114
|
+
return new Interpreter(host).run(chunk);
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
/** The SHA1 real Redis keys the script cache by — the same hex digest `SCRIPT LOAD` returns. */
|
|
1118
|
+
export function luaScriptSha1(script: string): string {
|
|
1119
|
+
return createHash('sha1').update(script).digest('hex');
|
|
1120
|
+
}
|