aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--README.md1
-rw-r--r--docs/design/002-dsl-direction.md22
-rw-r--r--src/main.c136
-rw-r--r--tests/check.py86
4 files changed, 223 insertions, 22 deletions
diff --git a/README.md b/README.md
index 421a81c..f3358be 100644
--- a/README.md
+++ b/README.md
@@ -16,6 +16,7 @@ v0c(레코드·리스트·`index of`·조건식·표 보기·`fn`·`use` 라이�
```sh
make # build/aup
build/aup build examples/counter.aup -o counter.pmod
+build/aup watch examples/counter.aup -o counter.pmod # 저장할 때마다 다시 빌드(vega ... --watch가 받아 바꾼다)
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
diff --git a/docs/design/002-dsl-direction.md b/docs/design/002-dsl-direction.md
index 2ba217a..1432ef6 100644
--- a/docs/design/002-dsl-direction.md
+++ b/docs/design/002-dsl-direction.md
@@ -208,4 +208,26 @@ view summary = words.counted(tasks.count, "task", "tasks")
검증: 라이브러리를 쓴 할 일 목록이 참조 모델과 일치하고, 인자를 한 번만 계산하며(값 전달), 라이브러리 경로·순환·
상태 선언·앱 상태 읽기 오류가 해당 파일 위치로 보고된다. 같은 소스와 라이브러리는 같은 모듈을 만든다. 진단 10건을 더했다.
+### 다시 빌드: aup watch
+
+```sh
+aup watch app.aup -o app.pmod # 저장할 때마다 다시 빌드
+vega tui app.pmod --watch # 실행 중인 앱이 새 모듈로 바뀐다(상태는 이름으로 이어진다)
+```
+
+- **빌드는 읽은 바이트의 함수다.** 빌드는 연 경로와 찾아봤지만 없던 경로를 모두 기록하고, 거기서 읽은 바이트의
+ digest를 함께 남긴다(소스, 쓴 라이브러리, 앞선 탐색 위치). 다시 빌드할 이유는 이 경로 중 하나가 다르게
+ 읽히는 것뿐이다. 파일 시각을 보지 않으므로 빌드와 기록 사이의 저장을 놓치지 않는다. 같은 바이트를 다시 저장하면
+ 아무 일도 없다. 오류가 난 라이브러리를 고치면 다시 빌드되고, 앞선 탐색 위치에 라이브러리가 새로 생겨도 다시
+ 빌드된다.
+- **쓰는 중인 파일은 빌드하지 않는다.** 바뀐 내용은 50 ms 간격으로 두 번 연속 같게 읽힐 때 빌드한다. 편집기가
+ 제자리에 쓰는 도중의 빈 파일이나 앞부분도 컴파일될 수 있는데, 그런 모듈로 바꾸면 실행 중인 앱은 사라진 상태의
+ 값을 잃는다.
+- **모듈은 통째로만 바뀐다.** 결과를 임시 파일에 쓰고 rename한다(`aup build`도 같다). 오류가 나면 마지막 모듈을
+ 그대로 두고, 결과마다 stderr에 한 줄(`aup: built app.pmod` 또는 진단)을 쓴다.
+
+검증: 소스 수정, 라이브러리 수정, 라이브러리의 오류와 수정, 앞선 위치에 라이브러리가 생김, 같은 바이트 저장, 두
+번에 나눠 쓴 저장 10회(앞부분만으로도 컴파일되는 소스)에서 빌드된 모듈이 매번 기대한 보기를 낸다. 두 번 읽기를
+빼면 나눠 쓴 저장 검사가 8번 중 8번 실패한다.
+
구현은 C로 손수 쓴 재귀 하강 파서를 유지한다. 문법은 EBNF로 문서화하고, 이후 `aup fmt`와 언어 서버를 붙인다.
diff --git a/src/main.c b/src/main.c
index 2797d93..b117df4 100644
--- a/src/main.c
+++ b/src/main.c
@@ -1,17 +1,21 @@
#define _POSIX_C_SOURCE 200809L
#include "aup.h"
+#include <signal.h>
+#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
+#include <time.h>
+#include <unistd.h>
static int usage(void) {
- fputs("aup check FILE\naup build FILE -o OUT.pmod\n"
+ fputs("aup check FILE\naup build FILE -o OUT.pmod\naup watch FILE -o OUT.pmod\n"
"A library `use name` is name.aup beside FILE or in a directory of AUP_PATH (':' separated).\n", stderr);
return 2;
}
-/* Read a whole source; NULL if it cannot be read. Longer than the limit is
- * left for the compiler to report. */
+/* Read a whole source (up to one byte past the limit, which the compiler
+ * reports); NULL if it cannot be read. */
static char *read_source(const char *path, size_t *length) {
FILE *file = fopen(path, "rb");
if (!file) return NULL;
@@ -23,21 +27,51 @@ static char *read_source(const char *path, size_t *length) {
return source;
}
-typedef struct { char directory[AUP_PATH]; char *sources[16]; unsigned count; } Libraries;
+/* Everything a build read: each path it opened or looked for, with a digest
+ * of what it found there (0: nothing). A build is a function of these bytes
+ * and AUP_PATH alone, so a path that reads differently is a reason to build
+ * again and nothing else is. */
+enum { READS = 64 };
+typedef struct {
+ char directory[AUP_PATH], paths[READS][AUP_PATH];
+ uint64_t digests[READS];
+ unsigned reads;
+ bool overflowed; /* it read more paths than READS */
+ char *sources[16];
+ unsigned count;
+} Inputs;
-static bool try_library(Libraries *l, const char *directory, size_t directory_length, const char *name,
+static uint64_t digest(const char *bytes, size_t length) {
+ uint64_t h = UINT64_C(0xcbf29ce484222325) ^ length;
+ for (size_t i = 0; i < length; ++i) h = (h ^ (unsigned char)bytes[i]) * UINT64_C(0x100000001b3);
+ return h ? h : 1;
+}
+
+/* read_source, noting in inputs the path and what it held. */
+static char *read_input(Inputs *in, const char *path, size_t *length) {
+ char *text = read_source(path, length);
+ if (in->reads == READS) {
+ in->overflowed = true;
+ } else {
+ snprintf(in->paths[in->reads], AUP_PATH, "%s", path);
+ in->digests[in->reads++] = text ? digest(text, *length) : 0;
+ }
+ return text;
+}
+
+static bool try_library(Inputs *in, const char *directory, size_t directory_length, const char *name,
const char **source, size_t *length, char path[AUP_PATH]) {
int written = snprintf(path, AUP_PATH, "%.*s/%s.aup", (int)directory_length, directory, name);
- if (written < 0 || written >= AUP_PATH || l->count == sizeof(l->sources) / sizeof(*l->sources)) return false;
- char *text = read_source(path, length);
+ if (written < 0 || written >= AUP_PATH || in->count == sizeof(in->sources) / sizeof(*in->sources)) return false;
+ char *text = read_input(in, path, length);
if (!text) return false;
- *source = l->sources[l->count++] = text;
+ *source = in->sources[in->count++] = text;
return true;
}
/* Beside the main source first, then each directory of AUP_PATH in order. */
static bool load_library(void *context, const char *name, const char **source, size_t *length, char path[AUP_PATH]) {
- Libraries *l = context;
+ Inputs *l = context;
if (try_library(l, l->directory, strlen(l->directory), name, source, length, path)) return true;
for (const char *at = getenv("AUP_PATH"); at && *at;) {
size_t span = strcspn(at, ":");
@@ -47,32 +81,90 @@ static bool load_library(void *context, const char *name, const char **source, s
return false;
}
-int main(int argc, char **argv) {
- bool build = argc == 5 && !strcmp(argv[1], "build") && !strcmp(argv[3], "-o");
- if (!build && !(argc == 3 && !strcmp(argv[1], "check"))) return usage();
- const char *path = argv[2];
+/* Compile path; write the module to out (unless NULL) through a temporary
+ * file renamed over it, so a reader never sees half of one. What it read is
+ * left in inputs. 0 on success, 1 on a diagnostic, 2 when a file failed. */
+static int compile(const char *path, const char *out, Inputs *inputs) {
+ *inputs = (Inputs){.count = 0};
size_t length;
- char *source = read_source(path, &length);
+ char *source = read_input(inputs, path, &length);
if (!source) { perror(path); return 2; }
- Libraries libraries = {.count = 0};
const char *slash = strrchr(path, '/');
- snprintf(libraries.directory, sizeof(libraries.directory), "%.*s", slash ? (int)(slash - path) : 1, slash ? path : ".");
+ snprintf(inputs->directory, sizeof(inputs->directory), "%.*s", slash ? (int)(slash - path) : 1, slash ? path : ".");
unsigned char *module;
size_t size;
AupDiagnostic error;
- bool compiled = aup_compile(source, length, load_library, &libraries, &module, &size, &error);
- for (unsigned i = 0; i < libraries.count; ++i) free(libraries.sources[i]);
+ bool compiled = aup_compile(source, length, load_library, inputs, &module, &size, &error);
+ for (unsigned i = 0; i < inputs->count; ++i) free(inputs->sources[i]);
free(source);
if (!compiled) {
fprintf(stderr, "%s:%d:%d: %s\n", *error.path ? error.path : path, error.line, error.column, error.message);
return 1;
}
int status = 0;
- if (build) {
- FILE *out = fopen(argv[4], "wb");
- if (!out || fwrite(module, 1, size, out) != size) { perror(argv[4]); status = 2; }
- if (out && fclose(out)) { perror(argv[4]); status = 2; }
+ if (out) {
+ char temporary[AUP_PATH + 32];
+ snprintf(temporary, sizeof(temporary), "%s.%ld.tmp", out, (long)getpid());
+ FILE *file = fopen(temporary, "wb");
+ if (!file || fwrite(module, 1, size, file) != size) { perror(temporary); status = 2; }
+ if (file && fclose(file)) { perror(temporary); status = 2; }
+ if (!status && rename(temporary, out)) { perror(out); status = 2; }
+ if (status) remove(temporary);
}
free(module);
return status;
}
+
+static volatile sig_atomic_t stopping;
+static void on_stop(int signal_number) { (void)signal_number; stopping = 1; }
+
+/* One digest of what the paths of the last build hold: as it read them, or
+ * as they read now. Comparing bytes, not file times, no write is lost
+ * between a build and its bookkeeping, and saving the same bytes is no change. */
+static uint64_t contents(const Inputs *in, bool now) {
+ uint64_t h = 0;
+ for (unsigned i = 0; i < in->reads; ++i) {
+ uint64_t d = in->digests[i];
+ if (now) {
+ size_t length;
+ char *text = read_source(in->paths[i], &length);
+ d = text ? digest(text, length) : 0;
+ free(text);
+ }
+ h = (h ^ d) * UINT64_C(0x100000001b3);
+ }
+ return h;
+}
+
+/* Build now and again whenever what the last build read reads differently:
+ * the source, a library (the one with the error in it too), or a library
+ * appearing where one was looked for. A change is built once it reads the
+ * same twice in a row, so a file caught halfway through being written is not
+ * (a running app swapped to it would lose its states). A failed build keeps
+ * the last module. Each outcome is one line on stderr. */
+static int watch(const char *path, const char *out) {
+ signal(SIGINT, on_stop);
+ signal(SIGTERM, on_stop);
+ Inputs inputs;
+ uint64_t built = 0, seen = 0;
+ for (bool first = true; !stopping; first = false) {
+ uint64_t now = first ? 0 : contents(&inputs, true);
+ if (first || (now != built && now == seen)) {
+ if (!compile(path, out, &inputs)) fprintf(stderr, "aup: built %s\n", out);
+ if (inputs.overflowed) fprintf(stderr, "aup: watching only the first %d paths this build read\n", READS);
+ built = seen = contents(&inputs, false);
+ continue;
+ }
+ seen = now;
+ nanosleep(&(struct timespec){0, 50000000}, NULL);
+ }
+ return 0;
+}
+
+int main(int argc, char **argv) {
+ Inputs inputs;
+ if (argc == 5 && !strcmp(argv[3], "-o") && !strcmp(argv[1], "build")) return compile(argv[2], argv[4], &inputs);
+ if (argc == 5 && !strcmp(argv[3], "-o") && !strcmp(argv[1], "watch")) return watch(argv[2], argv[4]);
+ if (argc == 3 && !strcmp(argv[1], "check")) return compile(argv[2], NULL, &inputs);
+ return usage();
+}
diff --git a/tests/check.py b/tests/check.py
index ec8e9de..4cd63c5 100644
--- a/tests/check.py
+++ b/tests/check.py
@@ -28,6 +28,11 @@
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.
+10. Watch: aup watch builds again when the source or a library it read is
+ written, or a library appears where one was looked for; it keeps the last
+ module while the source has an error (a library's error too), saving the
+ same bytes builds nothing, a file caught halfway through being written is
+ not built, and a module is only ever replaced whole.
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
@@ -1050,6 +1055,86 @@ def folds(aup, pure_run, work, seeds, steps=300):
return compared
+def watching(aup, pure_run, work):
+ """aup watch: whatever the last build read, read differently, builds
+ again; errors keep the last module; the module is only ever replaced whole."""
+ import os
+ import time
+ directory, far = work / 'watch', work / 'watch-far'
+ directory.mkdir()
+ far.mkdir()
+ source, out = directory / 'app.aup', directory / 'app.pmod'
+ library, beside = far / 'shared.aup', directory / 'shared.aup'
+ library.write_text('fn twice(n) = n * 2\n')
+ program = 'use shared\nstate count = {}\ninput tap = key Space\nview doubled = shared.twice(count)\n' \
+ 'on tap.down {{\n count = count + 1\n}}\n'
+ source.write_text(program.format(1))
+ watcher = subprocess.Popen([str(aup), 'watch', str(source), '-o', str(out)], stderr=subprocess.PIPE, text=True,
+ env=dict(os.environ, AUP_PATH=str(far)))
+ lines = []
+
+ def heard(expected):
+ """The next stderr line, which must contain expected."""
+ line = watcher.stderr.readline()
+ lines.append(line)
+ assert expected in line, (expected, lines)
+
+ def shown():
+ return json.loads(run(pure_run, out, stdin='show\n').stdout)['views']['doubled']
+
+ def write(path, text):
+ path.write_text(text)
+
+ try:
+ heard(f'built {out}')
+ assert shown() == 2
+ write(source, program.format(21))
+ heard(f'built {out}')
+ assert shown() == 42
+ write(source, program.format(21).replace('twice(count)', 'twice(missing)'))
+ heard(f'{source}:4:')
+ assert shown() == 42 # the last module stays
+ write(source, program.format(5))
+ heard(f'built {out}')
+ assert shown() == 10
+ write(library, 'fn twice(n) = n * 2 + 100\n')
+ heard(f'built {out}')
+ assert shown() == 110
+ write(library, 'fn twice(n) = n * oops\n')
+ heard(f'{library}:1:')
+ assert shown() == 110
+ write(library, 'fn twice(n) = n + n\n') # the fix in the library alone builds again
+ heard(f'built {out}')
+ assert shown() == 10
+ write(beside, 'fn twice(n) = n * 3\n') # found first from now on
+ heard(f'built {out}')
+ assert shown() == 15
+ write(library, 'fn twice(n) = 0\n') # no longer read: nothing to build
+ write(source, program.format(5)) # the same bytes again: nothing to build
+ write(source, program.format(6))
+ heard(f'built {out}')
+ assert shown() == 18
+ # Written in two parts at random moments; the first part alone (and the
+ # empty file before it) compiles too, into a module without the view.
+ rng = random.Random(10)
+ for count in range(7, 17):
+ time.sleep(rng.uniform(0, 0.05))
+ text = program.format(count)
+ cut = text.index('view')
+ with open(source, 'w') as file:
+ file.write(text[:cut])
+ file.flush()
+ time.sleep(0.02)
+ file.write(text[cut:])
+ heard(f'built {out}')
+ assert shown() == 3 * count
+ finally:
+ watcher.terminate()
+ assert watcher.wait(timeout=5) == 0
+ assert sorted(path.name for path in directory.iterdir()) == ['app.aup', 'app.pmod', 'shared.aup'], 'no temporary left'
+ return len(lines)
+
+
def conditionals(aup, pure_run, work):
"""Only the chosen branch runs (10 / x is never computed at x = 0), else-if
chains pick the first true arm, and conditionals nest inside arithmetic."""
@@ -1178,6 +1263,7 @@ def main():
report['list_model_snapshots'], report['list_model_failed_events'] = \
tasks_parity(aup, pure_run, work, range(args.text_seeds))
report['conditional_snapshots'] = conditionals(aup, pure_run, work)
+ report['watched_builds'] = watching(aup, pure_run, work)
report['fold_snapshots'] = folds(aup, pure_run, work, range(args.seeds if hasattr(args, 'seeds') else 8))
report['stored_snapshots'], report['stored_restarts'] = stored_runs(aup, pure_run, work, range(args.text_seeds))
report['library_programs'], report['library_diagnostics'] = libraries(aup, pure_run, work)