diff options
| author | eunhhu <[email protected]> | 2026-09-26 11:49:33 +0100 |
|---|---|---|
| committer | eunhhu <[email protected]> | 2026-09-26 11:49:33 +0100 |
| commit | cb0f7878f11025ea31cd4c5cecb5ae359b14c148 (patch) | |
| tree | 2c2e55d415a5fe264f5a431edfcc45d14f059122 | |
| parent | 7b0d1ec72ee45631d7e71d7498d360b49e268eb6 (diff) | |
| download | aup-cb0f7878f11025ea31cd4c5cecb5ae359b14c148.tar.gz aup-cb0f7878f11025ea31cd4c5cecb5ae359b14c148.zip | |
Add aup watch: build again when what a build read changes
A build records every path it opened or looked for and a digest of the
bytes there, so a rebuild happens exactly when one of them reads
differently: the source, a library (the one with the error too), or a
library appearing where one was searched. A change is built once it reads
the same twice in a row, so a file caught halfway through being written
is never built; the module is replaced whole through a rename.
| -rw-r--r-- | README.md | 1 | ||||
| -rw-r--r-- | docs/design/002-dsl-direction.md | 22 | ||||
| -rw-r--r-- | src/main.c | 136 | ||||
| -rw-r--r-- | tests/check.py | 86 |
4 files changed, 223 insertions, 22 deletions
@@ -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`와 언어 서버를 붙인다. @@ -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) |
