From de0818ad89434b63ed82d49b57723e41ee4466ff Mon Sep 17 00:00:00 2001 From: eunhhu Date: Sat, 26 Sep 2026 17:55:14 +0100 Subject: Add time: now, every timers, floor and % now is the time of the event a handler runs for (milliseconds since 1970, UTC), which a host gives and a script can replay; views cannot read it. input tick = every 1 second declares a timer (ms, second, minute or hour; 1 ms to a day). floor(x) and x % y, taking the divisor's sign as Python does, take times apart. Checked against a Python model over 3,600 random steps; nine diagnostics. --- docs/design/002-dsl-direction.md | 24 +++++++++++++ src/compile.c | 65 ++++++++++++++++++++++++----------- tests/check.py | 74 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 144 insertions(+), 19 deletions(-) diff --git a/docs/design/002-dsl-direction.md b/docs/design/002-dsl-direction.md index 0e7e8ca..06cd5f3 100644 --- a/docs/design/002-dsl-direction.md +++ b/docs/design/002-dsl-direction.md @@ -245,6 +245,30 @@ view summary = words.counted(tasks.count, "task", "tasks") 검증: 라이브러리를 쓴 할 일 목록이 참조 모델과 일치하고, 인자를 한 번만 계산하며(값 전달), 라이브러리 경로·순환· 상태 선언·앱 상태 읽기 오류가 해당 파일 위치로 보고된다. 같은 소스와 라이브러리는 같은 모듈을 만든다. 진단 10건을 더했다. +### 시간 + +```text +state started = 0 +state last = 0 +input tick = every 1 second # ms, second, minute, hour (1 ms–24시간) +on tick { + if started == 0 { started = now } # now: 이 이벤트의 시각, 1970년부터 ms(UTC) + last = now +} +view elapsed = floor((last - started) / 1000) +view clock = "{floor(last / 3600000) % 24}:{floor(last / 60000) % 60}:{floor(last / 1000) % 60}" +``` + +- **시간은 입력이다.** `now`는 핸들러에서만 읽는 이벤트의 시각이다. 호스트가 이벤트마다 정하고 스크립트는 아무 시각이나 + 재현한다(`pure-run`의 `time MS`). 보기에서는 쓸 수 없다. 보기는 상태의 순수 함수라, 시계를 보이려면 타이머가 상태에 적는다. +- **타이머는 입력이다.** `input tick = every 1 second`는 호스트가 앱을 돌리는 동안 주기마다 틱을 넣는다. 틱은 키 이벤트처럼 + 저장되는 이벤트다. 놓친 틱을 몰아서 넣지는 않는다. +- **시각을 나누는 셈.** `floor(x)`와 `x % y`(곱셈과 같은 우선순위)를 더했다. `%`의 결과는 나누는 수의 부호를 따른다(파이썬과 + 같다). `-7 % 3`은 2다. 0으로 나누면 이벤트가 `divide`로 실패한다. + +검증: 무작위 시각(정수·소수 ms, 앞뒤로)과 두 타이머의 틱, 키 3,600단계에서 상태와 보기가 파이썬 모델(`math.floor`, `%`)과 같다. +진단 9건을 더했다. + ### 다시 빌드: aup watch ```sh diff --git a/src/compile.c b/src/compile.c index 26dd1cb..e9ce67a 100644 --- a/src/compile.c +++ b/src/compile.c @@ -17,9 +17,9 @@ enum { CONST, LOAD, DT, ADD, SUB, MUL, DIV, NEG, LT, LE, GT, GE, EQ, NE, AND, OR CUR_LOAD, CUR_START, CUR_END, CUR_MOVE, CUR_STORE, TEXT_INSERT, TEXT_REMOVE, TEXT_LINE, TEXT_SET, TEXT_FROM, BYTES_FROM, CUR_COUNT, LIST_COUNT, LIST_APPEND, LIST_INSERT, LIST_REMOVE, ITEM_GET, ITEM_SET, INDEX_LOAD, INDEX_STORE, INDEX_MOVE, INDEX_FIRST, INDEX_LAST, INDEX_NUM, ROW, - PICK, SLIDE, FOLD, TEXT_COMPARE, LIST_FIND, LIST_HAS }; + PICK, SLIDE, FOLD, TEXT_COMPARE, LIST_FIND, LIST_HAS, NOW, FLOOR, MOD }; enum { FOLD_COUNT, FOLD_SUM, FOLD_MAX, FOLD_MIN, FOLD_FIRST }; -enum { KIND_KEY = 1, KIND_MESSAGE = 2, KIND_TEXT = 3, FIELD_U32 = 1, FIELD_BYTES = 2, FIELD_NUMBER = 3, FIELD_TEXT = 4 }; +enum { KIND_KEY = 1, KIND_MESSAGE = 2, KIND_TEXT = 3, KIND_TIMER = 4, FIELD_U32 = 1, FIELD_BYTES = 2, FIELD_NUMBER = 3, FIELD_TEXT = 4 }; enum { STATE_NUM = 0, STATE_TEXT = 1, STATE_CURSOR = 2, STATE_LIST = 3, STATE_INDEX = 4, UNIT_CHAR = 0, UNIT_LINE = 1 }; typedef enum { NUM = 1, BYTES, TEXT, CURSOR, UNIT, INDEX } Type; typedef struct { Type type; int owner; } Kind; /* owner: a cursor's text state or an index's list state */ @@ -34,7 +34,7 @@ typedef struct { } Token; typedef enum { - E_NUMBER, E_NAME, E_DT, E_UNARY, E_BINARY, E_CALL, + E_NUMBER, E_NAME, E_DT, E_NOW, E_UNARY, E_BINARY, E_CALL, E_MEMBER, /* args[0].member */ E_DOC, /* name[key] */ E_SIZE, /* name[key].bytes */ @@ -74,6 +74,7 @@ typedef struct Stmt { typedef struct { char name[NAME], key[NAME]; int kind; + uint32_t period; /* a timer's, in milliseconds */ bool keyed; char field_names[FIELDS][NAME]; int field_types[FIELDS]; @@ -175,7 +176,8 @@ typedef struct { static const char *const reserved[] = {"state", "input", "view", "on", "if", "else", "key", "frame", "dt", "min", "max", "clamp", "message", "by", "bytes", "u32", - "reply", "require", "record", "each", "fn", "use"}; + "reply", "require", "record", "each", "fn", "use", "now", "every", + "floor"}; /* An error in the module being parsed or inlined; inside a fn it names the call. */ static _Noreturn void fail_at(Compiler *c, int line, int column, const char *format, ...) { @@ -291,7 +293,7 @@ static Token scan(Compiler *c) { token.kind = T_PUNCT; return token; } - if (strchr("=+-*/!<>(){}[],.;:", ch)) { + if (strchr("=+-*/%!<>(){}[],.;:", ch)) { token.text[0] = ch; advance(c); token.kind = T_PUNCT; @@ -373,7 +375,7 @@ static bool utf8_valid(const unsigned char *bytes, size_t size) { static const struct { const char *text; int precedence, op; } binary[] = { {"||", 1, OR}, {"&&", 2, AND}, {"==", 3, EQ}, {"!=", 3, NE}, {"<", 4, LT}, {"<=", 4, LE}, - {">", 4, GT}, {">=", 4, GE}, {"+", 5, ADD}, {"-", 5, SUB}, {"*", 6, MUL}, {"/", 6, DIV}, + {">", 4, GT}, {">=", 4, GE}, {"+", 5, ADD}, {"-", 5, SUB}, {"*", 6, MUL}, {"/", 6, DIV}, {"%", 6, MOD}, }; static Expr *node(Compiler *c, ExprKind kind, int line, int column) { @@ -638,9 +640,10 @@ static Expr *expression(Compiler *c, int minimum, bool multiline) { expect(c, ")", "')'"); } else if (token.kind == T_NAME) { static const struct { const char *name; unsigned count; int op; } calls[] = - {{"min", 2, MIN}, {"max", 2, MAX}, {"clamp", 3, CLAMP}, {"text", 1, TEXT_FROM}, {"bytes", 1, BYTES_FROM}}; + {{"min", 2, MIN}, {"max", 2, MAX}, {"clamp", 3, CLAMP}, {"text", 1, TEXT_FROM}, {"bytes", 1, BYTES_FROM}, + {"floor", 1, FLOOR}}; int call = -1, how = -1; - for (int i = 0; i < 5; ++i) if (!strcmp(token.text, calls[i].name)) call = i; + for (int i = 0; i < (int)(sizeof(calls) / sizeof(*calls)); ++i) if (!strcmp(token.text, calls[i].name)) call = i; static const char *const folds[] = {"count", "sum", "max", "min", "first"}; for (int i = 0; i < 5; ++i) if (!strcmp(token.text, folds[i])) how = i; Expr *first = NULL; @@ -679,7 +682,7 @@ static Expr *expression(Compiler *c, int minimum, bool multiline) { } if (call >= 0 && (first || is(c, "("))) { if (!first) take(c); - left = node(c, call >= 3 ? E_CONVERT : E_CALL, token.line, token.column); + left = node(c, calls[call].op == TEXT_FROM || calls[call].op == BYTES_FROM ? E_CONVERT : E_CALL, token.line, token.column); left->op = calls[call].op; newlines(c); if (first) { @@ -699,8 +702,8 @@ static Expr *expression(Compiler *c, int minimum, bool multiline) { if (left->count != calls[call].count) fail_at(c, token.line, token.column, "%s expects %u argument%s", token.text, calls[call].count, calls[call].count == 1 ? "" : "s"); - } else if (!strcmp(token.text, "dt")) { - left = node(c, E_DT, token.line, token.column); + } else if (!strcmp(token.text, "dt") || !strcmp(token.text, "now")) { + left = node(c, token.text[0] == 'd' ? E_DT : E_NOW, token.line, token.column); } else if (!strcmp(token.text, "if")) { left = conditional(c, &token); } else { @@ -725,7 +728,7 @@ parsed: if (multiline) newlines(c); int found = -1; if (c->token.kind == T_PUNCT) - for (int i = 0; i < 12; ++i) if (!strcmp(c->token.text, binary[i].text)) found = i; + for (int i = 0; i < (int)(sizeof(binary) / sizeof(*binary)); ++i) if (!strcmp(c->token.text, binary[i].text)) found = i; if (found < 0 || binary[found].precedence < minimum) break; Token op = take(c); Expr *e = node(c, E_BINARY, op.line, op.column); @@ -1275,8 +1278,27 @@ static void declaration(Compiler *c) { take(c); in->kind = KIND_TEXT; ++c->input_count; + } else if (word(c, "every")) { + /* input tick = every 1 second: ticks while a host runs the app. */ + take(c); + if (c->token.kind != T_NUMBER) fail_at(c, c->token.line, c->token.column, "expected a period, as in every 1 second"); + Token amount = take(c); + Token unit = expect_name(c, "ms, second, minute or hour"); + static const struct { const char *name; double scale; } units[] = { + {"ms", 1}, {"second", 1000}, {"seconds", 1000}, {"minute", 60000}, {"minutes", 60000}, + {"hour", 3600000}, {"hours", 3600000}}; + double scale = 0; + for (size_t u = 0; u < sizeof(units) / sizeof(*units); ++u) + if (!strcmp(unit.text, units[u].name)) scale = units[u].scale; + if (!scale) fail_at(c, unit.line, unit.column, "a timer's unit is ms, second, minute or hour"); + double period = amount.number * scale; + if (period != floor(period) || period < 1 || period > 86400000) + fail_at(c, amount.line, amount.column, "a timer ticks every 1 ms to 24 hours, in whole milliseconds"); + in->kind = KIND_TIMER; + in->period = (uint32_t)period; + ++c->input_count; } else { - if (!word(c, "key")) fail_at(c, c->token.line, c->token.column, "expected 'key', 'text' or 'message'"); + if (!word(c, "key")) fail_at(c, c->token.line, c->token.column, "expected 'key', 'text', 'message' or 'every'"); take(c); Token key = expect_name(c, "key name"); if (!key_name(key.text)) fail_at(c, key.line, key.column, "unknown key '%s'", key.text); @@ -1498,11 +1520,15 @@ static Kind value(Compiler *c, const Expr *e, bool handler, int height) { if (height + 1 > STACK) fail_at(c, e->line, e->column, "expression needs more than 64 stack values"); switch (e->kind) { case E_NUMBER: emit(c, e->line, e->column, CONST, 0, e->number); return (Kind){NUM, -1}; - case E_DT: - if (c->folding) fail_at(c, e->line, e->column, "dt is not available in count, sum, max or min"); - if (!handler) fail_at(c, e->line, e->column, "dt is only available in handlers"); - emit(c, e->line, e->column, DT, 0, 0); + case E_DT: case E_NOW: { + const char *name = e->kind == E_DT ? "dt" : "now"; + if (c->folding) fail_at(c, e->line, e->column, "%s is not available in count, sum, max or min", name); + if (!handler) + fail_at(c, e->line, e->column, e->kind == E_DT ? "dt is only available in handlers" : + "now is only available in handlers; a view shows a state that a timer or an event sets"); + emit(c, e->line, e->column, e->kind == E_DT ? DT : NOW, 0, 0); return (Kind){NUM, -1}; + } case E_TEXT: emit(c, e->line, e->column, TEXT_CONST, constant(c, e), 0); return (Kind){TEXT, -1}; case E_STRING: { int pieces = 0; @@ -2005,7 +2031,8 @@ static void generate(Compiler *c, unsigned char **module, size_t *size) { if (kind) fail_at(c, line, column, "'%s' is a %s, not an input", name, kind); fail_at(c, line, column, "unknown input '%s'", name); } - const char *what = c->inputs[input].kind == KIND_MESSAGE ? "message" : "text input"; + const char *what = c->inputs[input].kind == KIND_MESSAGE ? "message" : + c->inputs[input].kind == KIND_TIMER ? "timer" : "text input"; if (c->inputs[input].kind != KIND_KEY && c->handlers[i].edge >= 0) fail_at(c, line, column, "'%s' is a %s; write on %s { }", name, what, name); if (c->inputs[input].kind == KIND_KEY && c->handlers[i].edge < 0) @@ -2108,7 +2135,7 @@ static void generate(Compiler *c, unsigned char **module, size_t *size) { *p++ = (unsigned char)in->kind; *p++ = in->keyed; p = put16(p, 0); - p = put32(p, input_key[i]); + p = put32(p, in->kind == KIND_TIMER ? in->period : input_key[i]); p = put16(p, in->kind == KIND_MESSAGE ? first : 0); p = put16(p, in->fields); first += in->fields; diff --git a/tests/check.py b/tests/check.py index 2fba383..9bcf1ff 100644 --- a/tests/check.py +++ b/tests/check.py @@ -202,6 +202,16 @@ DIAGNOSTICS = [ # Texts compare with texts, by their bytes. ('view v = "a" < 1\n', '1:16', 'expected text, not a number'), ('view v = 1 < "a"\n', '1:14', 'expected a number, not text'), + # Time: now in handlers, timers, floor and a floored modulo. + ('view v = now\n', '1:10', 'now is only available in handlers; a view shows a state that a timer or an event sets'), + ('input t = every 0 ms\n', '1:17', 'a timer ticks every 1 ms to 24 hours, in whole milliseconds'), + ('input t = every 1.5 ms\n', '1:17', 'a timer ticks every 1 ms to 24 hours, in whole milliseconds'), + ('input t = every 25 hours\n', '1:17', 'a timer ticks every 1 ms to 24 hours, in whole milliseconds'), + ('input t = every 2 days\n', '1:19', "a timer's unit is ms, second, minute or hour"), + ('input t = every second\n', '1:17', 'expected a period, as in every 1 second'), + ('input t = every 1 second\non t.down {\n}\n', '2:4', "'t' is a timer; write on t { }"), + ('view v = floor(1, 2)\n', '1:10', 'floor expects 1 argument'), + ('state now = 1\n', '1:7', "'now' is reserved"), ] ACCEPTED = [ @@ -253,6 +263,9 @@ ACCEPTED = [ # Message fields of every type, variable ones anywhere and several of them. 'state n = 0\nstate t: text\ninput m = message {\n a: text\n b: number\n c: bytes\n d: u32\n e: text\n}\n' 'on m {\n n = m.b + m.d + m.c.bytes\n t = "{m.a}{m.e}"\n}\n', + # A timer, now, floor and %. + 'state last = 0\ninput tick = every 500 ms\ninput slow = every 2 hours\non tick { last = floor(now / 1000) % 60 }\n' + 'on slow { last = -7 % 3 }\nview v = last % 7\n', ] @@ -1483,6 +1496,66 @@ def folds(aup, pure_run, work, seeds, steps=300): return compared +TIME_SOURCE = """state started = 0 +state last = 0 +state ticks = 0 +state spread = 0 +input tick = every 250 ms +input slow = every 1 hour +input mix = key KeyM +on tick { + if started == 0 { started = now } + last = now + ticks = ticks + 1 +} +on slow { spread = (now - started) % 7 } +on mix.down { spread = floor(spread * 3.5 - 2) % -4 } +view elapsed = floor((last - started) / 1000) +view clock = "{floor(last / 3600000) % 24}:{floor(last / 60000) % 60}:{floor(last / 1000) % 60}" +view day = floor(last / 86400000) +""" + + +def timing(aup, pure_run, work, seeds, steps=300): + """now, timers, floor and % against a Python model under random times + (whole and fractional milliseconds, forwards and back), ticks of two + timers and a key: % takes the divisor's sign as Python's does.""" + import math + source = work / 'time.aup' + source.write_text(TIME_SOURCE) + compile_module(aup, source, work / 'time.pmod') + compared = 0 + for seed in seeds: + rng, now, started, last, ticks, spread = random.Random(seed + 5100), 0.0, 0.0, 0.0, 0, 0.0 + commands, expected = [], [] + for _ in range(steps): + roll = rng.random() + if roll < 0.3: + now = rng.choice([now + rng.choice([250, 1000, 61000, 3600000]), rng.uniform(0, 2e12), + float(rng.randrange(0, 10 ** 12)), now - 5000 if now > 5000 else now]) + commands.append(f'time {now!r}') + elif roll < 0.6: + commands.append('tick tick') + if started == 0: started = now + last, ticks = now, ticks + 1 + elif roll < 0.8: + commands.append('tick slow') + spread = (now - started) % 7 + else: + commands.append('down mix') + spread = math.floor(spread * 3.5 - 2) % -4 + views = {'elapsed': math.floor((last - started) / 1000), + 'clock': f'{math.floor(last / 3600000) % 24}:{math.floor(last / 60000) % 60}:{math.floor(last / 1000) % 60}', + 'day': math.floor(last / 86400000)} + expected.append(({'started': started, 'last': last, 'ticks': ticks, 'spread': spread}, views)) + lines = run(pure_run, work / 'time.pmod', stdin='\n'.join(commands) + '\n').stdout.splitlines() + shown = [json.loads(line) for line in lines] + for number, (got, (states, views)) in enumerate(zip(shown, expected)): + assert got['states'] == states and got['views'] == views, (seed, number, commands[number], got, states, views) + compared += len(shown) + return compared + + def watching(aup, pure_run, work): """aup watch: whatever the last build read, read differently, builds again; errors keep the last module; the module is only ever replaced whole.""" @@ -1691,6 +1764,7 @@ def main(): report['list_model_snapshots'], report['list_model_failed_events'] = \ tasks_parity(aup, pure_run, work, range(args.text_seeds)) report['conditional_snapshots'] = conditionals(aup, pure_run, work) + report['time_snapshots'] = timing(aup, pure_run, work, range(args.seeds)) report['watched_builds'] = watching(aup, pure_run, work) report['fold_snapshots'] = folds(aup, pure_run, work, range(args.seeds if hasattr(args, 'seeds') else 8)) report['keyed_snapshots'], report['keyed_failed_events'] = keyed(aup, pure_run, work, range(args.seeds)) -- cgit v1.2.3