diff options
| -rw-r--r-- | README.md | 8 | ||||
| -rw-r--r-- | docs/design/001-language-v0.md | 5 | ||||
| -rw-r--r-- | docs/design/002-dsl-direction.md | 44 | ||||
| -rw-r--r-- | examples/tasks.aup | 42 | ||||
| -rw-r--r-- | src/compile.c | 418 | ||||
| -rw-r--r-- | tests/check.py | 133 |
6 files changed, 589 insertions, 61 deletions
@@ -9,8 +9,8 @@ aup는 [Pure](/srv/git/pure.git) 런타임을 위한 **언어와 툴체인**이� | **aup** | 언어와 툴체인. 소스 → 진단 → Pure 모듈 | | [Vega](/srv/git/vega.git) | aup와 Pure 위의 인터페이스 구현자(TUI·CLI·GUI·NUI) | -상태: v0a(scalar 상태·키 입력·원자적 이벤트·보기)와 v0b(바이트·영속 문서·메시지·응답·text·커서) 동작. -다음은 v0c(레코드·리스트·`index of`·순수 함수·모듈). +상태: v0a(scalar 상태·키 입력·원자적 이벤트·보기), v0b(바이트·영속 문서·메시지·응답·text·커서), +v0c의 레코드·리스트·`index of` 동작. 다음은 항목 투영과 조건식, 순수 함수, 모듈. [언어 v0](docs/design/001-language-v0.md). ```sh @@ -19,9 +19,11 @@ build/aup build examples/counter.aup -o counter.pmod printf 'down tap\nup tap\nshow\n' | ../pure/prototypes/connections/build/pure-run counter.pmod build/aup build examples/notes.aup -o notes.pmod printf 'type typed %s\ndown left\nshow\n' "$(printf '안녕' | xxd -p)" | ../pure/prototypes/connections/build/pure-run notes.pmod +build/aup build examples/tasks.aup -o tasks.pmod +printf 'type typed %s\ndown again\ndown toggle\nshow\n' "$(printf '우유' | xxd -p)" | ../pure/prototypes/connections/build/pure-run tasks.pmod build/aup build examples/editor.aup -o editor.pmod ../pure/prototypes/connections/build/pure-edit-module --module editor.pmod --store /tmp/docs show note -make check # Pure 검증, 결정성, 진단, 워크벤치·기존 편집기·text 참조 모델과의 대조 +make check # Pure 검증, 결정성, 진단, 워크벤치·기존 편집기·text·리스트 참조 모델과의 대조 ``` `make check`는 옆의 Pure 체크아웃(`PURE=../pure/prototypes/connections`)의 검증기와 시험 호스트를 쓰고, diff --git a/docs/design/001-language-v0.md b/docs/design/001-language-v0.md index 5be574d..1f934fb 100644 --- a/docs/design/001-language-v0.md +++ b/docs/design/001-language-v0.md @@ -1,6 +1,7 @@ # aup v0 — 한 언어, 한 모듈 형식 -2026-09-25. 상태: v0a(scalar)와 v0b(바이트·영속 문서·text·커서) 구현. text 문법은 [002](002-dsl-direction.md). +2026-09-25. 상태: v0a(scalar)와 v0b(바이트·영속 문서·text·커서), v0c의 레코드·리스트·인덱스 구현. +text와 리스트 문법은 [002](002-dsl-direction.md). 착수 기준: @@ -47,7 +48,7 @@ on frame { # 1/60초 진행. 핸들러 안에서 dt를 쓸 ``` - 선언과 문장은 줄바꿈이나 `;`로 끝난다. 이름은 ASCII `[A-Za-z_][A-Za-z_0-9]*`, 31바이트 이하다. -- 예약어: `state input view on if else key frame dt min max clamp`. +- 예약어: `state input view on if else key frame dt min max clamp message by bytes u32 reply require record`. - 키: `Space Enter ArrowLeft ArrowRight ArrowUp ArrowDown KeyA..KeyZ Digit0..Digit9`. 입력 이름과 키는 각각 한 번만 쓴다. 상태·입력·보기의 이름공간은 서로 겹칠 수 없다. - 식: 숫자, 상태 이름, `dt`(핸들러 안에서만), 괄호, 단항 `- !`, 이항 `* /`, `+ -`, diff --git a/docs/design/002-dsl-direction.md b/docs/design/002-dsl-direction.md index 2b419c9..c0d237c 100644 --- a/docs/design/002-dsl-direction.md +++ b/docs/design/002-dsl-direction.md @@ -1,6 +1,6 @@ # 002 — DSL 방향과 text -2026-09-25. 상태: 방향 확정, v0b·v0c에서 구현. +2026-09-25. 상태: 방향 확정. v0b 구현, v0c는 레코드·리스트·인덱스까지 구현. ## 위치 @@ -64,7 +64,7 @@ view current = note.line(at) # 커서가 있는 줄. 복사하지 않� |---|---|---| | v0a ✅ | 스칼라 상태, 키 입력, 핸들러, 보기 | 워크벤치 엔진과 비트 단위 일치 | | v0b ✅ | `bytes`, 키별 영속 문서, `message` 입력, 응답, `require`, `text`(리터럴·보간·`cursor in`·`char`/`line`), `text` 입력 | 기존 편집기 CLI와 바이트 단위 일치, text 편집이 참조 모델과 일치 | -| v0c | `i64 bool`, `record`, `list`/`map`, `index of`, 순수 `fn`, `use` 모듈 | 모든 연산에 실행량 상한 | +| v0c | `record`, `list of`, `index of` ✅ / 항목 투영, 조건식, 순수 `fn`, `use` 모듈, `i64 bool`, `map` | 모든 연산에 실행량 상한, 리스트가 참조 모델과 일치 ✅ | | v0d | `use vega`로 첫 인터페이스 투영(목록·표·커서·톤) | 한 명세가 두 표면 이상에서 동작 | ### 구현된 text (v0b) @@ -83,4 +83,44 @@ view current = note.line(at) # 커서가 있는 줄. 복사하지 않� 보기가 Python 참조 모델과 같다. 이스케이프와 숫자 표시(`1e+21`, `0.30000000000000004`, `2.5e-7`, `-0`은 `0`)를 확인한다. 컴파일러와 Pure 실행기를 각각 sanitizer로 빌드해 같은 검사를 통과했다. +### 구현된 레코드·리스트 (v0c-1) + +`examples/tasks.aup`가 할 일 목록 전체다. + +```text +record Task { + title: text # text 필드. 기본값은 "" (title: text = "..." 로 바꾼다) + done = 0 # 수 필드와 기본값 +} + +state tasks: list of Task # 런타임이 소유하는 항목 목록 +state at: index of tasks # 항상 tasks의 항목을 가리키는 위치 + +on typed { tasks.append(Task { title: typed }) } # 빠진 필드는 기본값 +on toggle.down { tasks[at].done = !tasks[at].done } +on drop.down { tasks.remove(at) } +on again.down { tasks.insert(at, Task { title: tasks[at].title }) } +on down.down { at = at + 1 } # 마지막 항목에서 멈춘다 + +view current = tasks[at].title +view status = "{at.position + 1}/{tasks.count}" +``` + +- **위치는 정수가 아니다.** 커서처럼 `index of 리스트` 타입을 둔다. 인덱스는 항상 그 리스트의 항목을 + 가리키고(빈 리스트면 0), 앞에 넣거나 지워도 같은 항목을 따라가며, 가리키던 항목이 지워지면 다음 항목 + (없으면 마지막)으로 간다. `at + n`, `at - n`은 처음과 마지막에서 멈춘다. `.first`/`.last`, `.position`(앞 항목 수). +- **다른 리스트의 인덱스는 컴파일 오류다.** `ys[at]`처럼 `xs`의 인덱스로 `ys`를 읽는 모듈은 Pure도 로드 단계에서 거부한다. +- **항목 읽기는 실패할 수 있다.** 빈 리스트의 `tasks[at]`은 이벤트를 `not_found`로 실패시킨다(아무것도 바뀌지 않음). + 보기는 그때 값이 없다(`pure-run`은 `null`). +- **변경은 되돌릴 수 있다.** 리스트 변경은 이벤트마다 기록되고, 이벤트가 실패하면 모두 되돌린다. +- **레코드를 바꿔도 데이터가 남는다.** 모듈을 교체할 때 리스트는 이름으로 남고, 레코드가 바뀌었으면 항목마다 + 이름과 타입이 같은 필드를 옮기고 새 필드는 기본값을 쓴다. 마이그레이션 스크립트가 필요 없다. +- 레코드 값(`Task { ... }`)은 아직 `append`/`insert`의 인자로만 쓴다. 레코드 상태, 항목 전체 읽기, 정렬·검색은 없다. + +한도: 레코드 8개, 레코드당 필드 16개, 전체 필드 64개, 리스트당 항목 65,536개. + +검증: 무작위 추가·이동·토글·복사·이름 바꾸기·지우기 4,800단계(빈 리스트에서 실패하는 이벤트 약 500건 포함)에서 +리스트, 인덱스, 보기, 실패 여부가 Python 참조 모델과 같다. 필드를 더하고 순서를 바꾼 레코드로 교체해도 항목이 +남는다. 진단 25건을 더했다. + 구현은 C로 손수 쓴 재귀 하강 파서를 유지한다. 문법은 EBNF로 문서화하고, 이후 `aup fmt`와 언어 서버를 붙인다. diff --git a/examples/tasks.aup b/examples/tasks.aup new file mode 100644 index 0000000..4c6627f --- /dev/null +++ b/examples/tasks.aup @@ -0,0 +1,42 @@ +# A task list. The runtime owns the list: the index always names an item, +# stays on it when items are inserted or removed before it, stops at the +# ends, and a failed event leaves the list as it was. +record Task { + title: text + done = 0 +} + +state tasks: list of Task +state at: index of tasks +state made = 0 + +input typed = text +input up = key ArrowUp +input down = key ArrowDown +input toggle = key Space +input drop = key Delete +input top = key Home +input bottom = key End +input again = key KeyD +input shout = key KeyS + +on typed { + tasks.append(Task { title: typed }) + at = tasks.last + made = made + 1 +} +on up.down { at = at - 1 } +on down.down { at = at + 1 } +on toggle.down { tasks[at].done = !tasks[at].done } +on drop.down { tasks.remove(at) } +on top.down { at = tasks.first } +on bottom.down { at = tasks.last } +# A copy goes before the current task, which stays current. +on again.down { tasks.insert(at, Task { title: tasks[at].title, done: tasks[at].done }) } +on shout.down { tasks[at].title = "{tasks[at].title}!" } + +view count = tasks.count +view position = at.position +view current = tasks[at].title +view status = "{at.position + 1}/{tasks.count}" +view finished = tasks[at].done diff --git a/src/compile.c b/src/compile.c index 340e623..7305937 100644 --- a/src/compile.c +++ b/src/compile.c @@ -9,16 +9,18 @@ /* Limits and opcodes of Pure module format v4. */ enum { STATES = 32, INPUTS = 32, VIEWS = 24, HANDLERS = 64, CODE = 512, STACK = 64, NAME = 32, - DEPTH = 64, FIELDS = 32, COLLECTIONS = 1, CONSTANTS = 64, CONSTANT_BYTES = 8192 }; + DEPTH = 64, FIELDS = 32, COLLECTIONS = 1, CONSTANTS = 64, CONSTANT_BYTES = 8192, + RECORDS = 8, RECORD_FIELDS = 64, FIELDS_PER_RECORD = 16 }; enum { CONST, LOAD, DT, ADD, SUB, MUL, DIV, NEG, LT, LE, GT, GE, EQ, NE, AND, OR, NOT, MIN, MAX, CLAMP, STORE, JZ, JMP, FIELD, DOC, DOC_SIZE, LEN, REPLACE, REPLY, REQUIRE, TEXT_CONST, TEXT_LOAD, TEXT_INPUT, NUM_TEXT, CONCAT, CHARS, LINES, TEXT_BYTES, CUR_LOAD, CUR_START, CUR_END, CUR_MOVE, CUR_STORE, TEXT_INSERT, TEXT_REMOVE, TEXT_LINE, - TEXT_SET, TEXT_FROM, BYTES_FROM, CUR_COUNT }; + 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 }; enum { KIND_KEY = 1, KIND_MESSAGE = 2, KIND_TEXT = 3, FIELD_U32 = 1, FIELD_BYTES = 2 }; -enum { STATE_NUM = 0, STATE_TEXT = 1, STATE_CURSOR = 2, UNIT_CHAR = 0, UNIT_LINE = 1 }; -typedef enum { NUM = 1, BYTES, TEXT, CURSOR, UNIT } Type; -typedef struct { Type type; int owner; } Kind; /* owner: a cursor's text state */ +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 */ typedef enum { T_EOF, T_NL, T_NUMBER, T_NAME, T_PUNCT, T_STRING } TokenKind; typedef struct { @@ -39,6 +41,9 @@ typedef enum { E_UNIT, /* args[0] char|line */ E_CONVERT, /* text(args[0]) or bytes(args[0]) */ E_LINE, /* name.line(args[0]) */ + E_ITEM, /* name[args[0]].member: a field of a list item */ + E_RECORD, /* name { fields }: E_FIELD nodes linked from args[0] */ + E_FIELD, /* member: args[0] */ } ExprKind; typedef struct Expr { ExprKind kind; @@ -51,13 +56,13 @@ typedef struct Expr { size_t size; } Expr; -typedef enum { S_ASSIGN, S_IF, S_REPLACE, S_REPLY, S_REQUIRE, S_INSERT, S_REMOVE } StmtKind; +typedef enum { S_ASSIGN, S_IF, S_REPLACE, S_REPLY, S_REQUIRE, S_INSERT, S_REMOVE, S_APPEND, S_SET } StmtKind; typedef struct Stmt { StmtKind kind; int line, column; - char name[NAME]; + char name[NAME], member[NAME]; /* member: the item field an S_SET changes */ Expr *value; /* assigned value, condition, reply, requirement or insertion */ - Expr *first, *last; /* a range, or the insertion cursor in first */ + Expr *first, *last; /* a range, the insertion cursor or index, or the item index; last may be NULL */ struct Stmt *then, *otherwise, *next; } Stmt; @@ -70,6 +75,18 @@ typedef struct { unsigned fields; } Input; +/* A record: fields in order, each a number or text with its default. */ +typedef struct { + char name[NAME]; + int line, column; + char field_names[FIELDS_PER_RECORD][NAME]; + bool field_text[FIELDS_PER_RECORD]; + double field_number[FIELDS_PER_RECORD]; + Expr *field_string[FIELDS_PER_RECORD]; /* a text default, or NULL for "" */ + uint32_t field_constant[FIELDS_PER_RECORD]; + unsigned fields; +} Record; + typedef struct { const char *source; size_t length, at; @@ -89,6 +106,8 @@ typedef struct { unsigned states; char collection_names[COLLECTIONS][NAME]; unsigned collections; + Record records[RECORDS]; + unsigned record_count, record_fields; Input inputs[INPUTS]; unsigned input_count, field_count; char view_names[VIEWS][NAME]; @@ -110,7 +129,7 @@ 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"}; + "reply", "require", "record"}; static _Noreturn void fail_at(Compiler *c, int line, int column, const char *format, ...) { va_list args; @@ -326,7 +345,8 @@ static void index_key(Compiler *c) { } static Expr *postfix(Compiler *c, Expr *e) { - if (e->kind == E_NAME && is(c, "[")) { + const Token *next = is(c, "[") ? lookahead(c) : NULL; + if (e->kind == E_NAME && next && next->kind == T_NAME && !strcmp(next->text, "key")) { index_key(c); e->kind = E_DOC; if (is(c, ".") && lookahead(c)->kind == T_NAME && !strcmp(lookahead(c)->text, "bytes")) { @@ -336,6 +356,21 @@ static Expr *postfix(Compiler *c, Expr *e) { } return e; } + if (e->kind == E_NAME && next) { + /* tasks[at].title: a field of a list item. */ + take(c); + Expr *item = node(c, E_ITEM, e->line, e->column); + memcpy(item->name, e->name, NAME); + item->args[0] = expression(c, 0, true); + item->count = 1; + newlines(c); + expect(c, "]", "']'"); + if (is(c, ".")) { + take(c); + memcpy(item->member, expect_name(c, "a field name").text, NAME); + } + e = item; + } while (is(c, ".")) { take(c); Token member = expect_name(c, "a field or member name"); @@ -538,6 +573,38 @@ static Expr *expression(Compiler *c, int minimum, bool multiline) { return left; } +/* Record { field: value, ... }: fields end at a comma, semicolon or line end. */ +static Expr *record_value(Compiler *c) { + Token name = take(c); + take(c); /* { */ + Expr *r = node(c, E_RECORD, name.line, name.column), **tail = &r->args[0]; + memcpy(r->name, name.text, NAME); + separators(c); + while (!is(c, "}")) { + Token field = expect_name(c, "a field name"); + expect(c, ":", "':'"); + Expr *f = node(c, E_FIELD, field.line, field.column); + memcpy(f->member, field.text, NAME); + f->args[0] = expression(c, 0, false); + f->count = 1; + *tail = f; + tail = &f->next; + if (!is(c, ",") && !is(c, ";") && !is(c, "}") && c->token.kind != T_NL) + fail_at(c, c->token.line, c->token.column, "expected ',' or a new line between fields"); + while (is(c, ",") || is(c, ";") || c->token.kind == T_NL) take(c); + } + take(c); + return r; +} + +/* A value inserted into a text or a list: an expression or a record. */ +static Expr *inserted(Compiler *c) { + newlines(c); + if (c->token.kind == T_NAME && lookahead(c)->kind == T_PUNCT && !strcmp(lookahead(c)->text, "{")) + return record_value(c); + return expression(c, 0, true); +} + static Stmt *block(Compiler *c, unsigned depth); static void range(Compiler *c, Stmt *s) { @@ -577,6 +644,25 @@ static Stmt *statement(Compiler *c, unsigned depth) { } if (reserved_name(token.text)) fail_at(c, token.line, token.column, "expected a statement or '}'"); memcpy(s->name, token.text, NAME); + if (is(c, "[") && !(lookahead(c)->kind == T_NAME && !strcmp(lookahead(c)->text, "key"))) { + /* tasks[at].field = value */ + take(c); + s->kind = S_SET; + s->first = expression(c, 0, true); + newlines(c); + expect(c, "]", "']'"); + if (is(c, ".") && lookahead(c)->kind == T_NAME) { + take(c); + Token field = take(c); + if (is(c, "(")) + fail_at(c, s->first->line, s->first->column, "a collection is indexed by the message key, as in name[key]"); + memcpy(s->member, field.text, NAME); + } else fail_at(c, c->token.line, c->token.column, "expected '.' and the field to change, as in %s[at].field = value", + s->name); + expect(c, "=", "'='"); + s->value = expression(c, 0, false); + return s; + } if (is(c, "[")) { index_key(c); expect(c, ".", "'.replace'"); @@ -593,19 +679,31 @@ static Stmt *statement(Compiler *c, unsigned depth) { } if (is(c, ".")) { take(c); - Token method = expect_name(c, "insert or remove"); + Token method = expect_name(c, "insert, remove or append"); expect(c, "(", "'('"); if (!strcmp(method.text, "insert")) { s->kind = S_INSERT; s->first = expression(c, 0, true); newlines(c); expect(c, ",", "','"); - s->value = expression(c, 0, true); + s->value = inserted(c); newlines(c); } else if (!strcmp(method.text, "remove")) { + /* A text removes a range; a list removes the item at an index. */ s->kind = S_REMOVE; - range(c, s); - } else fail_at(c, method.line, method.column, "a text supports insert(cursor, text) and remove(first .. last)"); + s->first = expression(c, 0, true); + newlines(c); + if (is(c, "..")) { + take(c); + s->last = expression(c, 0, true); + newlines(c); + } + } else if (!strcmp(method.text, "append")) { + s->kind = S_APPEND; + s->value = inserted(c); + newlines(c); + } else fail_at(c, method.line, method.column, + "a text supports insert and remove; a list supports append, insert and remove"); expect(c, ")", "')'"); return s; } @@ -641,10 +739,16 @@ static int state_index(const Compiler *c, const char *name) { return -1; } +static int record_index(const Compiler *c, const char *name) { + for (unsigned i = 0; i < c->record_count; ++i) if (!strcmp(c->records[i].name, name)) return (int)i; + return -1; +} + static const char *declared_kind(const Compiler *c, const char *name) { - static const char *const states[] = {"state", "text state", "cursor"}; + static const char *const states[] = {"state", "text state", "cursor", "list", "index"}; int state = state_index(c, name); if (state >= 0) return states[c->state_types[state]]; + if (record_index(c, name) >= 0) return "record"; for (unsigned i = 0; i < c->collections; ++i) if (!strcmp(c->collection_names[i], name)) return "collection"; int input = input_index(c, name); if (input >= 0) return c->inputs[input].kind == KIND_KEY ? "key input" : @@ -693,6 +797,67 @@ static void message_fields(Compiler *c, Input *in) { take(c); } +/* A signed number literal: a numeric state or field default. */ +static double signed_number(Compiler *c, const char *what) { + 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 %s", what); + return sign * take(c).number; +} + +/* A plain string with no {expressions}: a text state or field default. */ +static Expr *plain_string(Compiler *c, const char *what) { + if (c->token.kind != T_STRING) fail_at(c, c->token.line, c->token.column, "%s starts from a string", what); + Token literal = take(c); + Expr *value = string(c, &literal); + if (value->args[0]->kind != E_TEXT || value->args[0]->next) + fail_at(c, literal.line, literal.column, "%s starts from a plain string without {expressions}", what); + return value->args[0]; +} + +/* record Name { field = number; field: text = "..." } */ +static void record_declaration(Compiler *c) { + Token name = expect_name(c, "record name"); + declare(c, &name); + if (c->record_count == RECORDS) fail_at(c, name.line, name.column, "at most 8 records are allowed"); + Record *r = &c->records[c->record_count]; + memset(r, 0, sizeof(*r)); + memcpy(r->name, name.text, NAME); + r->line = name.line; + r->column = name.column; + expect(c, "{", "'{' on the line of the record name"); + separators(c); + while (!is(c, "}")) { + Token field = expect_name(c, "a field name"); + if (reserved_name(field.text)) fail_at(c, field.line, field.column, "'%s' is reserved", field.text); + for (unsigned i = 0; i < r->fields; ++i) + if (!strcmp(r->field_names[i], field.text)) fail_at(c, field.line, field.column, "duplicate field '%s'", field.text); + if (r->fields == FIELDS_PER_RECORD) fail_at(c, field.line, field.column, "a record has at most 16 fields"); + if (c->record_fields == RECORD_FIELDS) fail_at(c, field.line, field.column, "at most 64 record fields are allowed"); + unsigned index = r->fields++; + memcpy(r->field_names[index], field.text, NAME); + ++c->record_fields; + if (is(c, ":")) { + take(c); + Token type = expect_name(c, "text"); + if (strcmp(type.text, "text")) + fail_at(c, type.line, type.column, "a field is a number (field = 0) or text (field: text)"); + r->field_text[index] = true; + if (is(c, "=")) { + take(c); + r->field_string[index] = plain_string(c, "a text field"); + } + } else { + expect(c, "=", "'=' and a number, or ': text'"); + r->field_number[index] = signed_number(c, "field default"); + } + end(c); + } + take(c); + if (!r->fields) fail_at(c, name.line, name.column, "a record needs at least one field"); + ++c->record_count; +} + static void state_declaration(Compiler *c) { Token name = expect_name(c, "state name"); declare(c, &name); @@ -712,40 +877,40 @@ static void state_declaration(Compiler *c) { c->state_column[index] = name.column; if (is(c, ":")) { take(c); - Token type = expect_name(c, "text or cursor"); + Token type = expect_name(c, "text, cursor, list or index"); if (!strcmp(type.text, "text")) { c->state_types[index] = STATE_TEXT; if (is(c, "=")) { take(c); - if (c->token.kind != T_STRING) fail_at(c, c->token.line, c->token.column, "a text state starts from a string"); - Token literal = take(c); - Expr *value = string(c, &literal); - if (value->args[0]->kind != E_TEXT || value->args[0]->next) - fail_at(c, literal.line, literal.column, "a text state starts from a plain string without {expressions}"); - c->state_text[index] = value->args[0]; + c->state_text[index] = plain_string(c, "a text state"); } } else if (!strcmp(type.text, "cursor")) { c->state_types[index] = STATE_CURSOR; expect_word(c, "in"); Token target = expect_name(c, "a text state"); memcpy(c->state_target[index], target.text, NAME); - } else fail_at(c, type.line, type.column, "state types are numbers, text, cursor in a text, and bytes by key"); + } else if (!strcmp(type.text, "list") || !strcmp(type.text, "index")) { + c->state_types[index] = type.text[0] == 'l' ? STATE_LIST : STATE_INDEX; + expect_word(c, "of"); + Token target = expect_name(c, type.text[0] == 'l' ? "a record" : "a list state"); + memcpy(c->state_target[index], target.text, NAME); + } else fail_at(c, type.line, type.column, + "state types are numbers, text, cursor in a text, list of a record, index of a list, and bytes by key"); ++c->states; return; } 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"); c->state_types[index] = STATE_NUM; - c->initial[index] = sign * take(c).number; + c->initial[index] = signed_number(c, "state initializer"); ++c->states; } static void declaration(Compiler *c) { - Token kind = expect_name(c, "state, input, view, or on"); + Token kind = expect_name(c, "state, record, input, view, or on"); if (!strcmp(kind.text, "state")) { state_declaration(c); + } else if (!strcmp(kind.text, "record")) { + record_declaration(c); } else if (!strcmp(kind.text, "input")) { Token name = expect_name(c, "input name"); declare(c, &name); @@ -816,7 +981,7 @@ static void emit(Compiler *c, int line, int column, uint8_t op, uint32_t arg, do } static const char *type_name(Type type) { - static const char *const names[] = {"", "a number", "bytes", "text", "a cursor", "a distance"}; + static const char *const names[] = {"", "a number", "bytes", "text", "a cursor", "a distance", "an index"}; return names[type]; } @@ -859,6 +1024,27 @@ static int text_state(Compiler *c, const char *name, int line, int column) { static int cursor_owner(const Compiler *c, int cursor) { return state_index(c, c->state_target[cursor]); } +/* The list state name[...] indexes; where points at the index for a collection. */ +static int list_state(Compiler *c, const char *name, int line, int column, const Expr *where) { + int index = state_index(c, name); + if (index >= 0 && c->state_types[index] == STATE_LIST) return index; + for (unsigned i = 0; i < c->collections; ++i) + if (!strcmp(c->collection_names[i], name) && where) + fail_at(c, where->line, where->column, "a collection is indexed by the message key, as in name[key]"); + const char *kind = declared_kind(c, name); + if (kind) fail_at(c, line, column, "'%s' is a %s, not a list", name, kind); + fail_at(c, line, column, "unknown list '%s'", name); +} + +static const Record *record_of(const Compiler *c, int list) { + return &c->records[record_index(c, c->state_target[list])]; +} + +static unsigned record_field(Compiler *c, const Record *r, const char *field, int line, int column) { + for (unsigned i = 0; i < r->fields; ++i) if (!strcmp(r->field_names[i], field)) return i; + fail_at(c, line, column, "record '%s' has no field '%s'", r->name, field); +} + static Kind value(Compiler *c, const Expr *e, bool handler, int height); static void expect_kind(Compiler *c, const Expr *e, Kind actual, Type type) { @@ -879,6 +1065,15 @@ static void cursor_of(Compiler *c, const Expr *e, bool handler, int height, int fail_at(c, e->line, e->column, "a cursor of '%s' cannot point into '%s'", c->state_names[kind.owner], name); } +/* An index of the named list state. */ +static void index_of(Compiler *c, const Expr *e, bool handler, int height, int list) { + Kind kind = value(c, e, handler, height); + expect_kind(c, e, kind, INDEX); + if (kind.owner != list) + fail_at(c, e->line, e->column, "an index of '%s' cannot point into '%s'", c->state_names[kind.owner], + c->state_names[list]); +} + /* Emit an expression; height is the stack height before it. */ 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"); @@ -904,9 +1099,13 @@ static Kind value(Compiler *c, const Expr *e, bool handler, int height) { int index = state_index(c, e->name); if (index >= 0) { int type = c->state_types[index]; - emit(c, e->line, e->column, type == STATE_NUM ? LOAD : type == STATE_TEXT ? TEXT_LOAD : CUR_LOAD, (uint32_t)index, 0); - return type == STATE_NUM ? (Kind){NUM, -1} : type == STATE_TEXT ? (Kind){TEXT, -1} : - (Kind){CURSOR, cursor_owner(c, index)}; + if (type == STATE_LIST) + fail_at(c, e->line, e->column, "'%s' is a list; read %s.count or a field, as in %s[index].field", + e->name, e->name, e->name); + static const uint8_t loads[] = {LOAD, TEXT_LOAD, CUR_LOAD, 0, INDEX_LOAD}; + static const Type types[] = {NUM, TEXT, CURSOR, 0, INDEX}; + emit(c, e->line, e->column, loads[type], (uint32_t)index, 0); + return (Kind){types[type], type == STATE_CURSOR || type == STATE_INDEX ? cursor_owner(c, index) : -1}; } int input = input_index(c, e->name); if (input >= 0 && c->inputs[input].kind == KIND_TEXT) { @@ -941,6 +1140,15 @@ static Kind value(Compiler *c, const Expr *e, bool handler, int height) { emit(c, e->line, e->column, e->member[0] == 's' ? CUR_START : CUR_END, (uint32_t)state, 0); return (Kind){CURSOR, state}; } + if (state >= 0 && c->state_types[state] == STATE_LIST) { + const char *m = e->member; + if (!strcmp(m, "count")) { emit(c, e->line, e->column, LIST_COUNT, (uint32_t)state, 0); return (Kind){NUM, -1}; } + if (!strcmp(m, "first") || !strcmp(m, "last")) { + emit(c, e->line, e->column, m[0] == 'f' ? INDEX_FIRST : INDEX_LAST, (uint32_t)state, 0); + return (Kind){INDEX, state}; + } + fail_at(c, e->line, e->column, "a list has count, first and last, not '%s'", m); + } } Kind inner = value(c, base, handler, height); const char *m = e->member; @@ -953,6 +1161,7 @@ static Kind value(Compiler *c, const Expr *e, bool handler, int height) { emit(c, e->line, e->column, CUR_COUNT, m[0] == 'c' ? UNIT_CHAR : UNIT_LINE, 0); return (Kind){NUM, -1}; } + if (inner.type == INDEX && !strcmp(m, "position")) { emit(c, e->line, e->column, INDEX_NUM, 0, 0); return (Kind){NUM, -1}; } fail_at(c, e->line, e->column, "%s has no '%s'", type_name(inner.type), m); } case E_LINE: { @@ -976,17 +1185,33 @@ static Kind value(Compiler *c, const Expr *e, bool handler, int height) { emit(c, e->line, e->column, (uint8_t)e->op, 0, 0); return (Kind){e->op == TEXT_FROM ? TEXT : BYTES, -1}; } + case E_ITEM: { + int list = list_state(c, e->name, e->line, e->column, e->args[0]); + const Record *r = record_of(c, list); + if (!*e->member) fail_at(c, e->line, e->column, "read a field of the item, as in %s[index].%s", e->name, r->field_names[0]); + unsigned field = record_field(c, r, e->member, e->line, e->column); + index_of(c, e->args[0], handler, height, list); + emit(c, e->line, e->column, ITEM_GET, (uint32_t)list | field << 16, 0); + return (Kind){r->field_text[field] ? TEXT : NUM, -1}; + } + case E_RECORD: case E_FIELD: + fail_at(c, e->line, e->column, "a record value goes into a list, as in list.append(%s { ... })", e->name); case E_UNIT: fail_at(c, e->line, e->column, "a distance moves a cursor, as in at + 1 char"); case E_BINARY: - if (e->args[1]->kind == E_UNIT && (e->op == ADD || e->op == SUB)) { - Kind cursor = value(c, e->args[0], handler, height); - if (cursor.type != CURSOR) - fail_at(c, e->line, e->column, "only a cursor moves by a distance, not %s", type_name(cursor.type)); - number(c, e->args[1]->args[0], handler, height + 1); + if (e->op == ADD || e->op == SUB) { + /* at + 1 char moves a cursor, at + 1 an index; both stop at the ends. */ + bool distance = e->args[1]->kind == E_UNIT; + Kind left = value(c, e->args[0], handler, height); + if (distance && left.type != CURSOR) + fail_at(c, e->line, e->column, "only a cursor moves by a distance, not %s", type_name(left.type)); + if (!distance && left.type != INDEX) expect_kind(c, e->args[0], left, NUM); + number(c, distance ? e->args[1]->args[0] : e->args[1], handler, height + 1); + if (left.type == NUM) { emit(c, e->line, e->column, (uint8_t)e->op, 0, 0); return left; } if (e->op == SUB) emit(c, e->line, e->column, NEG, 0, 0); - emit(c, e->line, e->column, CUR_MOVE, (uint32_t)e->args[1]->op, 0); - return cursor; + if (distance) emit(c, e->line, e->column, CUR_MOVE, (uint32_t)e->args[1]->op, 0); + else emit(c, e->line, e->column, INDEX_MOVE, 0, 0); + return left; } /* fall through */ default: @@ -1003,6 +1228,28 @@ static void bytes_value(Compiler *c, const Expr *e, int height, const char *what else if (kind.type != BYTES) fail_at(c, e->line, e->column, "%s takes bytes or text, not %s", what, type_name(kind.type)); } +/* A record's fields in order, onto the stack from height; missing fields take their default. */ +static void record_fields(Compiler *c, const Expr *e, int list, int height) { + const Record *r = record_of(c, list); + if (e->kind != E_RECORD) + fail_at(c, e->line, e->column, "'%s' holds %s records, as in %s.append(%s { %s: ... })", c->state_names[list], + r->name, c->state_names[list], r->name, r->field_names[0]); + if (strcmp(e->name, r->name)) fail_at(c, e->line, e->column, "'%s' holds %s, not %s", c->state_names[list], r->name, e->name); + const Expr *given[FIELDS_PER_RECORD] = {0}; + for (const Expr *f = e->args[0]; f; f = f->next) { + unsigned field = record_field(c, r, f->member, f->line, f->column); + if (given[field]) fail_at(c, f->line, f->column, "field '%s' is given twice", f->member); + given[field] = f; + } + for (unsigned k = 0; k < r->fields; ++k) { + int at = height + (int)k; + if (given[k]) expect_kind(c, given[k]->args[0], value(c, given[k]->args[0], true, at), r->field_text[k] ? TEXT : NUM); + else if (at + 1 > STACK) fail_at(c, e->line, e->column, "expression needs more than 64 stack values"); + else if (r->field_text[k]) emit(c, e->line, e->column, TEXT_CONST, r->field_constant[k], 0); + else emit(c, e->line, e->column, CONST, 0, r->field_number[k]); + } +} + static void statements(Compiler *c, const Stmt *s) { for (; s; s = s->next) { switch (s->kind) { @@ -1016,6 +1263,11 @@ static void statements(Compiler *c, const Stmt *s) { if (c->state_types[index] == STATE_NUM) { number(c, s->value, true, 0); emit(c, s->line, s->column, STORE, (uint32_t)index, 0); + } else if (c->state_types[index] == STATE_LIST) { + fail_at(c, s->line, s->column, "a list changes by append, insert, remove and %s[index].field = value", s->name); + } else if (c->state_types[index] == STATE_INDEX) { + index_of(c, s->value, true, 0, cursor_owner(c, index)); + emit(c, s->line, s->column, INDEX_STORE, (uint32_t)index, 0); } else if (c->state_types[index] == STATE_TEXT) { expect_kind(c, s->value, value(c, s->value, true, 0), TEXT); emit(c, s->line, s->column, TEXT_SET, (uint32_t)index, 0); @@ -1026,20 +1278,42 @@ static void statements(Compiler *c, const Stmt *s) { } break; } - case S_INSERT: { - int text = text_state(c, s->name, s->line, s->column); - cursor_of(c, s->first, true, 0, text, s->name); - expect_kind(c, s->value, value(c, s->value, true, 1), TEXT); - emit(c, s->line, s->column, TEXT_INSERT, (uint32_t)text, 0); - break; - } - case S_REMOVE: { + case S_INSERT: case S_REMOVE: { + int list = state_index(c, s->name); + if (list >= 0 && c->state_types[list] == STATE_LIST) { + if (s->last) fail_at(c, s->last->line, s->last->column, "a list removes one item, as in %s.remove(index)", s->name); + index_of(c, s->first, true, 0, list); + if (s->kind == S_INSERT) record_fields(c, s->value, list, 1); + emit(c, s->line, s->column, s->kind == S_INSERT ? LIST_INSERT : LIST_REMOVE, (uint32_t)list, 0); + break; + } int text = text_state(c, s->name, s->line, s->column); cursor_of(c, s->first, true, 0, text, s->name); + if (s->kind == S_INSERT) { + expect_kind(c, s->value, value(c, s->value, true, 1), TEXT); + emit(c, s->line, s->column, TEXT_INSERT, (uint32_t)text, 0); + break; + } + if (!s->last) fail_at(c, s->line, s->column, "a text removes a range, as in %s.remove(first .. last)", s->name); cursor_of(c, s->last, true, 1, text, s->name); emit(c, s->line, s->column, TEXT_REMOVE, (uint32_t)text, 0); break; } + case S_APPEND: { + int list = list_state(c, s->name, s->line, s->column, NULL); + record_fields(c, s->value, list, 0); + emit(c, s->line, s->column, LIST_APPEND, (uint32_t)list, 0); + break; + } + case S_SET: { + int list = list_state(c, s->name, s->line, s->column, s->first); + const Record *r = record_of(c, list); + unsigned field = record_field(c, r, s->member, s->line, s->column); + index_of(c, s->first, true, 0, list); + expect_kind(c, s->value, value(c, s->value, true, 1), r->field_text[field] ? TEXT : NUM); + emit(c, s->line, s->column, ITEM_SET, (uint32_t)list | field << 16, 0); + break; + } case S_REPLACE: { uint32_t target = collection(c, s->name, s->line, s->column); number(c, s->first, true, 0); @@ -1113,12 +1387,25 @@ static void generate(Compiler *c, unsigned char **module, size_t *size) { if (c->state_types[i] == STATE_TEXT) { Expr empty = {.kind = E_TEXT, .line = c->state_line[i], .column = c->state_column[i]}; state_ref[i] = constant(c, c->state_text[i] ? c->state_text[i] : &empty); - } else if (c->state_types[i] == STATE_CURSOR) { + } else if (c->state_types[i] == STATE_CURSOR || c->state_types[i] == STATE_INDEX) { + bool cursor = c->state_types[i] == STATE_CURSOR; int target = state_index(c, c->state_target[i]); - if (target < 0 || c->state_types[target] != STATE_TEXT) - fail_at(c, c->state_line[i], c->state_column[i], "a cursor moves in a text state; '%s' is not one", - c->state_target[i]); + if (target < 0 || c->state_types[target] != (cursor ? STATE_TEXT : STATE_LIST)) + fail_at(c, c->state_line[i], c->state_column[i], cursor ? "a cursor moves in a text state; '%s' is not one" : + "an index points into a list state; '%s' is not one", c->state_target[i]); state_ref[i] = (uint32_t)target; + } else if (c->state_types[i] == STATE_LIST) { + int record = record_index(c, c->state_target[i]); + if (record < 0) + fail_at(c, c->state_line[i], c->state_column[i], "a list holds a record; '%s' is not one", c->state_target[i]); + state_ref[i] = (uint32_t)record; + } + } + for (unsigned i = 0; i < c->record_count; ++i) { + Record *r = &c->records[i]; + for (unsigned k = 0; k < r->fields; ++k) { + Expr empty = {.kind = E_TEXT, .line = r->line, .column = r->column}; + if (r->field_text[k]) r->field_constant[k] = constant(c, r->field_string[k] ? r->field_string[k] : &empty); } } for (unsigned i = 0; i < c->handler_count; ++i) { @@ -1158,10 +1445,11 @@ static void generate(Compiler *c, unsigned char **module, size_t *size) { statements(c, c->handlers[i].body); c->handler_code[i][1] = c->code_count - c->handler_code[i][0]; } - static unsigned char strings[(STATES + COLLECTIONS + 2 * INPUTS + FIELDS + VIEWS) * NAME]; + static unsigned char strings[(STATES + COLLECTIONS + 2 * INPUTS + FIELDS + VIEWS + RECORDS + RECORD_FIELDS) * NAME]; size_t used = 0; uint32_t state_name[STATES] = {0}, collection_name[COLLECTIONS] = {0}, input_name[INPUTS] = {0}, input_key[INPUTS] = {0}; - uint32_t field_name[FIELDS] = {0}, view_name[VIEWS] = {0}; + uint32_t field_name[FIELDS] = {0}, view_name[VIEWS] = {0}, record_name[RECORDS] = {0}; + uint32_t record_field_name[RECORDS][FIELDS_PER_RECORD] = {{0}}; unsigned fields = 0; for (unsigned i = 0; i < c->states; ++i) state_name[i] = intern(strings, &used, c->state_names[i]); for (unsigned i = 0; i < c->collections; ++i) collection_name[i] = intern(strings, &used, c->collection_names[i]); @@ -1172,8 +1460,13 @@ static void generate(Compiler *c, unsigned char **module, size_t *size) { field_name[fields++] = intern(strings, &used, c->inputs[i].field_names[f]); } for (unsigned i = 0; i < c->views; ++i) view_name[i] = intern(strings, &used, c->view_names[i]); + for (unsigned i = 0; i < c->record_count; ++i) { + record_name[i] = intern(strings, &used, c->records[i].name); + for (unsigned k = 0; k < c->records[i].fields; ++k) + record_field_name[i][k] = intern(strings, &used, c->records[i].field_names[k]); + } *size = 40 + 16u * c->states + 12u * c->collections + 16u * c->input_count + 8u * fields + 8u * c->constants + - 16u * c->views + 12u * c->handler_count + 16u * c->code_count + used + c->constant_used; + 8u * c->record_count + 16u * c->record_fields + 16u * c->views + 12u * c->handler_count + 16u * c->code_count + used + c->constant_used; unsigned char *p = *module = allocate(c, *size); memcpy(p, "PUREMOD4", 8); put16(p + 8, c->states); @@ -1185,7 +1478,9 @@ static void generate(Compiler *c, unsigned char **module, size_t *size) { put16(p + 24, fields); put16(p + 26, c->collections); put16(p + 28, c->constants); + put16(p + 30, c->record_count); put32(p + 32, (uint32_t)c->constant_used); + put16(p + 36, c->record_fields); p += 40; for (unsigned i = 0; i < c->states; ++i) { p = put32(p, state_name[i]); @@ -1213,6 +1508,21 @@ static void generate(Compiler *c, unsigned char **module, size_t *size) { p = put32(p, (uint32_t)c->inputs[i].field_types[f]); } for (unsigned i = 0; i < c->constants; ++i) { p = put32(p, c->constant_offset[i]); p = put32(p, c->constant_length[i]); } + first = 0; + for (unsigned i = 0; i < c->record_count; ++i) { + p = put32(p, record_name[i]); + p = put16(p, first); + p = put16(p, c->records[i].fields); + first += c->records[i].fields; + } + for (unsigned i = 0; i < c->record_count; ++i) + for (unsigned k = 0; k < c->records[i].fields; ++k) { + const Record *r = &c->records[i]; + p = put32(p, record_field_name[i][k]); + p = put32(p, r->field_text[k]); + if (r->field_text[k]) { p = put32(p, r->field_constant[k]); p = put32(p, 0); } + else p = put_double(p, r->field_number[k]); + } for (unsigned i = 0; i < c->views; ++i) { p = put32(p, view_name[i]); p = put32(p, (uint32_t)c->view_types[i]); diff --git a/tests/check.py b/tests/check.py index 5ed90ff..f4f3b92 100644 --- a/tests/check.py +++ b/tests/check.py @@ -14,6 +14,10 @@ mark exactly as a reference Python model under random typing (ASCII, Korean, emoji, combining marks, NUL, newlines), moves and deletions; string escapes and number text follow their stated rules. +7. List model: examples/tasks.aup keeps its list, index, views and failed + events exactly as a reference Python model under random appends, moves, + toggles, copies, renames and removals; replacing it with a module whose + record changed keeps each item's fields by name. 5. Editor parity: the document editor written in aup, run by pure-edit-module, answers random show/edit/apply operations exactly as pure-edit running the original editor.pure (stdout, stderr, exit code), on the same tree store @@ -30,6 +34,9 @@ from pathlib import Path HERE = Path(__file__).resolve().parent ROOT = HERE.parent +RECORD = 'record T {\n a = 0\n}\n' +ON_K = 'input k = key KeyK\non k.down {\n' + 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'), @@ -86,6 +93,38 @@ DIAGNOSTICS = [ ('input typed = text\non typed.down {\n}\n', '2:4', "'typed' is a text input; write on typed { }"), ('view v = text(3)\n', '1:10', 'text(...) converts bytes, not a number'), ('view v = "é"\nview w = 1 é\n', '2:12', 'unexpected byte 0xc3 outside a string'), + # records, lists and indices + ('record T {\n a = 0\n a = 1\n}\n', '3:3', "duplicate field 'a'"), + ('record T {\n}\n', '1:8', 'a record needs at least one field'), + ('record T {\n a: number\n}\n', '2:6', 'a field is a number (field = 0) or text (field: text)'), + ('record T {\n a = 0\n}\nstate T = 0\n', '4:7', "'T' is already declared as a record"), + ('state xs: list of Missing\n', '1:7', "a list holds a record; 'Missing' is not one"), + ('state x = 0\nstate at: index of x\n', '2:7', "an index points into a list state; 'x' is not one"), + (RECORD + 'state xs: list T\n', '4:16', "expected 'of'"), + (RECORD + 'state xs: list of T\nstate ys: list of T\nstate at: index of xs\nview v = ys[at].a\n', '7:13', + "an index of 'xs' cannot point into 'ys'"), + (RECORD + 'state xs: list of T\nstate at: index of xs\nview v = xs[at].b\n', '6:10', "record 'T' has no field 'b'"), + (RECORD + 'state xs: list of T\nstate at: index of xs\nview v = xs[at]\n', '6:10', 'read a field of the item'), + (RECORD + 'state xs: list of T\nview v = xs\n', '5:10', "'xs' is a list; read xs.count"), + (RECORD + 'state xs: list of T\nview v = xs.size\n', '5:10', "a list has count, first and last, not 'size'"), + (RECORD + 'state xs: list of T\nstate at: index of xs\nview v = at\n', '6:10', 'a view is a number or text, not an index'), + (RECORD + 'state xs: list of T\nstate at: index of xs\n' + ON_K + ' at = 3\n}\n', '8:8', 'expected an index, not a number'), + (RECORD + 'state xs: list of T\n' + ON_K + ' xs = 1\n}\n', '7:3', 'a list changes by append, insert, remove'), + (RECORD + 'record U {\n a = 0\n}\nstate xs: list of T\n' + ON_K + ' xs.append(U { a: 1 })\n}\n', '10:13', + "'xs' holds T, not U"), + (RECORD + 'state xs: list of T\n' + ON_K + ' xs.append(1)\n}\n', '7:13', "'xs' holds T records"), + (RECORD + 'state xs: list of T\n' + ON_K + ' xs.append(T { a: "x" })\n}\n', '7:20', 'expected a number, not text'), + (RECORD + 'state xs: list of T\n' + ON_K + ' xs.append(T { b: 1 })\n}\n', '7:17', "record 'T' has no field 'b'"), + (RECORD + 'state xs: list of T\n' + ON_K + ' xs.append(T { a: 1, a: 2 })\n}\n', '7:23', "field 'a' is given twice"), + (RECORD + 'state xs: list of T\n' + ON_K + ' xs.append(T { a: 1 a: 2 })\n}\n', '7:22', + "expected ',' or a new line between fields"), + (RECORD + 'state xs: list of T\nstate at: index of xs\n' + ON_K + ' xs.remove(at .. at)\n}\n', '8:19', + 'a list removes one item'), + (RECORD + 'state xs: list of T\nstate at: index of xs\n' + ON_K + ' xs[at].a = "x"\n}\n', '8:14', + 'expected a number, not text'), + (RECORD + 'state n: text\nstate at: cursor in n\n' + ON_K + ' n.insert(at, T { a: 1 })\n}\n', '8:16', + 'a record value goes into a list'), + ('state n: text\nstate at: cursor in n\n' + ON_K + ' n.remove(at)\n}\n', '5:3', 'a text removes a range'), ] ACCEPTED = [ @@ -102,6 +141,15 @@ ACCEPTED = [ 'on open {\n note = text(documents[key])\n at = note.end\n}\n' 'on typed { note.insert(at, typed) }\n' 'on save {\n documents[key].replace(0 .. documents[key].bytes, note)\n reply "saved {note.chars} characters"\n}\n', + # a record literal over several lines, text defaults, inserting at the first item + 'record Item {\n name: text = "untitled"\n price = 0; count = -1\n}\n' + 'state items: list of Item\nstate at: index of items\ninput add = key KeyA\n' + 'on add.down {\n items.insert(items.first, Item {\n price: 2.5,\n count: items.count + 1,\n })\n' + ' at = items.first\n items[at].name = "{items[at].name} #{items.count}"\n}\nview total = items.count\n', + # index arithmetic and positions in conditions and views + 'record Row {\n label: text\n}\nstate rows: list of Row\nstate focus: index of rows\ninput next = key Tab\n' + 'on next.down {\n if focus.position + 1 < rows.count {\n focus = focus + 1\n } else {\n focus = rows.first\n }\n}\n' + 'view summary = "{rows.count} rows, focus {focus.position}"\n', ] @@ -376,6 +424,89 @@ class NotesModel: 'size': len(self.note.encode()), 'lines': self.note.count('\n') + 1}) +class TasksModel: + """Reference semantics of examples/tasks.aup: the index names an item (0 in + an empty list), stays on its item across inserts and removes before it, + names the next item when its item is removed, and stops at the ends. An + event that reads a missing item fails and changes nothing.""" + + def __init__(self): + self.tasks, self.at, self.made = [], 0, 0 + + def last(self): + return max(len(self.tasks) - 1, 0) + + def typed(self, word): + self.tasks.append({'title': word, 'done': 0}) + self.at, self.made = self.last(), self.made + 1 + return 'none' + + def key(self, name): + if name in ('toggle', 'drop', 'again', 'shout') and not self.tasks: + return 'not_found' + if name == 'up': self.at = max(self.at - 1, 0) + elif name == 'down': self.at = min(self.at + 1, self.last()) + elif name == 'toggle': self.tasks[self.at]['done'] = int(self.tasks[self.at]['done'] == 0) + elif name == 'drop': + del self.tasks[self.at] + self.at = min(self.at, self.last()) + elif name == 'top': self.at = 0 + elif name == 'bottom': self.at = self.last() + elif name == 'again': + self.tasks.insert(self.at, dict(self.tasks[self.at])) + self.at += 1 + elif name == 'shout': self.tasks[self.at]['title'] += '!' + return 'none' + + def expected(self): + item = self.tasks[self.at] if self.tasks else None + return ({'tasks': [dict(task) for task in self.tasks], 'at': self.at, 'made': self.made}, + {'count': len(self.tasks), 'position': self.at, 'current': item and item['title'], + 'status': f'{self.at + 1}/{len(self.tasks)}', 'finished': item and item['done']}) + + +def tasks_parity(aup, pure_run, work, seeds, steps=400): + module = work / 'tasks.pmod' + compile_module(aup, ROOT / 'examples/tasks.aup', module) + words = ['milk', '빵', '', 'a"b\\', '😀\n', '\u0000', '👩\u200d💻'] + keys = ['up', 'down', 'toggle', 'drop', 'drop', 'top', 'bottom', 'again', 'shout'] + compared = failed = 0 + for seed in seeds: + rng = random.Random(seed + 9000) + model, commands, expected = TasksModel(), [], [] + for _ in range(steps): + # Appends and copies balance drops, so the list keeps coming back to empty. + if rng.random() < 0.1: + word = rng.choice(words) + commands.append('type typed ' + (word.encode().hex() or '-')) + fault = model.typed(word) + else: + name = rng.choice(keys) + commands.append(f'down {name}') + fault = model.key(name) + expected.append((fault, *model.expected())) + lines = run(pure_run, module, stdin='\n'.join(commands) + '\n', check=False).stdout.splitlines() + assert len(lines) == len(commands), (seed, len(lines)) + for line, command, (fault, states, views) in zip(lines, commands, expected): + snapshot = json.loads(line) + where = (seed, command) + assert snapshot['fault'] == fault and (snapshot['error'] is None) == (fault == 'none'), (where, snapshot) + assert snapshot['states'] == states, (where, snapshot['states'], states) + assert snapshot['views'] == views, (where, snapshot['views'], views) + compared += 1 + failed += fault != 'none' + # A replacement whose record gained and reordered fields keeps every item. + (work / 'tasks2.aup').write_text('record Task {\n urgent = 1\n done = 0\n title: text = "-"\n}\n' + 'state tasks: list of Task\nstate at: index of tasks\n') + compile_module(aup, work / 'tasks2.aup', work / 'tasks2.pmod') + lines = run(pure_run, module, stdin='type typed 6d696c6b\ntype typed 62\ndown toggle\ndown up\n' + f'replace {work / "tasks2.pmod"}\n').stdout.splitlines() + final = json.loads(lines[-1]) + assert final['states'] == {'tasks': [{'urgent': 1, 'done': 0, 'title': 'milk'}, + {'urgent': 1, 'done': 1, 'title': 'b'}], 'at': 0}, final + return compared, failed + + def text_parity(aup, pure_run, work, seeds, steps=400): module = work / 'notes.pmod' compile_module(aup, ROOT / 'examples/notes.aup', module) @@ -484,6 +615,8 @@ def main(): report['parity_failed_events_matched'] = sum(failures for _, failures in results) report['parity_programs'] = len(pairs) report['text_model_snapshots'] = text_parity(aup, pure_run, work, range(args.text_seeds)) + report['list_model_snapshots'], report['list_model_failed_events'] = \ + tasks_parity(aup, pure_run, work, range(args.text_seeds)) report['editor_parity_operations'], report['editor_parity_outcomes'] = \ editor_parity(aup, args.pure.resolve(), work, range(args.editor_seeds)) print(json.dumps(report)) |
