diff options
| -rw-r--r-- | .gitignore | 2 | ||||
| -rw-r--r-- | Makefile | 19 | ||||
| -rw-r--r-- | README.md | 13 | ||||
| -rw-r--r-- | examples/charge.aup | 36 | ||||
| -rw-r--r-- | examples/counter.aup | 9 | ||||
| -rw-r--r-- | examples/steps.aup | 24 | ||||
| -rw-r--r-- | examples/toggle.aup | 20 | ||||
| -rw-r--r-- | src/aup.h | 19 | ||||
| -rw-r--r-- | src/compile.c | 599 | ||||
| -rw-r--r-- | src/main.c | 38 | ||||
| -rw-r--r-- | tests/check.py | 263 | ||||
| -rw-r--r-- | tests/oracle/faults.aup | 22 | ||||
| -rw-r--r-- | tests/oracle/faults.pure | 19 | ||||
| -rw-r--r-- | tests/oracle/steps.pure | 23 |
14 files changed, 1105 insertions, 1 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3d43339 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +build/ +__pycache__/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..fa03acf --- /dev/null +++ b/Makefile @@ -0,0 +1,19 @@ +CC ?= cc +CFLAGS ?= -O2 -g +CFLAGS += -std=c17 -Wall -Wextra -Wpedantic -Werror +BUILD ?= build +# The Pure checkout that owns module format v1; tests load every module with it. +PURE ?= ../pure/prototypes/connections + +.PHONY: all check +all: $(BUILD)/aup + +$(BUILD): + mkdir -p $@ + +$(BUILD)/aup: src/main.c src/compile.c src/aup.h | $(BUILD) + $(CC) $(CPPFLAGS) $(CFLAGS) src/main.c src/compile.c $(LDFLAGS) $(LDLIBS) -o $@ + +check: $(BUILD)/aup + $(MAKE) -C $(PURE) BUILD=build build/pure-run + python3 tests/check.py --aup $(BUILD)/aup --pure-run $(PURE)/build/pure-run @@ -9,7 +9,18 @@ aup는 [Pure](/srv/git/pure.git) 런타임을 위한 **언어와 툴체인**이� | **aup** | 언어와 툴체인. 소스 → 진단 → Pure 모듈 | | [Vega](/srv/git/vega.git) | aup와 Pure 위의 인터페이스 구현자(TUI·CLI·GUI·NUI) | -상태: v0 설계 중. [언어 v0](docs/design/001-language-v0.md). +상태: v0a(scalar 상태·키 입력·원자적 이벤트·보기) 동작. v0b(바이트·영속 문서) 설계 중. +[언어 v0](docs/design/001-language-v0.md). + +```sh +make # build/aup +build/aup build examples/counter.aup -o counter.pmod +printf 'down tap\nup tap\nshow\n' | ../pure/prototypes/connections/build/pure-run counter.pmod +make check # Pure 검증, 결정성, 진단, 워크벤치 엔진과의 비트 단위 대조 +``` + +`make check`는 옆의 Pure 체크아웃(`PURE=../pure/prototypes/connections`)의 검증기와 시험 호스트를 쓰고, +워크벤치 대조에는 Node가 필요하다. ## 원칙 diff --git a/examples/charge.aup b/examples/charge.aup new file mode 100644 index 0000000..d483b8f --- /dev/null +++ b/examples/charge.aup @@ -0,0 +1,36 @@ +# Hold Space to charge a jump; gravity and the floor are authored here. +state holding = 0 +state charge = 0 +state y = 240 +state velocity = 0 +input jump = key Space +view actor_y = y +view meter_width = charge * 200 +view amount = charge + +on jump.down { + if y >= 240 { + holding = 1 + charge = 0 + } +} + +on jump.up { + if holding { + velocity = -(160 + charge * 300) + holding = 0 + charge = 0 + } +} + +on frame { + if holding { + charge = clamp(charge + dt, 0, 1) + } + velocity = velocity + 600 * dt + y = y + velocity * dt + if y >= 240 { + y = 240 + velocity = 0 + } +} diff --git a/examples/counter.aup b/examples/counter.aup new file mode 100644 index 0000000..d966292 --- /dev/null +++ b/examples/counter.aup @@ -0,0 +1,9 @@ +# Space increments a count; views expose what a surface would show. +state count = 0 +input tap = key Space +view label = count +view tile_width = 40 + min(count, 20) * 8 + +on tap.down { + count = count + 1 +} diff --git a/examples/steps.aup b/examples/steps.aup new file mode 100644 index 0000000..ee4eb47 --- /dev/null +++ b/examples/steps.aup @@ -0,0 +1,24 @@ +# if / else and else if; the first matching branch runs. +state level = 0 +state band = 0 +input up = key ArrowUp +input down = key ArrowDown +view shown = band + +on up.down { + level = level + 1 +} + +on down.down { + level = level - 1 +} + +on frame { + if level > 2 { + band = 2 + } else if level > 0 { + band = 1 + } else { + band = 0 + } +} diff --git a/examples/toggle.aup b/examples/toggle.aup new file mode 100644 index 0000000..39417cc --- /dev/null +++ b/examples/toggle.aup @@ -0,0 +1,20 @@ +# Space toggles visibility; Right arrow moves a position that wraps. +state visible = 1 +state position = 120 +input toggle = key Space +input move = key ArrowRight +view marker_x = position +view marker_radius = visible * 24 +view tile_x = position - 24 +view value = visible + +on toggle.down { + visible = !visible +} + +on move.down { + position = position + 40 + if position > 560 { + position = 80 + } +} diff --git a/src/aup.h b/src/aup.h new file mode 100644 index 0000000..aa48daf --- /dev/null +++ b/src/aup.h @@ -0,0 +1,19 @@ +#ifndef AUP_H +#define AUP_H +#include <stdbool.h> +#include <stddef.h> + +/* aup v0a: source text to a Pure module v1 (Pure docs/design/008). */ +enum { AUP_SOURCE_LIMIT = 32768, AUP_MESSAGE = 160 }; + +typedef struct { + int line, column; + char message[AUP_MESSAGE]; +} AupDiagnostic; + +/* Compile source into a newly allocated module (free with free()). The same + * source always produces the same bytes. On failure *module is NULL and the + * diagnostic names the first error. */ +bool aup_compile(const char *source, size_t length, unsigned char **module, size_t *size, + AupDiagnostic *error); +#endif diff --git a/src/compile.c b/src/compile.c new file mode 100644 index 0000000..24b5c71 --- /dev/null +++ b/src/compile.c @@ -0,0 +1,599 @@ +#include "aup.h" +#include <math.h> +#include <setjmp.h> +#include <stdarg.h> +#include <stdint.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> + +/* Limits and opcodes of Pure module format v1. */ +enum { STATES = 32, INPUTS = 8, VIEWS = 24, HANDLERS = 64, CODE = 512, STACK = 64, NAME = 32, DEPTH = 64 }; +enum { CONST, LOAD, DT, ADD, SUB, MUL, DIV, NEG, LT, LE, GT, GE, EQ, NE, AND, OR, NOT, + MIN, MAX, CLAMP, STORE, JZ, JMP }; + +typedef enum { T_EOF, T_NL, T_NUMBER, T_NAME, T_PUNCT } TokenKind; +typedef struct { + TokenKind kind; + int line, column; + double number; + char text[NAME + 1]; /* name, or punctuation of one or two characters */ +} Token; + +typedef enum { E_NUMBER, E_STATE, E_DT, E_UNARY, E_BINARY, E_CALL } ExprKind; +typedef struct Expr { + ExprKind kind; + int line, column, op, state; + double number; + char name[NAME]; + struct Expr *args[3]; + unsigned count; +} Expr; + +typedef struct Stmt { + bool branch; + int line, column, state; + char name[NAME]; + Expr *value; /* assignment value or branch condition */ + struct Stmt *then, *otherwise, *next; +} Stmt; + +typedef struct { + const char *source; + size_t length, at; + int line, column; + Token token, ahead; + bool has_ahead; + unsigned depth; + jmp_buf fail; + AupDiagnostic *error; + void **blocks; + size_t block_count, block_capacity; + /* Declarations in source order. */ + char state_names[STATES][NAME]; + double initial[STATES]; + unsigned states; + char input_names[INPUTS][NAME], input_keys[INPUTS][NAME]; + unsigned inputs; + char view_names[VIEWS][NAME]; + Expr *view_value[VIEWS]; + unsigned views; + struct { char input[NAME]; int edge, line, column; Stmt *body; } handlers[HANDLERS]; + unsigned handler_count; + /* Code generation. */ + struct { uint8_t op; uint32_t arg; double value; } code[CODE]; + unsigned code_count; + uint32_t view_code[VIEWS][2], handler_code[HANDLERS][2]; +} Compiler; + +static const char *const reserved[] = {"state", "input", "view", "on", "if", "else", "key", + "frame", "dt", "min", "max", "clamp"}; + +static _Noreturn void fail_at(Compiler *c, int line, int column, const char *format, ...) { + va_list args; + va_start(args, format); + c->error->line = line; + c->error->column = column; + vsnprintf(c->error->message, sizeof(c->error->message), format, args); + va_end(args); + longjmp(c->fail, 1); +} + +static void *allocate(Compiler *c, size_t size) { + if (c->block_count == c->block_capacity) { + size_t capacity = c->block_capacity ? 2 * c->block_capacity : 64; + void **blocks = realloc(c->blocks, capacity * sizeof(*blocks)); + if (!blocks) fail_at(c, c->line, c->column, "out of memory"); + c->blocks = blocks; + c->block_capacity = capacity; + } + void *block = calloc(1, size); + if (!block) fail_at(c, c->line, c->column, "out of memory"); + return c->blocks[c->block_count++] = block; +} + +/* ---- Lexer ---------------------------------------------------------- */ + +static char peek_char(const Compiler *c, size_t offset) { + return c->at + offset < c->length ? c->source[c->at + offset] : 0; +} + +static void advance(Compiler *c) { + if (c->source[c->at++] == '\n') { ++c->line; c->column = 1; } else ++c->column; +} + +static bool digit(char ch) { return ch >= '0' && ch <= '9'; } +static bool letter(char ch) { return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || ch == '_'; } + +static Token scan(Compiler *c) { + for (;;) { + char ch = peek_char(c, 0); + if (ch == ' ' || ch == '\t' || ch == '\r') advance(c); + else if (ch == '#') while (c->at < c->length && peek_char(c, 0) != '\n') advance(c); + else break; + } + Token token = {.line = c->line, .column = c->column}; + if (c->at >= c->length) { token.kind = T_EOF; return token; } + char ch = peek_char(c, 0); + if (ch == '\n') { advance(c); token.kind = T_NL; return token; } + if (digit(ch) || (ch == '.' && digit(peek_char(c, 1)))) { + /* \d+(\.\d*)?|\.\d+ with an optional exponent, as the workbench reads it. */ + char text[400]; + size_t n = 0; + while (digit(peek_char(c, 0)) || peek_char(c, 0) == '.') { + if (peek_char(c, 0) == '.' && memchr(text, '.', n)) break; + if (n + 1 >= sizeof(text)) fail_at(c, token.line, token.column, "number is too long"); + text[n++] = peek_char(c, 0); + advance(c); + } + char e = peek_char(c, 0); + size_t sign = peek_char(c, 1) == '+' || peek_char(c, 1) == '-'; + if ((e == 'e' || e == 'E') && digit(peek_char(c, 1 + sign))) { + for (size_t i = 0; i < 1 + sign; ++i) { text[n++] = peek_char(c, 0); advance(c); } + while (digit(peek_char(c, 0))) { + if (n + 1 >= sizeof(text)) fail_at(c, token.line, token.column, "number is too long"); + text[n++] = peek_char(c, 0); + advance(c); + } + } + text[n] = 0; + token.kind = T_NUMBER; + token.number = strtod(text, NULL); + if (!isfinite(token.number)) fail_at(c, token.line, token.column, "number must be finite"); + return token; + } + if (letter(ch)) { + size_t n = 0; + while (letter(peek_char(c, 0)) || digit(peek_char(c, 0))) { + if (n + 1 >= NAME) fail_at(c, token.line, token.column, "name exceeds 31 bytes"); + token.text[n++] = peek_char(c, 0); + advance(c); + } + token.kind = T_NAME; + return token; + } + static const char *const pairs[] = {"<=", ">=", "==", "!=", "&&", "||"}; + for (size_t i = 0; i < sizeof(pairs) / sizeof(*pairs); ++i) + if (ch == pairs[i][0] && peek_char(c, 1) == pairs[i][1]) { + memcpy(token.text, pairs[i], 3); + advance(c); + advance(c); + token.kind = T_PUNCT; + return token; + } + if (strchr("=+-*/!<>(){},.;", ch)) { + token.text[0] = ch; + advance(c); + token.kind = T_PUNCT; + return token; + } + if ((unsigned char)ch >= 32 && (unsigned char)ch < 127) + fail_at(c, token.line, token.column, "unexpected character '%c'", ch); + fail_at(c, token.line, token.column, "unexpected byte 0x%02x", (unsigned char)ch); +} + +static Token take(Compiler *c) { + Token token = c->token; + if (c->has_ahead) { c->token = c->ahead; c->has_ahead = false; } + else c->token = scan(c); + return token; +} + +static const Token *lookahead(Compiler *c) { + if (!c->has_ahead) { c->ahead = scan(c); c->has_ahead = true; } + return &c->ahead; +} + +static bool is(const Compiler *c, const char *punct) { + return c->token.kind == T_PUNCT && !strcmp(c->token.text, punct); +} + +static bool word(const Compiler *c, const char *name) { + return c->token.kind == T_NAME && !strcmp(c->token.text, name); +} + +static Token expect(Compiler *c, const char *punct, const char *what) { + if (!is(c, punct)) fail_at(c, c->token.line, c->token.column, "expected %s", what); + return take(c); +} + +static Token expect_name(Compiler *c, const char *what) { + if (c->token.kind != T_NAME) fail_at(c, c->token.line, c->token.column, "expected %s", what); + return take(c); +} + +static void newlines(Compiler *c) { while (c->token.kind == T_NL) take(c); } +static void separators(Compiler *c) { while (c->token.kind == T_NL || is(c, ";")) take(c); } + +static void end(Compiler *c) { + if (c->token.kind != T_NL && c->token.kind != T_EOF && !is(c, ";") && !is(c, "}")) + fail_at(c, c->token.line, c->token.column, "expected newline or semicolon"); + separators(c); +} + +static bool reserved_name(const char *name) { + for (size_t i = 0; i < sizeof(reserved) / sizeof(*reserved); ++i) + if (!strcmp(name, reserved[i])) return true; + return false; +} + +/* ---- Parser --------------------------------------------------------- */ + +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}, +}; + +static Expr *node(Compiler *c, ExprKind kind, const Token *at) { + Expr *e = allocate(c, sizeof(*e)); + e->kind = kind; + e->line = at->line; + e->column = at->column; + return e; +} + +static Expr *expression(Compiler *c, int minimum, bool multiline) { + if (++c->depth > DEPTH) fail_at(c, c->token.line, c->token.column, "expression nesting exceeds 64"); + if (multiline) newlines(c); + Token token = take(c); + Expr *left; + if (token.kind == T_NUMBER) { + left = node(c, E_NUMBER, &token); + left->number = token.number; + } else if (token.kind == T_PUNCT && (!strcmp(token.text, "-") || !strcmp(token.text, "!"))) { + left = node(c, E_UNARY, &token); + left->op = token.text[0] == '-' ? NEG : NOT; + left->args[0] = expression(c, 7, multiline); + left->count = 1; + } else if (token.kind == T_PUNCT && !strcmp(token.text, "(")) { + left = expression(c, 0, true); + newlines(c); + 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}}; + int call = -1; + for (int i = 0; i < 3; ++i) if (!strcmp(token.text, calls[i].name)) call = i; + if (call >= 0 && is(c, "(")) { + take(c); + left = node(c, E_CALL, &token); + left->op = calls[call].op; + newlines(c); + while (!is(c, ")")) { + if (left->count == 3) fail_at(c, token.line, token.column, "%s expects %u arguments", token.text, calls[call].count); + left->args[left->count++] = expression(c, 0, true); + newlines(c); + if (!is(c, ",")) break; + take(c); + newlines(c); + } + expect(c, ")", "')'"); + if (left->count != calls[call].count) + fail_at(c, token.line, token.column, "%s expects %u arguments", token.text, calls[call].count); + } else if (!strcmp(token.text, "dt")) { + left = node(c, E_DT, &token); + } else { + if (reserved_name(token.text)) fail_at(c, token.line, token.column, "expected a number, state, or expression"); + left = node(c, E_STATE, &token); + memcpy(left->name, token.text, NAME); + } + } else fail_at(c, token.line, token.column, "expected a number, state, or expression"); + for (;;) { + 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; + if (found < 0 || binary[found].precedence < minimum) break; + Token op = take(c); + Expr *e = node(c, E_BINARY, &op); + e->op = binary[found].op; + e->args[0] = left; + e->args[1] = expression(c, binary[found].precedence + 1, multiline); + e->count = 2; + left = e; + } + --c->depth; + return left; +} + +static Stmt *block(Compiler *c, unsigned depth); + +static Stmt *statement(Compiler *c, unsigned depth) { + Token token = expect_name(c, "assignment, if, or '}'"); + Stmt *s = allocate(c, sizeof(*s)); + s->line = token.line; + s->column = token.column; + if (!strcmp(token.text, "if")) { + s->branch = true; + s->value = expression(c, 0, false); + s->then = block(c, depth + 1); + /* else may follow on the same line or after newlines */ + bool has_else = word(c, "else"); + if (!has_else && c->token.kind == T_NL) { + const Token *next = lookahead(c); + if (next->kind == T_NAME && !strcmp(next->text, "else")) { take(c); has_else = true; } + } + if (has_else) { + take(c); + if (word(c, "if")) s->otherwise = statement(c, depth + 1); + else s->otherwise = block(c, depth + 1); + } + return s; + } + if (reserved_name(token.text)) fail_at(c, token.line, token.column, "expected assignment, if, or '}'"); + memcpy(s->name, token.text, NAME); + expect(c, "=", "'='"); + s->value = expression(c, 0, false); + return s; +} + +static Stmt *block(Compiler *c, unsigned depth) { + if (depth >= DEPTH) fail_at(c, c->token.line, c->token.column, "block nesting exceeds 64"); + newlines(c); + expect(c, "{", "'{'"); + separators(c); + Stmt *first = NULL, **tail = &first; + while (!is(c, "}")) { + if (c->token.kind == T_EOF) fail_at(c, c->token.line, c->token.column, "expected '}'"); + *tail = statement(c, depth); + tail = &(*tail)->next; + end(c); + } + take(c); + return first; +} + +static const char *declared_kind(const Compiler *c, const char *name) { + for (unsigned i = 0; i < c->states; ++i) if (!strcmp(c->state_names[i], name)) return "state"; + for (unsigned i = 0; i < c->inputs; ++i) if (!strcmp(c->input_names[i], name)) return "input"; + for (unsigned i = 0; i < c->views; ++i) if (!strcmp(c->view_names[i], name)) return "view"; + return NULL; +} + +static void declare(Compiler *c, const Token *name) { + if (reserved_name(name->text)) fail_at(c, name->line, name->column, "'%s' is reserved", name->text); + const char *kind = declared_kind(c, name->text); + if (kind) fail_at(c, name->line, name->column, "'%s' is already declared as a %s", name->text, kind); +} + +static bool key_name(const char *key) { + static const char *const named[] = {"Space", "Enter", "ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"}; + for (size_t i = 0; i < sizeof(named) / sizeof(*named); ++i) if (!strcmp(key, named[i])) return true; + if (!strncmp(key, "Key", 3)) return key[3] >= 'A' && key[3] <= 'Z' && !key[4]; + if (!strncmp(key, "Digit", 5)) return key[5] >= '0' && key[5] <= '9' && !key[6]; + return false; +} + +static void declaration(Compiler *c) { + Token kind = expect_name(c, "state, input, view, or on"); + if (!strcmp(kind.text, "state")) { + Token name = expect_name(c, "state name"); + declare(c, &name); + if (c->states == STATES) fail_at(c, name.line, name.column, "at most 32 states are allowed"); + expect(c, "=", "'='"); + double sign = 1; + if (is(c, "-") || is(c, "+")) sign = take(c).text[0] == '-' ? -1 : 1; + if (c->token.kind != T_NUMBER) fail_at(c, c->token.line, c->token.column, "expected a numeric state initializer"); + memcpy(c->state_names[c->states], name.text, NAME); + c->initial[c->states++] = sign * take(c).number; + } else if (!strcmp(kind.text, "input")) { + Token name = expect_name(c, "input name"); + declare(c, &name); + if (c->inputs == INPUTS) fail_at(c, name.line, name.column, "at most 8 inputs are allowed"); + expect(c, "=", "'='"); + if (!word(c, "key")) fail_at(c, c->token.line, c->token.column, "expected 'key'"); + 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); + for (unsigned i = 0; i < c->inputs; ++i) + if (!strcmp(c->input_keys[i], key.text)) + fail_at(c, key.line, key.column, "key %s is already used by input '%s'", key.text, c->input_names[i]); + memcpy(c->input_names[c->inputs], name.text, NAME); + memcpy(c->input_keys[c->inputs++], key.text, NAME); + } else if (!strcmp(kind.text, "view")) { + Token name = expect_name(c, "view name"); + declare(c, &name); + if (c->views == VIEWS) fail_at(c, name.line, name.column, "at most 24 views are allowed"); + expect(c, "=", "'='"); + memcpy(c->view_names[c->views], name.text, NAME); + c->view_value[c->views++] = expression(c, 0, false); + } else if (!strcmp(kind.text, "on")) { + if (c->handler_count == HANDLERS) fail_at(c, kind.line, kind.column, "at most 64 handlers are allowed"); + Token event = expect_name(c, "frame or an input event"); + unsigned index = c->handler_count++; + c->handlers[index].line = event.line; + c->handlers[index].column = event.column; + if (strcmp(event.text, "frame")) { + /* The input may be declared later; it is resolved at generation. */ + expect(c, ".", "'.' and down or up"); + Token phase = expect_name(c, "down or up"); + if (strcmp(phase.text, "down") && strcmp(phase.text, "up")) + fail_at(c, phase.line, phase.column, "expected down or up"); + memcpy(c->handlers[index].input, event.text, NAME); + c->handlers[index].edge = phase.text[0] == 'u'; + } + c->handlers[index].body = block(c, 0); + } else fail_at(c, kind.line, kind.column, "unknown declaration '%s'", kind.text); + end(c); +} + +/* ---- Checks and code generation ------------------------------------- */ + +static void emit(Compiler *c, int line, int column, uint8_t op, uint32_t arg, double value) { + if (c->code_count == CODE) fail_at(c, line, column, "program exceeds 512 instructions"); + c->code[c->code_count].op = op; + c->code[c->code_count].arg = arg; + c->code[c->code_count++].value = value; +} + +static int state_index(const Compiler *c, const char *name) { + for (unsigned i = 0; i < c->states; ++i) if (!strcmp(c->state_names[i], name)) return (int)i; + return -1; +} + +/* Emit an expression; height is the stack height before it. */ +static void 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; + case E_DT: + if (!handler) fail_at(c, e->line, e->column, "dt is only available in handlers"); + emit(c, e->line, e->column, DT, 0, 0); + return; + case E_STATE: { + int index = state_index(c, e->name); + if (index < 0) { + const char *kind = declared_kind(c, e->name); + if (kind) fail_at(c, e->line, e->column, "'%s' is a %s, not a state", e->name, kind); + fail_at(c, e->line, e->column, "unknown state '%s'", e->name); + } + emit(c, e->line, e->column, LOAD, (uint32_t)index, 0); + return; + } + default: + for (unsigned i = 0; i < e->count; ++i) value(c, e->args[i], handler, height + (int)i); + emit(c, e->line, e->column, (uint8_t)e->op, 0, 0); + } +} + +static void statements(Compiler *c, const Stmt *s) { + for (; s; s = s->next) { + if (!s->branch) { + int index = state_index(c, s->name); + if (index < 0) { + const char *kind = declared_kind(c, s->name); + if (kind) fail_at(c, s->line, s->column, "cannot assign to %s '%s'", kind, s->name); + fail_at(c, s->line, s->column, "unknown state '%s'", s->name); + } + value(c, s->value, true, 0); + emit(c, s->line, s->column, STORE, (uint32_t)index, 0); + continue; + } + value(c, s->value, true, 0); + unsigned jump = c->code_count; + emit(c, s->line, s->column, JZ, 0, 0); + statements(c, s->then); + if (s->otherwise) { + unsigned skip = c->code_count; + emit(c, s->line, s->column, JMP, 0, 0); + c->code[jump].arg = c->code_count; + statements(c, s->otherwise); + c->code[skip].arg = c->code_count; + } else c->code[jump].arg = c->code_count; + } +} + +static unsigned char *put32(unsigned char *p, uint32_t v) { + for (unsigned i = 0; i < 4; ++i) p[i] = (unsigned char)(v >> (8 * i)); + return p + 4; +} + +static unsigned char *put_double(unsigned char *p, double value) { + uint64_t bits; + memcpy(&bits, &value, sizeof(bits)); + put32(p, (uint32_t)bits); + return put32(p + 4, (uint32_t)(bits >> 32)); +} + +/* Deduplicated [u8 length][bytes] strings; returns the offset. */ +static uint32_t intern(unsigned char *strings, size_t *used, const char *text) { + size_t length = strlen(text); + for (size_t at = 0; at < *used; at += 1u + strings[at]) + if (strings[at] == length && !memcmp(strings + at + 1, text, length)) return (uint32_t)at; + uint32_t offset = (uint32_t)*used; + strings[(*used)++] = (unsigned char)length; + memcpy(strings + *used, text, length); + *used += length; + return offset; +} + +static void generate(Compiler *c, unsigned char **module, size_t *size) { + uint32_t events[HANDLERS]; + for (unsigned i = 0; i < c->handler_count; ++i) { + const char *name = c->handlers[i].input; + events[i] = 0; /* frame */ + if (!*name) continue; + int input = -1; + for (unsigned k = 0; k < c->inputs; ++k) if (!strcmp(c->input_names[k], name)) input = (int)k; + if (input < 0) { + const char *kind = declared_kind(c, name); + if (kind) fail_at(c, c->handlers[i].line, c->handlers[i].column, "'%s' is a %s, not an input", name, kind); + fail_at(c, c->handlers[i].line, c->handlers[i].column, "unknown input '%s'", name); + } + events[i] = 1u + 2u * (uint32_t)input + (uint32_t)c->handlers[i].edge; + } + /* Views, then handlers, each as one contiguous block. */ + for (unsigned i = 0; i < c->views; ++i) { + c->view_code[i][0] = c->code_count; + value(c, c->view_value[i], false, 0); + c->view_code[i][1] = c->code_count - c->view_code[i][0]; + } + for (unsigned i = 0; i < c->handler_count; ++i) { + c->handler_code[i][0] = c->code_count; + statements(c, c->handlers[i].body); + c->handler_code[i][1] = c->code_count - c->handler_code[i][0]; + } + static unsigned char strings[STATES * NAME + 2 * INPUTS * NAME + VIEWS * NAME]; + size_t used = 0; + uint32_t state_name[STATES], input_name[INPUTS], input_key[INPUTS], view_name[VIEWS]; + for (unsigned i = 0; i < c->states; ++i) state_name[i] = intern(strings, &used, c->state_names[i]); + for (unsigned i = 0; i < c->inputs; ++i) { + input_name[i] = intern(strings, &used, c->input_names[i]); + input_key[i] = intern(strings, &used, c->input_keys[i]); + } + for (unsigned i = 0; i < c->views; ++i) view_name[i] = intern(strings, &used, c->view_names[i]); + *size = 32 + 16u * c->states + 12u * (c->inputs + c->views + c->handler_count) + 16u * c->code_count + used; + unsigned char *p = *module = allocate(c, *size); + memcpy(p, "PUREMOD1", 8); + p[8] = (unsigned char)c->states; p[10] = (unsigned char)c->inputs; + p[12] = (unsigned char)c->views; p[14] = (unsigned char)c->handler_count; + put32(p + 16, c->code_count); + put32(p + 20, (uint32_t)used); + p += 32; + for (unsigned i = 0; i < c->states; ++i) { p = put32(p, state_name[i]); p = put32(p, 0); p = put_double(p, c->initial[i]); } + for (unsigned i = 0; i < c->inputs; ++i) { p = put32(p, input_name[i]); p = put32(p, 1); p = put32(p, input_key[i]); } + for (unsigned i = 0; i < c->views; ++i) { p = put32(p, view_name[i]); p = put32(p, c->view_code[i][0]); p = put32(p, c->view_code[i][1]); } + for (unsigned i = 0; i < c->handler_count; ++i) { + p = put32(p, events[i]); + p = put32(p, c->handler_code[i][0]); + p = put32(p, c->handler_code[i][1]); + } + for (unsigned i = 0; i < c->code_count; ++i) { + *p++ = c->code[i].op; *p++ = 0; *p++ = 0; *p++ = 0; + p = put32(p, c->code[i].arg); + p = put_double(p, c->code[i].value); + } + memcpy(p, strings, used); +} + +bool aup_compile(const char *source, size_t length, unsigned char **module, size_t *size, + AupDiagnostic *error) { + *module = NULL; + *size = 0; + *error = (AupDiagnostic){0}; + Compiler *c = calloc(1, sizeof(*c)); + if (!c) { *error = (AupDiagnostic){1, 1, "out of memory"}; return false; } + c->source = source; + c->length = length; + c->line = c->column = 1; + c->error = error; + unsigned char *result = NULL; + if (!setjmp(c->fail)) { + if (length > AUP_SOURCE_LIMIT) fail_at(c, 1, 1, "source exceeds 32768 bytes"); + c->token = scan(c); + separators(c); + while (c->token.kind != T_EOF) declaration(c); + generate(c, &result, size); + *module = malloc(*size); + if (!*module) fail_at(c, 1, 1, "out of memory"); + memcpy(*module, result, *size); + } else { + free(*module); + *module = NULL; + *size = 0; + } + for (size_t i = 0; i < c->block_count; ++i) free(c->blocks[i]); + free(c->blocks); + free(c); + return *module != NULL; +} diff --git a/src/main.c b/src/main.c new file mode 100644 index 0000000..3731eec --- /dev/null +++ b/src/main.c @@ -0,0 +1,38 @@ +#define _POSIX_C_SOURCE 200809L +#include "aup.h" +#include <stdio.h> +#include <stdlib.h> +#include <string.h> + +static int usage(void) { + fputs("aup check FILE\naup build FILE -o OUT.pmod\n", stderr); + return 2; +} + +int main(int argc, char **argv) { + bool build = argc == 5 && !strcmp(argv[1], "build") && !strcmp(argv[3], "-o"); + if (!build && !(argc == 3 && !strcmp(argv[1], "check"))) return usage(); + const char *path = argv[2]; + FILE *file = fopen(path, "rb"); + if (!file) { perror(path); return 2; } + static char source[AUP_SOURCE_LIMIT + 1]; + size_t length = fread(source, 1, sizeof(source), file); + bool unreadable = ferror(file); + fclose(file); + if (unreadable) { fprintf(stderr, "%s: cannot read\n", path); return 2; } + unsigned char *module; + size_t size; + AupDiagnostic error; + if (!aup_compile(source, length, &module, &size, &error)) { + fprintf(stderr, "%s:%d:%d: %s\n", path, error.line, error.column, error.message); + return 1; + } + int status = 0; + if (build) { + FILE *out = fopen(argv[4], "wb"); + if (!out || fwrite(module, 1, size, out) != size) { perror(argv[4]); status = 2; } + if (out && fclose(out)) { perror(argv[4]); status = 2; } + } + free(module); + return status; +} diff --git a/tests/check.py b/tests/check.py new file mode 100644 index 0000000..1bf107d --- /dev/null +++ b/tests/check.py @@ -0,0 +1,263 @@ +#!/usr/bin/env python3 +"""aup v0a checks. + +1. Every example compiles, and Pure's verifier accepts the module. +2. The same source always yields the same bytes. +3. Diagnostics name the right line, column and rule. Generated programs always + compile and verify; damaged sources never crash the compiler, and every + module it does emit passes Pure's verifier. +4. Parity: random event scripts produce the same states, views, ticks, event + counts and event failures as the workbench Zig engine (via its Node CLI). + Workbench JSON prints -0 as 0, so signed zeros compare numerically there; + Pure's unit tests check signed zeros directly. Other values compare exactly. +""" +import argparse +import json +import random +import subprocess +import sys +import tempfile +from pathlib import Path + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parent + +DIAGNOSTICS = [ + ('state x = 1\nstate x = 2\n', '2:7', "already declared as a state"), + ('state a = b\n', '1:11', 'expected a numeric state initializer'), + ('view v = dt\n', '1:10', 'dt is only available in handlers'), + ('input k = key Space\ninput j = key Space\n', '2:15', "key Space is already used by input 'k'"), + ('input k = key Tab\n', '1:15', "unknown key 'Tab'"), + ('on missing.down {\n}\n', '1:4', "unknown input 'missing'"), + ('state a = 0\non a.down {\n}\n', '2:4', "'a' is a state, not an input"), + ('state a = 0\non frame {\n b = 1\n}\n', '3:3', "unknown state 'b'"), + ('state a = 0\nview v = a\non frame {\n v = 1\n}\n', '4:3', "cannot assign to view 'v'"), + ('input k = key Space\nview v = k\n', '2:10', "'k' is a input, not a state"), + ('state a = 0\nview v = min(a)\n', '2:10', 'min expects 2 arguments'), + ('state if = 0\n', '1:7', "'if' is reserved"), + ('state a = 0 $\n', '1:13', "unexpected character '$'"), + ('state a = 1e400\n', '1:11', 'number must be finite'), + ('view v = 1 +\n', '1:13', 'expected a number, state, or expression'), + ('state a = 0\non frame {\n a = 1 a\n}\n', '3:9', 'expected newline or semicolon'), + ('state a = 0\non frame {\n if a {\n a = 1\n } els {\n }\n}\n', '5:5', 'expected newline or semicolon'), + ('view v = ' + '+'.join(['1'] * 300) + '\n', '1:', 'program exceeds 512 instructions'), + ('view v = ' + '(' * 70 + '1' + ')' * 70 + '\n', '1:', 'expression nesting exceeds 64'), + ('state s = 0\n' * 1 + ''.join(f'state s{i} = 0\n' for i in range(32)), '33:7', 'at most 32 states are allowed'), +] + +ACCEPTED = [ + # else on the next line, else if chains, multiline arguments, semicolons. + 'state a = 0\non frame {\n if a > 1 {\n a = 1\n }\n else {\n a = 2\n }\n}\n', + 'state a = 0; state b = 1\nview v = clamp(\n a,\n 0,\n b\n)\non frame { a = a + 1; b = b + 2 }\n', + '# empty handlers and comments only\ninput k = key KeyZ\non k.down {\n}\non k.up { }\n', +] + + +def run(*args, stdin=None, check=True): + return subprocess.run([str(a) for a in args], input=stdin, capture_output=True, text=True, + check=check, timeout=60) + + +def compile_module(aup, source_path, out): + run(aup, 'build', source_path, '-o', out) + return out.read_bytes() + + +def script(inputs, seed, steps=250): + rng = random.Random(seed) + held, lines = set(), [] + for _ in range(steps): + if rng.random() < 0.4: + lines.append(f'frame {rng.randint(1, 8)}') + else: + name = rng.choice(inputs) + lines.append(f"{'up' if name in held else 'down'} {name}") + held ^= {name} + if rng.random() < 0.1: + lines.append('show') + lines.extend(f'up {name}' for name in sorted(held)) + return '\n'.join(lines) + '\n' + + +def same(a, b): + return a == b and (a is None) == (b is None) + + +def parity(aup, pure_run, workbench, pair, work, seeds): + source, reference, inputs, views = pair + module = work / (Path(source).stem + '.pmod') + compile_module(aup, ROOT / source, module) + compared = failures = 0 + for seed in seeds: + commands = script(inputs, seed) + ours = run(pure_run, module, stdin=commands, check=False).stdout.splitlines() + theirs = run('node', workbench / 'cli.mjs', reference, stdin=commands, check=False).stdout.splitlines() + assert len(ours) == len(theirs) == commands.count('\n'), (source, seed, len(ours), len(theirs)) + for number, (line, other, command) in enumerate(zip(ours, theirs, commands.splitlines())): + a, b = json.loads(line), json.loads(other) + where = f'{source} seed {seed} command {number} ({command})' + assert a['states'].keys() == b['states'].keys(), where + for name, value in a['states'].items(): + assert value == b['states'][name], (where, name, value, b['states'][name]) + wb_views = {view['id']: view for view in b['views']} + for name, (view_id, field) in views.items(): + assert same(a['views'][name], wb_views[view_id].get(field)), (where, name) + assert a['tick'] == b['tick'] and a['successfulEvents'] == b['successfulEvents'], where + # lastError is sticky in the workbench host; lastEvent is the latest event. + status = (b['lastEvent'] or {}).get('status', 0) + assert (a['fault'] != 'none') == (status != 0), (where, a['fault'], status) + failures += status != 0 + compared += 1 + return compared, failures + + +KEYS = ['Space', 'Enter', 'ArrowLeft', 'ArrowRight', 'KeyA', 'KeyQ', 'Digit0', 'Digit9'] + + +def random_program(rng): + """A random well-formed v0a program: every one must compile and verify.""" + states = [f's{i}' for i in range(rng.randint(1, 6))] + inputs = [f'i{i}' for i in range(rng.randint(0, 4))] + keys = rng.sample(KEYS, len(inputs)) + + def expr(depth, handler): + choice = rng.random() + if depth <= 0 or choice < 0.25: + return rng.choice([str(rng.choice([0, 1, 2, 0.5, 1e-3, 1e300, 7.25])), rng.choice(states)] + + (['dt'] if handler else [])) + if choice < 0.4: + return rng.choice(['-', '!']) + expr(depth - 1, handler) + if choice < 0.55: + name, count = rng.choice([('min', 2), ('max', 2), ('clamp', 3)]) + return f'{name}(' + ', '.join(expr(depth - 1, handler) for _ in range(count)) + ')' + op = rng.choice(['+', '-', '*', '/', '<', '<=', '>', '>=', '==', '!=', '&&', '||']) + return f'({expr(depth - 1, handler)} {op} {expr(depth - 1, handler)})' + + def body(depth, indent): + lines = [] + for _ in range(rng.randint(0, 3)): + if depth > 0 and rng.random() < 0.3: + lines.append(f'{indent}if {expr(3, True)} {{') + lines += body(depth - 1, indent + ' ') + if rng.random() < 0.5: + lines.append(f'{indent}}} else {{') + lines += body(depth - 1, indent + ' ') + lines.append(f'{indent}}}') + else: + lines.append(f'{indent}{rng.choice(states)} = {expr(3, True)}') + return lines + + lines = [f'state {name} = {rng.choice(["0", "-1", "3.5", "+2"])}' for name in states] + lines += [f'input {name} = key {key}' for name, key in zip(inputs, keys)] + lines += [f'view v{i} = {expr(3, False)}' for i in range(rng.randint(0, 3))] + events = ['frame'] + [f'{name}.{edge}' for name in inputs for edge in ('down', 'up')] + for _ in range(rng.randint(0, 5)): + lines.append(f'on {rng.choice(events)} {{') + lines += body(3, ' ') + lines.append('}') + return '\n'.join(lines) + '\n', inputs + + +def properties(aup, pure_run, work, rounds): + """Generated programs compile and verify; damaged sources never crash the + compiler, and whatever it accepts, Pure accepts.""" + rng = random.Random(2026) + accepted_damaged = 0 + examples = [path.read_text() for path in sorted(ROOT.glob('examples/*.aup'))] + for number in range(rounds): + source, inputs = random_program(rng) + path, module = work / 'generated.aup', work / 'generated.pmod' + path.write_text(source) + result = run(aup, 'build', path, '-o', module, check=False) + assert result.returncode == 0, (source, result.stderr) + commands = ''.join(rng.choice(['frame\n'] + [f'down {i}\nup {i}\n' for i in inputs]) for _ in range(8)) + lines = run(pure_run, module, stdin=commands + 'show\n', check=False).stdout.splitlines() + assert lines and json.loads(lines[-1])['error'] is None, (source, lines[-1:]) + + text = list(rng.choice(examples)) + for _ in range(rng.randint(1, 6)): + at = rng.randrange(len(text)) + action = rng.random() + if action < 0.4: del text[at] + elif action < 0.8: text.insert(at, rng.choice('{}()=+-*/!<>.,;#\n abc0129eE')) + else: text[at] = rng.choice('{}()=+-*/\n x') + path.write_text(''.join(text)) + result = run(aup, 'build', path, '-o', module, check=False) + assert result.returncode in (0, 1) and (result.returncode == 0) == (not result.stderr), result.stderr + if result.returncode == 0: + accepted_damaged += 1 + shown = run(pure_run, module, stdin='frame\nshow\n', check=False).stdout.splitlines() + assert json.loads(shown[-1])['error'] is None or 'event failed' in shown[-1], shown + return accepted_damaged + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('--aup', type=Path, required=True) + parser.add_argument('--pure-run', type=Path, required=True) + parser.add_argument('--workbench', type=Path, default=ROOT.parent / 'pure' / 'prototypes' / 'workbench') + parser.add_argument('--seeds', type=int, default=12) + parser.add_argument('--generated', type=int, default=600) + args = parser.parse_args() + aup, pure_run, workbench = args.aup.resolve(), args.pure_run.resolve(), args.workbench.resolve() + report = {} + with tempfile.TemporaryDirectory(prefix='aup-check-') as directory: + work = Path(directory) + sources = sorted(ROOT.glob('examples/*.aup')) + sorted(HERE.glob('oracle/*.aup')) + for path in sources: + first = compile_module(aup, path, work / 'first.pmod') + second = compile_module(aup, path, work / 'second.pmod') + assert first == second, f'{path} is not deterministic' + shown = json.loads(run(pure_run, work / 'first.pmod', stdin='show\n').stdout) + assert shown['error'] is None, path + report['verified_deterministic_modules'] = len(sources) + + for number, (source, where, message) in enumerate(DIAGNOSTICS): + path = work / f'bad{number}.aup' + path.write_text(source) + result = run(aup, 'check', path, check=False) + assert result.returncode == 1, (number, result.stderr) + assert f'{path}:{where}' in result.stderr and message in result.stderr, (number, result.stderr) + for number, source in enumerate(ACCEPTED): + path = work / f'good{number}.aup' + path.write_text(source) + compile_module(aup, path, work / f'good{number}.pmod') + assert json.loads(run(pure_run, work / f'good{number}.pmod', stdin='frame\nshow\n').stdout.splitlines()[-1])['error'] is None + report['diagnostics'] = len(DIAGNOSTICS) + report['accepted_forms'] = len(ACCEPTED) + + # Replacing a running module keeps states by name. + compile_module(aup, ROOT / 'examples/counter.aup', work / 'counter.pmod') + (work / 'counter2.aup').write_text('state bonus = 7\nstate count = 100\ninput tap = key Space\n' + 'view doubled = count * 2\non tap.down {\n count = count + bonus\n}\n') + compile_module(aup, work / 'counter2.aup', work / 'counter2.pmod') + lines = run(pure_run, work / 'counter.pmod', + stdin=f'down tap\nup tap\ndown tap\nreplace {work / "counter2.pmod"}\ndown tap\n').stdout.splitlines() + final = json.loads(lines[-1]) + assert final['states'] == {'bonus': 7, 'count': 9} and final['views'] == {'doubled': 18}, final + report['replace_keeps_states_by_name'] = True + report['generated_programs_verified'] = args.generated + report['damaged_sources_accepted_and_verified'] = properties(aup, pure_run, work, args.generated) + + wb = workbench / 'examples' + pairs = [ + ('examples/counter.aup', wb / 'counter.pure', ['tap'], + {'label': ('label', 'value'), 'tile_width': ('tile', 'width')}), + ('examples/toggle.aup', wb / 'toggle.pure', ['toggle', 'move'], + {'marker_x': ('marker', 'x'), 'marker_radius': ('marker', 'radius'), + 'tile_x': ('tile', 'x'), 'value': ('value', 'value')}), + ('examples/charge.aup', wb / 'charge.pure', ['jump'], + {'actor_y': ('actor', 'y'), 'meter_width': ('meter', 'width'), 'amount': ('amount', 'value')}), + ('examples/steps.aup', HERE / 'oracle/steps.pure', ['up', 'down'], {'shown': ('shown', 'value')}), + ('tests/oracle/faults.aup', HERE / 'oracle/faults.pure', ['divide', 'grow', 'bound'], + {'shown': ('shown', 'value')}), + ] + results = [parity(aup, pure_run, workbench, pair, work, range(args.seeds)) for pair in pairs] + report['parity_snapshots'] = sum(compared for compared, _ in results) + report['parity_failed_events_matched'] = sum(failures for _, failures in results) + report['parity_programs'] = len(pairs) + print(json.dumps(report)) + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/tests/oracle/faults.aup b/tests/oracle/faults.aup new file mode 100644 index 0000000..cb91f17 --- /dev/null +++ b/tests/oracle/faults.aup @@ -0,0 +1,22 @@ +# Each input fails on some presses; a failed event changes nothing. +state q = 1 +state r = 0 +state s = 5 +input divide = key KeyD +input grow = key KeyG +input bound = key KeyB +view shown = r + +on divide.down { + r = r + 1 + q = 10 / (q - 1) +} + +on grow.down { + s = s * 1e200 +} + +on bound.down { + r = r + 1 + s = clamp(s, r, 3) +} diff --git a/tests/oracle/faults.pure b/tests/oracle/faults.pure new file mode 100644 index 0000000..598c853 --- /dev/null +++ b/tests/oracle/faults.pure @@ -0,0 +1,19 @@ +# Each input fails on some presses; a failed event changes nothing. +state q = 1 +state r = 0 +state s = 5 +input divide = KeyD +input grow = KeyG +input bound = KeyB +text shown(0, 0, r, "r") +on divide.down { + r = r + 1 + q = 10 / (q - 1) +} +on grow.down { + s = s * 1e200 +} +on bound.down { + r = r + 1 + s = clamp(s, r, 3) +} diff --git a/tests/oracle/steps.pure b/tests/oracle/steps.pure new file mode 100644 index 0000000..3c09dbb --- /dev/null +++ b/tests/oracle/steps.pure @@ -0,0 +1,23 @@ +# The workbench has no else; the aup else-if chain is written as exclusive ifs. +state level = 0 +state band = 0 +input up = ArrowUp +input down = ArrowDown +text shown(0, 0, band, "band") +on up.down { + level = level + 1 +} +on down.down { + level = level - 1 +} +on frame { + if level > 2 { + band = 2 + } + if level <= 2 && level > 0 { + band = 1 + } + if level <= 0 { + band = 0 + } +} |
