aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--README.md8
-rw-r--r--docs/design/001-language-v0.md5
-rw-r--r--docs/design/002-dsl-direction.md44
-rw-r--r--examples/tasks.aup42
-rw-r--r--src/compile.c418
-rw-r--r--tests/check.py133
6 files changed, 589 insertions, 61 deletions
diff --git a/README.md b/README.md
index c900e6a..b56066b 100644
--- a/README.md
+++ b/README.md
@@ -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))