aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authoreunhhu <[email protected]>2026-09-26 17:55:14 +0100
committereunhhu <[email protected]>2026-09-26 17:55:14 +0100
commitde0818ad89434b63ed82d49b57723e41ee4466ff (patch)
treea658a1981979a69e1ea0ffdd5af17f0feb54587d
parent2a050e1b9842eda4cf89f4af6fe797560c9caad6 (diff)
downloadaup-de0818ad89434b63ed82d49b57723e41ee4466ff.tar.gz
aup-de0818ad89434b63ed82d49b57723e41ee4466ff.zip
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.
-rw-r--r--docs/design/002-dsl-direction.md24
-rw-r--r--src/compile.c65
-rw-r--r--tests/check.py74
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))