@tryty/easydc-js 0.0.0-stage → 1.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/src/parser.js ADDED
@@ -0,0 +1,168 @@
1
+ 'use strict';
2
+ const { ParseError } = require('./errors');
3
+
4
+ // Karakter yang bisa di-escape dengan backslash: \$ \[ \] \; \\
5
+ const ESCAPABLE = '$[];\\';
6
+ const IDENT = /[A-Za-z][A-Za-z0-9_]*/y;
7
+ const BLOCK = new Set(['if', 'elseif', 'else', 'endif']);
8
+
9
+ function lineCol(root, pos) {
10
+ let line = 1;
11
+ let col = 1;
12
+ for (let i = 0; i < pos && i < root.length; i++) {
13
+ if (root[i] === '\n') { line++; col = 1; } else col++;
14
+ }
15
+ return { line, col };
16
+ }
17
+
18
+ function fail(message, pos, state) {
19
+ return new ParseError(message, lineCol(state.root, pos));
20
+ }
21
+
22
+ /** Cari "]" penutup yang cocok untuk "[" di indeks `open` (menghormati escape & nesting). */
23
+ function findClose(src, open) {
24
+ let depth = 0;
25
+ for (let i = open; i < src.length; i++) {
26
+ const c = src[i];
27
+ if (c === '\\') { i++; continue; }
28
+ if (c === '[') depth++;
29
+ else if (c === ']') {
30
+ depth--;
31
+ if (depth === 0) return i;
32
+ }
33
+ }
34
+ return -1;
35
+ }
36
+
37
+ /** Pecah isi kurung dengan ";" level-0 lalu parse tiap argumen. */
38
+ function splitArgs(inner, base, state) {
39
+ if (inner === '') return [];
40
+ const ranges = [];
41
+ let depth = 0;
42
+ let start = 0;
43
+ for (let i = 0; i < inner.length; i++) {
44
+ const c = inner[i];
45
+ if (c === '\\') { i++; continue; }
46
+ if (c === '[') depth++;
47
+ else if (c === ']') depth--;
48
+ else if (c === ';' && depth === 0) { ranges.push([start, i]); start = i + 1; }
49
+ }
50
+ ranges.push([start, inner.length]);
51
+ return ranges.map(([s, e]) => structure(parseFlat(inner.slice(s, e), base + s, state), state));
52
+ }
53
+
54
+ function describeArity(def) {
55
+ if (def.min === def.max) return `tepat ${def.min}`;
56
+ if (def.max === Infinity) return `minimal ${def.min}`;
57
+ return `${def.min}-${def.max}`;
58
+ }
59
+
60
+ /** Tahap 1: teks + pemanggilan fungsi (belum ada struktur $if). */
61
+ function parseFlat(src, base, state) {
62
+ const nodes = [];
63
+ let text = '';
64
+ const flush = () => {
65
+ if (text) { nodes.push({ type: 'text', value: text }); text = ''; }
66
+ };
67
+ let i = 0;
68
+ while (i < src.length) {
69
+ const ch = src[i];
70
+ if (ch === '\\' && i + 1 < src.length && ESCAPABLE.includes(src[i + 1])) {
71
+ text += src[i + 1];
72
+ i += 2;
73
+ continue;
74
+ }
75
+ if (ch !== '$') { text += ch; i++; continue; }
76
+
77
+ IDENT.lastIndex = i + 1;
78
+ const m = IDENT.exec(src);
79
+ if (!m) { text += '$'; i++; continue; } // "$5", "$" sendirian = teks biasa
80
+
81
+ const ident = m[0];
82
+ let len = 0;
83
+ for (let l = ident.length; l >= 1; l--) {
84
+ if (state.registry.has(ident.slice(0, l).toLowerCase())) { len = l; break; }
85
+ }
86
+ if (!len) {
87
+ throw fail(`Fungsi $${ident} tidak dikenal (kalau maksudnya teks biasa, tulis \\$${ident})`, base + i, state);
88
+ }
89
+ const def = state.registry.get(ident.slice(0, len).toLowerCase());
90
+ let j = i + 1 + len;
91
+ let args = null;
92
+ if (src[j] === '[' && def.brackets !== 'none') {
93
+ const close = findClose(src, j);
94
+ if (close === -1) throw fail(`Tanda ] penutup untuk ${def.display} tidak ditemukan`, base + i, state);
95
+ args = splitArgs(src.slice(j + 1, close), base + j + 1, state);
96
+ j = close + 1;
97
+ }
98
+ if (def.brackets === 'required' && args === null) {
99
+ throw fail(`${def.display} wajib memakai tanda kurung, contoh: ${def.display}[...]`, base + i, state);
100
+ }
101
+ const n = args ? args.length : 0;
102
+ if (n < def.min || n > def.max) {
103
+ throw fail(`${def.display} membutuhkan ${describeArity(def)} argumen, tetapi diberi ${n}`, base + i, state);
104
+ }
105
+ flush();
106
+ nodes.push({ type: 'fn', name: def.name, def, args, pos: base + i });
107
+ if (BLOCK.has(def.name)) {
108
+ if (src[j] === '\r' && src[j + 1] === '\n') j += 2;
109
+ else if (src[j] === '\n') j += 1;
110
+ }
111
+ i = j;
112
+ }
113
+ flush();
114
+ return nodes;
115
+ }
116
+
117
+ /** Tahap 2: ubah $if/$elseif/$else/$endif menjadi node blok. */
118
+ function structure(nodes, state) {
119
+ const root = { kind: 'root', body: [] };
120
+ const stack = [root];
121
+ const top = () => stack[stack.length - 1];
122
+
123
+ for (const node of nodes) {
124
+ if (node.type !== 'fn' || !BLOCK.has(node.def.name)) { top().body.push(node); continue; }
125
+ const name = node.def.name;
126
+ if (name === 'if') {
127
+ const frame = { kind: 'if', pos: node.pos, branches: [], body: null, sawElse: false };
128
+ const branch = { cond: node.args[0], body: [] };
129
+ frame.branches.push(branch);
130
+ frame.body = branch.body;
131
+ stack.push(frame);
132
+ continue;
133
+ }
134
+ const frame = top();
135
+ if (frame.kind !== 'if') throw fail(`${node.def.display} tanpa $if di depannya`, node.pos, state);
136
+ if (name === 'elseif') {
137
+ if (frame.sawElse) throw fail('$elseif tidak boleh setelah $else', node.pos, state);
138
+ const branch = { cond: node.args[0], body: [] };
139
+ frame.branches.push(branch);
140
+ frame.body = branch.body;
141
+ } else if (name === 'else') {
142
+ if (frame.sawElse) throw fail('$else tidak boleh dipakai dua kali dalam satu $if', node.pos, state);
143
+ frame.sawElse = true;
144
+ const branch = { cond: null, body: [] };
145
+ frame.branches.push(branch);
146
+ frame.body = branch.body;
147
+ } else {
148
+ stack.pop();
149
+ top().body.push({ type: 'if', branches: frame.branches, pos: frame.pos });
150
+ }
151
+ }
152
+ if (stack.length > 1) {
153
+ throw fail('$if tidak ditutup, tambahkan $endif', top().pos, state);
154
+ }
155
+ return root.body;
156
+ }
157
+
158
+ /**
159
+ * Parse kode menjadi AST. `registry` = Map(namaLowercase -> definisi fungsi).
160
+ * Melempar ParseError (dengan baris/kolom) bila ada kesalahan.
161
+ */
162
+ function parse(code, registry) {
163
+ const src = String(code ?? '');
164
+ const state = { root: src, registry };
165
+ return structure(parseFlat(src, 0, state), state);
166
+ }
167
+
168
+ module.exports = { parse };
package/src/util.js ADDED
@@ -0,0 +1,188 @@
1
+ 'use strict';
2
+ const { BotError } = require('./errors');
3
+
4
+ const UNITS = { ms: 1, s: 1000, m: 60000, h: 3600000, d: 86400000, w: 604800000 };
5
+
6
+ /** "10s", "5m", "1h30m", "1500" (ms) -> milidetik. */
7
+ function parseTime(input) {
8
+ const s = String(input ?? '').trim().toLowerCase().replace(/\s+/g, '');
9
+ if (!s) throw new BotError('Waktu kosong (contoh: 10s, 5m, 1h30m)');
10
+ if (/^\d+$/.test(s)) return Number(s);
11
+ const re = /(\d+(?:\.\d+)?)(ms|s|m|h|d|w)/y;
12
+ let total = 0;
13
+ let idx = 0;
14
+ while (idx < s.length) {
15
+ re.lastIndex = idx;
16
+ const m = re.exec(s);
17
+ if (!m) throw new BotError(`Format waktu salah: "${input}" (contoh: 10s, 5m, 1h30m)`);
18
+ total += Number(m[1]) * UNITS[m[2]];
19
+ idx = re.lastIndex;
20
+ }
21
+ return Math.round(total);
22
+ }
23
+
24
+ /** ms -> "1d 2h 3m 4s". */
25
+ function formatDuration(ms) {
26
+ let s = Math.floor(Math.max(0, Number(ms) || 0) / 1000);
27
+ const d = Math.floor(s / 86400);
28
+ s -= d * 86400;
29
+ const h = Math.floor(s / 3600);
30
+ s -= h * 3600;
31
+ const m = Math.floor(s / 60);
32
+ s -= m * 60;
33
+ const parts = [];
34
+ if (d) parts.push(`${d}d`);
35
+ if (h) parts.push(`${h}h`);
36
+ if (m) parts.push(`${m}m`);
37
+ if (s || !parts.length) parts.push(`${s}s`);
38
+ return parts.join(' ');
39
+ }
40
+
41
+ /** Format angka hasil hitungan tanpa noise floating point. */
42
+ function formatNumber(n) {
43
+ if (!Number.isFinite(n)) throw new BotError('Hasil hitungan bukan angka yang valid');
44
+ if (Number.isInteger(n)) return String(n);
45
+ return String(parseFloat(n.toPrecision(12)));
46
+ }
47
+
48
+ /** Kalkulator aman (tanpa eval). Mendukung + - * / % ^ ( ) dan fungsi umum. */
49
+ function safeMath(expr) {
50
+ const src = String(expr ?? '').toLowerCase().replace(/\s+/g, '').replace(/\*\*/g, '^');
51
+ if (!src) throw new BotError('Ekspresi matematika kosong');
52
+ const FUNCS = {
53
+ sqrt: Math.sqrt, abs: Math.abs, round: Math.round, floor: Math.floor, ceil: Math.ceil,
54
+ trunc: Math.trunc, sign: Math.sign, log: Math.log, log10: Math.log10, min: Math.min,
55
+ max: Math.max, pow: Math.pow,
56
+ };
57
+ const CONSTS = { pi: Math.PI, e: Math.E };
58
+ let i = 0;
59
+
60
+ const fail = (msg) => { throw new BotError(`${msg} pada "${expr}"`); };
61
+ const peek = () => src[i];
62
+
63
+ function parseExpr() {
64
+ let v = parseTerm();
65
+ while (peek() === '+' || peek() === '-') {
66
+ const op = src[i++];
67
+ const r = parseTerm();
68
+ v = op === '+' ? v + r : v - r;
69
+ }
70
+ return v;
71
+ }
72
+ function parseTerm() {
73
+ let v = parseUnary();
74
+ while (peek() === '*' || peek() === '/' || peek() === '%') {
75
+ const op = src[i++];
76
+ const r = parseUnary();
77
+ if (op === '*') v *= r;
78
+ else {
79
+ if (r === 0) throw new BotError('Pembagian dengan nol');
80
+ v = op === '/' ? v / r : v % r;
81
+ }
82
+ }
83
+ return v;
84
+ }
85
+ function parseUnary() {
86
+ if (peek() === '-') { i++; return -parseUnary(); }
87
+ if (peek() === '+') { i++; return parseUnary(); }
88
+ return parsePow();
89
+ }
90
+ function parsePow() {
91
+ const base = parsePrimary();
92
+ if (peek() === '^') {
93
+ i++;
94
+ return Math.pow(base, parseUnary());
95
+ }
96
+ return base;
97
+ }
98
+ function parsePrimary() {
99
+ if (peek() === '(') {
100
+ i++;
101
+ const v = parseExpr();
102
+ if (peek() !== ')') fail('Kurung tutup ")" hilang');
103
+ i++;
104
+ return v;
105
+ }
106
+ const numRe = /\d+(?:\.\d+)?|\.\d+/y;
107
+ numRe.lastIndex = i;
108
+ const nm = numRe.exec(src);
109
+ if (nm) { i = numRe.lastIndex; return Number(nm[0]); }
110
+ const idRe = /[a-z_][a-z0-9_]*/y;
111
+ idRe.lastIndex = i;
112
+ const im = idRe.exec(src);
113
+ if (im) {
114
+ i = idRe.lastIndex;
115
+ const name = im[0];
116
+ if (peek() === '(') {
117
+ if (!Object.hasOwn(FUNCS, name)) fail(`Fungsi "${name}" tidak dikenal`);
118
+ i++;
119
+ const args = [];
120
+ if (peek() !== ')') {
121
+ args.push(parseExpr());
122
+ while (peek() === ',') { i++; args.push(parseExpr()); }
123
+ }
124
+ if (peek() !== ')') fail('Kurung tutup ")" hilang');
125
+ i++;
126
+ return FUNCS[name](...args);
127
+ }
128
+ if (Object.hasOwn(CONSTS, name)) return CONSTS[name];
129
+ fail(`"${name}" tidak dikenal`);
130
+ }
131
+ return fail(i >= src.length ? 'Ekspresi tidak lengkap' : `Karakter "${src[i]}" tidak valid`);
132
+ }
133
+
134
+ const result = parseExpr();
135
+ if (i < src.length) fail(`Karakter "${src[i]}" tidak valid`);
136
+ return result;
137
+ }
138
+
139
+ /** Pecah teks panjang jadi potongan <= limit (Discord: 2000). */
140
+ function splitMessage(text, limit = 2000) {
141
+ const s = String(text ?? '');
142
+ if (!s.trim()) return [];
143
+ if (s.length <= limit) return [s];
144
+ const chunks = [];
145
+ let current = '';
146
+ const push = () => { if (current) { chunks.push(current); current = ''; } };
147
+ for (const line of s.split('\n')) {
148
+ let rest = line;
149
+ while (rest.length > limit) {
150
+ push();
151
+ chunks.push(rest.slice(0, limit));
152
+ rest = rest.slice(limit);
153
+ }
154
+ const candidate = current ? `${current}\n${rest}` : rest;
155
+ if (candidate.length > limit) { push(); current = rest; } else current = candidate;
156
+ }
157
+ push();
158
+ return chunks;
159
+ }
160
+
161
+ /** Format tanggal sederhana: token YYYY MM DD HH mm ss pada zona waktu tertentu. */
162
+ function formatDate(ms, format, timeZone) {
163
+ let parts;
164
+ try {
165
+ parts = new Intl.DateTimeFormat('en-CA', {
166
+ timeZone, year: 'numeric', month: '2-digit', day: '2-digit',
167
+ hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23',
168
+ }).formatToParts(new Date(ms));
169
+ } catch (e) {
170
+ throw new BotError(`Zona waktu "${timeZone}" tidak valid atau tanggal salah`, { cause: e });
171
+ }
172
+ const map = {};
173
+ for (const p of parts) map[p.type] = p.value;
174
+ const tokens = { YYYY: map.year, MM: map.month, DD: map.day, HH: map.hour, mm: map.minute, ss: map.second };
175
+ return String(format).replace(/YYYY|MM|DD|HH|mm|ss/g, (t) => tokens[t]);
176
+ }
177
+
178
+ /** 1234567 -> "1.234.567" (pemisah bisa diganti). */
179
+ function numberSeparator(value, sep = ',') {
180
+ const s = String(value).trim();
181
+ if (!/^-?\d+(\.\d+)?$/.test(s)) throw new BotError(`"${s}" bukan angka`);
182
+ const neg = s.startsWith('-');
183
+ const [int, dec] = s.replace('-', '').split('.');
184
+ const grouped = int.replace(/\B(?=(\d{3})+(?!\d))/g, sep);
185
+ return `${neg ? '-' : ''}${grouped}${dec ? `.${dec}` : ''}`;
186
+ }
187
+
188
+ module.exports = { parseTime, formatDuration, formatNumber, safeMath, splitMessage, formatDate, numberSeparator };