The binding is Dart, and it works
Step one of the Nim migration was to find out whether the FFI boundary holds. It does: `dart/frq_core` calls the Nim core over dart:ffi, and 20 tests on the plain Dart VM cover the marshalling — the UTF-8 round trip through an emoji, the null-vs-empty distinction IRCv3 draws, and ten thousand calls against the ownership rules, which is the case that corrupts a heap rather than failing an assertion. The binding was ClojureDart for one commit and should not have been. The core exists to have less Clojure in the tree, so writing more of it to call the thing replacing it is the wrong direction — and it invented a problem, since `lookupFunction` takes two type arguments and generic interop is the part of ClojureDart least worth fighting for a file that is pure marshalling. In Dart it is a typedef. `flutter/src/frq/core/ffi.cljd` is deleted unmourned. It is its own package rather than a directory under `flutter/`, and that was not a preference: `flutter/pubspec.yaml` depends on the Flutter SDK, so `dart pub get` refuses to resolve it at all, and anything living there needs a Flutter toolchain to check one assertion about a string. Out here it resolves on its own. `just dart-test` builds the library and runs the suite in about a second, which is the difference between a boundary that gets tested and one that gets assumed. No `package:ffi` either — it is there mostly for Utf8 conversions, and doing those against dart:convert costs ten lines and leaves pubspec.yaml alone, which matters when every dependency has to work on three targets. What is NOT done, and is deliberately not done here: nothing imports `frq_core`. Wiring it in means a path dependency in the Flutter app, which means regenerating pubspec.lock and widening the nix build's source root past the `flutter/` + `common/` restriction that keeps a CLAUDE.md edit from invalidating the Dart compile — and the library still has to reach each target before a call site can choose it. That is the next commit, not this one. `common/frq/irc/parse.cljc` remains what every target actually runs. The Flutter build is not re-verified here because nothing it compiles changed: the only file removed was one nothing required, and the pubspec change was reverted. Verified: `just check-common`, `just nim-test` (27) and `just dart-test` (20) all pass, and the flake evaluates with the new `dart` shell beside `nim`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
f7aea3b parent: 438b247 modified
.gitignore +4 -0 | @@ -4,6 +4,10 @@ | ||
| 4 | 4 | # Nim's own object/cache tree, and the test binaries `nim c -r` leaves beside |
| 5 | 5 | # the sources — one per tests/t*.nim, named without an extension. |
| 6 | 6 | /nim/nimcache/ |
| 7 | + | |
| 8 | +# Dart's per-package resolution output. | |
| 9 | +/dart/*/.dart_tool/ | |
| 10 | +/dart/*/pubspec.lock | |
| 7 | 11 | /nim/tests/t* |
| 8 | 12 | !/nim/tests/t*.nim |
| 9 | 13 | |
| @@ -4,6 +4,10 @@ | |||
| 4 | # Nim's own object/cache tree, and the test binaries `nim c -r` leaves beside | 4 | # Nim's own object/cache tree, and the test binaries `nim c -r` leaves beside |
| 5 | # the sources — one per tests/t*.nim, named without an extension. | 5 | # the sources — one per tests/t*.nim, named without an extension. |
| 6 | /nim/nimcache/ | 6 | /nim/nimcache/ |
| 7 | + | ||
| 8 | +# Dart's per-package resolution output. | ||
| 9 | +/dart/*/.dart_tool/ | ||
| 10 | +/dart/*/pubspec.lock | ||
| 7 | /nim/tests/t* | 11 | /nim/tests/t* |
| 8 | !/nim/tests/t*.nim | 12 | !/nim/tests/t*.nim |
| 9 | 13 | ||
modified
.gitlab-ci.yml +12 -0 | @@ -27,3 +27,15 @@ nim-test: | ||
| 27 | 27 | image: nimlang/nim:2.2.0-alpine |
| 28 | 28 | script: |
| 29 | 29 | - cd nim && for t in tests/t*.nim; do nim c -r --hints:off --path:src "$t"; done |
| 30 | + | |
| 31 | +# The Dart side of the same boundary. Needs the Nim library built first, which | |
| 32 | +# is why this is one job and not two: the artifact would be larger than the | |
| 33 | +# build that makes it. | |
| 34 | +dart-test: | |
| 35 | + stage: check | |
| 36 | + image: dart:3.13 | |
| 37 | + script: | |
| 38 | + - apt-get update -qq && apt-get install -y -qq nim | |
| 39 | + - cd nim && nim c --app:lib --mm:orc -d:release --hints:off --path:src | |
| 40 | + --out:../build/nim/libfrqcore.so src/frq_core.nim | |
| 41 | + - cd ../dart/frq_core && dart pub get && dart test -r expanded | |
| @@ -27,3 +27,15 @@ nim-test: | |||
| 27 | image: nimlang/nim:2.2.0-alpine | 27 | image: nimlang/nim:2.2.0-alpine |
| 28 | script: | 28 | script: |
| 29 | - cd nim && for t in tests/t*.nim; do nim c -r --hints:off --path:src "$t"; done | 29 | - cd nim && for t in tests/t*.nim; do nim c -r --hints:off --path:src "$t"; done |
| 30 | + | ||
| 31 | +# The Dart side of the same boundary. Needs the Nim library built first, which | ||
| 32 | +# is why this is one job and not two: the artifact would be larger than the | ||
| 33 | +# build that makes it. | ||
| 34 | +dart-test: | ||
| 35 | + stage: check | ||
| 36 | + image: dart:3.13 | ||
| 37 | + script: | ||
| 38 | + - apt-get update -qq && apt-get install -y -qq nim | ||
| 39 | + - cd nim && nim c --app:lib --mm:orc -d:release --hints:off --path:src | ||
| 40 | + --out:../build/nim/libfrqcore.so src/frq_core.nim | ||
| 41 | + - cd ../dart/frq_core && dart pub get && dart test -r expanded | ||
modified
.rickub/workflows/build.yml +15 -1 | @@ -40,9 +40,23 @@ jobs: | ||
| 40 | 40 | - uses: actions/checkout@v4 |
| 41 | 41 | - run: cd nim && for t in tests/t*.nim; do nim c -r --hints:off --path:src "$t"; done |
| 42 | 42 | |
| 43 | + # The Dart side of the Nim boundary, on the plain VM. Builds the library | |
| 44 | + # first — the test dlopens a real .so, and there is no point reporting that | |
| 45 | + # it could not find one. | |
| 46 | + dart-test: | |
| 47 | + runs-on: ubuntu-latest | |
| 48 | + container: dart:3.13 | |
| 49 | + steps: | |
| 50 | + - uses: actions/checkout@v4 | |
| 51 | + - run: apt-get update -qq && apt-get install -y -qq nim | |
| 52 | + - run: | | |
| 53 | + cd nim && nim c --app:lib --mm:orc -d:release --hints:off --path:src \ | |
| 54 | + --out:../build/nim/libfrqcore.so src/frq_core.nim | |
| 55 | + - run: cd dart/frq_core && dart pub get && dart test -r expanded | |
| 56 | + | |
| 43 | 57 | web: |
| 44 | 58 | runs-on: ubuntu-latest |
| 45 | - needs: [check-common, nim-test] | |
| 59 | + needs: [check-common, nim-test, dart-test] | |
| 46 | 60 | # What it spends its time on is the ClojureDart compile and, on a cold |
| 47 | 61 | # toolchain, fetching the pinned Flutter/JDK/Clojure tarballs. |
| 48 | 62 | timeout-minutes: 30 |
| @@ -40,9 +40,23 @@ jobs: | |||
| 40 | - uses: actions/checkout@v4 | 40 | - uses: actions/checkout@v4 |
| 41 | - run: cd nim && for t in tests/t*.nim; do nim c -r --hints:off --path:src "$t"; done | 41 | - run: cd nim && for t in tests/t*.nim; do nim c -r --hints:off --path:src "$t"; done |
| 42 | 42 | ||
| 43 | + # The Dart side of the Nim boundary, on the plain VM. Builds the library | ||
| 44 | + # first — the test dlopens a real .so, and there is no point reporting that | ||
| 45 | + # it could not find one. | ||
| 46 | + dart-test: | ||
| 47 | + runs-on: ubuntu-latest | ||
| 48 | + container: dart:3.13 | ||
| 49 | + steps: | ||
| 50 | + - uses: actions/checkout@v4 | ||
| 51 | + - run: apt-get update -qq && apt-get install -y -qq nim | ||
| 52 | + - run: | | ||
| 53 | + cd nim && nim c --app:lib --mm:orc -d:release --hints:off --path:src \ | ||
| 54 | + --out:../build/nim/libfrqcore.so src/frq_core.nim | ||
| 55 | + - run: cd dart/frq_core && dart pub get && dart test -r expanded | ||
| 56 | + | ||
| 43 | web: | 57 | web: |
| 44 | runs-on: ubuntu-latest | 58 | runs-on: ubuntu-latest |
| 45 | - needs: [check-common, nim-test] | 59 | + needs: [check-common, nim-test, dart-test] |
| 46 | # What it spends its time on is the ClojureDart compile and, on a cold | 60 | # What it spends its time on is the ClojureDart compile and, on a cold |
| 47 | # toolchain, fetching the pinned Flutter/JDK/Clojure tarballs. | 61 | # toolchain, fetching the pinned Flutter/JDK/Clojure tarballs. |
| 48 | timeout-minutes: 30 | 62 | timeout-minutes: 30 |
modified
CLAUDE.md +8 -1 | @@ -77,7 +77,14 @@ Two rules the ABI has, both of which cost a segfault to rediscover: | ||
| 77 | 77 | Nim's allocator is not Dart's. |
| 78 | 78 | * `frq_init` runs once before anything else. |
| 79 | 79 | |
| 80 | -`just nim-test` needs no Flutter and no Dart, which is most of the point. | |
| 80 | +`dart/frq_core` is the other half of that seam, and is **plain Dart**. New | |
| 81 | +code on the Dart side of the boundary is written in Dart rather than | |
| 82 | +ClojureDart — the core exists to have less Clojure in the tree, and adding | |
| 83 | +more of it to call the thing replacing it is the wrong direction. ClojureDart | |
| 84 | +shrinks from both ends. | |
| 85 | + | |
| 86 | +`just nim-test` and `just dart-test` need no Flutter, which is most of the | |
| 87 | +point: the whole boundary is checkable in about a second. | |
| 81 | 88 | |
| 82 | 89 | A module is not deleted from `common/` when its Nim version lands: the web |
| 83 | 90 | target cannot load a native library, so the ClojureDart original is the web's |
| @@ -77,7 +77,14 @@ Two rules the ABI has, both of which cost a segfault to rediscover: | |||
| 77 | Nim's allocator is not Dart's. | 77 | Nim's allocator is not Dart's. |
| 78 | * `frq_init` runs once before anything else. | 78 | * `frq_init` runs once before anything else. |
| 79 | 79 | ||
| 80 | -`just nim-test` needs no Flutter and no Dart, which is most of the point. | 80 | +`dart/frq_core` is the other half of that seam, and is **plain Dart**. New |
| 81 | +code on the Dart side of the boundary is written in Dart rather than | ||
| 82 | +ClojureDart — the core exists to have less Clojure in the tree, and adding | ||
| 83 | +more of it to call the thing replacing it is the wrong direction. ClojureDart | ||
| 84 | +shrinks from both ends. | ||
| 85 | + | ||
| 86 | +`just nim-test` and `just dart-test` need no Flutter, which is most of the | ||
| 87 | +point: the whole boundary is checkable in about a second. | ||
| 81 | 88 | ||
| 82 | A module is not deleted from `common/` when its Nim version lands: the web | 89 | A module is not deleted from `common/` when its Nim version lands: the web |
| 83 | target cannot load a native library, so the ClojureDart original is the web's | 90 | target cannot load a native library, so the ClojureDart original is the web's |
modified
README.md +5 -4 | @@ -17,12 +17,13 @@ Source lives in three trees: | ||
| 17 | 17 | common/ .cljc portable: the screens, the state, the protocol — no dart: |
| 18 | 18 | flutter/ .cljd the Flutter half, and the host's answers — flutter/README.md |
| 19 | 19 | nim/ .nim the portable logic as a native library — nim/README.md |
| 20 | +dart/ .dart the binding to it, and not a Flutter package — dart/README.md | |
| 20 | 21 | ``` |
| 21 | 22 | |
| 22 | -The first two are the client as it runs today; `nim/` is where the logic under | |
| 23 | -the screens is moving, a module at a time, behind a C ABI the Dart side calls | |
| 24 | -through FFI. One module has made the trip so far. See `nim/README.md` for | |
| 25 | -what is wired up and what is not. | |
| 23 | +The first two are the client as it runs today. `nim/` is where the logic under | |
| 24 | +the screens is moving, a module at a time, behind a C ABI; `dart/` is what | |
| 25 | +calls it. One module has made the trip so far and nothing imports it yet — see | |
| 26 | +`nim/README.md` for what is wired up and what is not. | |
| 26 | 27 | |
| 27 | 28 | What `common/` needs of the host it asks `common/frq/io.cljc` for — the seam, |
| 28 | 29 | named once and answered per target: `frq.io.dart` on Android and the desktop, |
| @@ -17,12 +17,13 @@ Source lives in three trees: | |||
| 17 | common/ .cljc portable: the screens, the state, the protocol — no dart: | 17 | common/ .cljc portable: the screens, the state, the protocol — no dart: |
| 18 | flutter/ .cljd the Flutter half, and the host's answers — flutter/README.md | 18 | flutter/ .cljd the Flutter half, and the host's answers — flutter/README.md |
| 19 | nim/ .nim the portable logic as a native library — nim/README.md | 19 | nim/ .nim the portable logic as a native library — nim/README.md |
| 20 | +dart/ .dart the binding to it, and not a Flutter package — dart/README.md | ||
| 20 | ``` | 21 | ``` |
| 21 | 22 | ||
| 22 | -The first two are the client as it runs today; `nim/` is where the logic under | 23 | +The first two are the client as it runs today. `nim/` is where the logic under |
| 23 | -the screens is moving, a module at a time, behind a C ABI the Dart side calls | 24 | +the screens is moving, a module at a time, behind a C ABI; `dart/` is what |
| 24 | -through FFI. One module has made the trip so far. See `nim/README.md` for | 25 | +calls it. One module has made the trip so far and nothing imports it yet — see |
| 25 | -what is wired up and what is not. | 26 | +`nim/README.md` for what is wired up and what is not. |
| 26 | 27 | ||
| 27 | What `common/` needs of the host it asks `common/frq/io.cljc` for — the seam, | 28 | What `common/` needs of the host it asks `common/frq/io.cljc` for — the seam, |
| 28 | named once and answered per target: `frq.io.dart` on Android and the desktop, | 29 | named once and answered per target: `frq.io.dart` on Android and the desktop, |
added
dart/README.md +27 -0 | new file mode 100644 | ||
| @@ -0,0 +1,27 @@ | ||
| 1 | +# The Dart side | |
| 2 | + | |
| 3 | +Packages that are Dart rather than ClojureDart, and are not Flutter. | |
| 4 | + | |
| 5 | +## Why this is a separate tree | |
| 6 | + | |
| 7 | +`frq_core` is the binding to the Nim core in `nim/`. It is `dart:ffi` and | |
| 8 | +`dart:convert` and nothing else, and keeping it out of `flutter/` buys two | |
| 9 | +things: | |
| 10 | + | |
| 11 | +* **It tests on the plain Dart VM.** `flutter/pubspec.yaml` depends on the | |
| 12 | + Flutter SDK, so `dart pub get` cannot resolve it at all — anything living | |
| 13 | + there needs a Flutter toolchain to run one assertion about a string. This | |
| 14 | + package resolves and tests in a second. `just dart-test`. | |
| 15 | +* **It says which way the dependency goes.** The Flutter app depends on this | |
| 16 | + by path. Nothing here may depend on Flutter, and if that ever becomes | |
| 17 | + tempting the thing being written belongs on the other side of the line. | |
| 18 | + | |
| 19 | +## Why Dart and not ClojureDart | |
| 20 | + | |
| 21 | +The Nim core exists to have less Clojure in the tree. Writing its binding in | |
| 22 | +ClojureDart would have added some — and would have meant fighting generic | |
| 23 | +interop for a file that is pure marshalling, since `lookupFunction` takes two | |
| 24 | +type arguments. In Dart it is a typedef. | |
| 25 | + | |
| 26 | +So the shape the migration moves toward: Nim owns the rules, Dart owns the | |
| 27 | +platform, and ClojureDart shrinks from both ends. | |
| new file mode 100644 | |||
| @@ -0,0 +1,27 @@ | |||
| 1 | +# The Dart side | ||
| 2 | + | ||
| 3 | +Packages that are Dart rather than ClojureDart, and are not Flutter. | ||
| 4 | + | ||
| 5 | +## Why this is a separate tree | ||
| 6 | + | ||
| 7 | +`frq_core` is the binding to the Nim core in `nim/`. It is `dart:ffi` and | ||
| 8 | +`dart:convert` and nothing else, and keeping it out of `flutter/` buys two | ||
| 9 | +things: | ||
| 10 | + | ||
| 11 | +* **It tests on the plain Dart VM.** `flutter/pubspec.yaml` depends on the | ||
| 12 | + Flutter SDK, so `dart pub get` cannot resolve it at all — anything living | ||
| 13 | + there needs a Flutter toolchain to run one assertion about a string. This | ||
| 14 | + package resolves and tests in a second. `just dart-test`. | ||
| 15 | +* **It says which way the dependency goes.** The Flutter app depends on this | ||
| 16 | + by path. Nothing here may depend on Flutter, and if that ever becomes | ||
| 17 | + tempting the thing being written belongs on the other side of the line. | ||
| 18 | + | ||
| 19 | +## Why Dart and not ClojureDart | ||
| 20 | + | ||
| 21 | +The Nim core exists to have less Clojure in the tree. Writing its binding in | ||
| 22 | +ClojureDart would have added some — and would have meant fighting generic | ||
| 23 | +interop for a file that is pure marshalling, since `lookupFunction` takes two | ||
| 24 | +type arguments. In Dart it is a typedef. | ||
| 25 | + | ||
| 26 | +So the shape the migration moves toward: Nim owns the rules, Dart owns the | ||
| 27 | +platform, and ClojureDart shrinks from both ends. | ||
added
dart/frq_core/lib/frq_core.dart +184 -0 | new file mode 100644 | ||
| @@ -0,0 +1,184 @@ | ||
| 1 | +/// The Nim core, as Dart functions. | |
| 2 | +/// | |
| 3 | +/// This is the whole of what knows `libfrqcore.so` is a native library; see | |
| 4 | +/// `nim/README.md` for why the logic is there rather than under `common/`. | |
| 5 | +/// | |
| 6 | +/// **Dart and not ClojureDart, on purpose.** The point of the Nim core is to | |
| 7 | +/// have less Clojure, so new code on this side of the boundary is written in | |
| 8 | +/// the language the platform speaks. It also sidesteps a real problem: | |
| 9 | +/// `lookupFunction` takes two type arguments, and generic interop is the part | |
| 10 | +/// of ClojureDart least worth fighting for a file that is pure marshalling. | |
| 11 | +/// | |
| 12 | +/// No `package:ffi` either. That package exists mostly for `Utf8` | |
| 13 | +/// conversions, and doing them here against `dart:convert` costs about ten | |
| 14 | +/// lines and keeps `pubspec.yaml` unchanged — which matters because every | |
| 15 | +/// dependency added here has to work on three targets. | |
| 16 | +/// | |
| 17 | +/// The two rules of the ABI, wrapped so no call site repeats them: | |
| 18 | +/// | |
| 19 | +/// * `frq_init` runs once before anything else. [_lib] does it on the way | |
| 20 | +/// out, so holding the handle means it has happened. | |
| 21 | +/// * Every string the core returns is **ours to free**, with `frq_free`. | |
| 22 | +/// [_takeString] is that, in a `finally` so a throw between the read and | |
| 23 | +/// the free does not leak. Nim's allocator is not Dart's, so calling | |
| 24 | +/// `malloc.free` on one of these pointers is undefined rather than merely | |
| 25 | +/// untidy. | |
| 26 | +library; | |
| 27 | + | |
| 28 | +import 'dart:convert'; | |
| 29 | +import 'dart:ffi'; | |
| 30 | +import 'dart:io'; | |
| 31 | + | |
| 32 | +// ---------------------------------------------------------------- the ABI | |
| 33 | + | |
| 34 | +typedef _InitNative = Void Function(); | |
| 35 | +typedef _InitDart = void Function(); | |
| 36 | + | |
| 37 | +typedef _FreeNative = Void Function(Pointer<Uint8>); | |
| 38 | +typedef _FreeDart = void Function(Pointer<Uint8>); | |
| 39 | + | |
| 40 | +typedef _VersionNative = Pointer<Uint8> Function(); | |
| 41 | +typedef _VersionDart = Pointer<Uint8> Function(); | |
| 42 | + | |
| 43 | +typedef _Str1Native = Pointer<Uint8> Function(Pointer<Uint8>); | |
| 44 | +typedef _Str1Dart = Pointer<Uint8> Function(Pointer<Uint8>); | |
| 45 | + | |
| 46 | +typedef _Str2Native = Pointer<Uint8> Function(Pointer<Uint8>, Pointer<Uint8>); | |
| 47 | +typedef _Str2Dart = Pointer<Uint8> Function(Pointer<Uint8>, Pointer<Uint8>); | |
| 48 | + | |
| 49 | +/// Where to look for the library, in order. | |
| 50 | +/// | |
| 51 | +/// Android resolves a bare soname out of the APK's `lib/<abi>/`. A desktop | |
| 52 | +/// build has no such rule, so the bare name is tried first (it works when the | |
| 53 | +/// object sits beside the executable or on the loader path) and then the | |
| 54 | +/// development path `just nim-lib` writes to. Named explicitly rather than by | |
| 55 | +/// exporting `LD_LIBRARY_PATH` from a launcher, because a variable set in a | |
| 56 | +/// wrapper script is a thing that works until someone starts the binary | |
| 57 | +/// another way. | |
| 58 | +DynamicLibrary _open() { | |
| 59 | + if (Platform.isAndroid) return DynamicLibrary.open('libfrqcore.so'); | |
| 60 | + // The development paths are relative to whichever directory the process | |
| 61 | + // started in: `dart test` runs from `dart/frq_core`, a built desktop bundle | |
| 62 | + // from the repo root. All of them are tried rather than guessing which | |
| 63 | + // invocation this is, because the failure mode is a StateError at first use | |
| 64 | + // rather than anything a type checker would have caught. | |
| 65 | + for (final p in [ | |
| 66 | + 'libfrqcore.so', | |
| 67 | + 'build/nim/libfrqcore.so', | |
| 68 | + '../../build/nim/libfrqcore.so', | |
| 69 | + ]) { | |
| 70 | + try { | |
| 71 | + return DynamicLibrary.open(p); | |
| 72 | + } on ArgumentError { | |
| 73 | + continue; | |
| 74 | + } | |
| 75 | + } | |
| 76 | + throw StateError( | |
| 77 | + 'libfrqcore.so not found — build it with `just nim-lib`, or ship it ' | |
| 78 | + 'beside the executable'); | |
| 79 | +} | |
| 80 | + | |
| 81 | +final DynamicLibrary _lib = () { | |
| 82 | + final lib = _open(); | |
| 83 | + lib.lookupFunction<_InitNative, _InitDart>('frq_init')(); | |
| 84 | + return lib; | |
| 85 | +}(); | |
| 86 | + | |
| 87 | +final _free = _lib.lookupFunction<_FreeNative, _FreeDart>('frq_free'); | |
| 88 | + | |
| 89 | +/// The bytes at [p] as a string, with [p] freed afterwards. Null in, null out. | |
| 90 | +String? _takeString(Pointer<Uint8> p) { | |
| 91 | + if (p == nullptr) return null; | |
| 92 | + try { | |
| 93 | + // Walk to the NUL rather than asking for a length the ABI does not carry. | |
| 94 | + var len = 0; | |
| 95 | + while (p[len] != 0) { | |
| 96 | + len++; | |
| 97 | + } | |
| 98 | + return utf8.decode(p.asTypedList(len)); | |
| 99 | + } finally { | |
| 100 | + _free(p); | |
| 101 | + } | |
| 102 | +} | |
| 103 | + | |
| 104 | +/// [s] as a NUL-terminated C string that the CALLER must free with [_freeArg]. | |
| 105 | +/// | |
| 106 | +/// Allocated with `malloc` from Dart's side, so it is freed from Dart's side — | |
| 107 | +/// the mirror of the rule for what comes back. The core never takes ownership | |
| 108 | +/// of an argument. | |
| 109 | +Pointer<Uint8> _toC(String s) { | |
| 110 | + final bytes = utf8.encode(s); | |
| 111 | + final p = _malloc(bytes.length + 1).cast<Uint8>(); | |
| 112 | + for (var i = 0; i < bytes.length; i++) { | |
| 113 | + p[i] = bytes[i]; | |
| 114 | + } | |
| 115 | + p[bytes.length] = 0; | |
| 116 | + return p; | |
| 117 | +} | |
| 118 | + | |
| 119 | +// malloc/free out of libc rather than package:ffi's allocator, for the same | |
| 120 | +// reason the rest of this file avoids that package: one less dependency to | |
| 121 | +// carry to three targets, for two symbols that are always there. | |
| 122 | +final DynamicLibrary _libc = | |
| 123 | + Platform.isWindows ? DynamicLibrary.open('msvcrt.dll') : DynamicLibrary.process(); | |
| 124 | +final _malloc = _libc | |
| 125 | + .lookupFunction<Pointer<Void> Function(IntPtr), Pointer<Void> Function(int)>('malloc'); | |
| 126 | +final _freeArg = | |
| 127 | + _libc.lookupFunction<Void Function(Pointer<Uint8>), void Function(Pointer<Uint8>)>('free'); | |
| 128 | + | |
| 129 | +/// Call a one-string-in, one-string-out entry point. | |
| 130 | +String? _call1(String symbol, String arg) { | |
| 131 | + final f = _lib.lookupFunction<_Str1Native, _Str1Dart>(symbol); | |
| 132 | + final a = _toC(arg); | |
| 133 | + try { | |
| 134 | + return _takeString(f(a)); | |
| 135 | + } finally { | |
| 136 | + _freeArg(a); | |
| 137 | + } | |
| 138 | +} | |
| 139 | + | |
| 140 | +// ------------------------------------------------------------------ public | |
| 141 | + | |
| 142 | +/// The core's version, for a caller that wants to check the library it found | |
| 143 | +/// is the one it was built against. Static storage on the Nim side: the one | |
| 144 | +/// return value that is NOT freed. | |
| 145 | +String get version { | |
| 146 | + final p = _lib.lookupFunction<_VersionNative, _VersionDart>('frq_version')(); | |
| 147 | + var len = 0; | |
| 148 | + while (p[len] != 0) { | |
| 149 | + len++; | |
| 150 | + } | |
| 151 | + return utf8.decode(p.asTypedList(len)); | |
| 152 | +} | |
| 153 | + | |
| 154 | +/// An IRC line, taken apart: `{raw, tags, account, prefix, command, params}`. | |
| 155 | +/// | |
| 156 | +/// `tags`, `account` and `prefix` are null where the line carried none, which | |
| 157 | +/// is the distinction `frq.irc.parse` draws with nil and every caller depends | |
| 158 | +/// on — a PRIVMSG from a server with no prefix is not the same line as one | |
| 159 | +/// from a nick. | |
| 160 | +Map<String, dynamic> parseLine(String line) { | |
| 161 | + final json = _call1('frq_irc_parse_line', line); | |
| 162 | + return jsonDecode(json ?? 'null') as Map<String, dynamic>; | |
| 163 | +} | |
| 164 | + | |
| 165 | +/// One IRCv3 tag's value, unescaped — null where the tag is absent OR empty, | |
| 166 | +/// which IRCv3 says are the same thing. | |
| 167 | +String? tagValue(String tags, String key) { | |
| 168 | + final f = _lib.lookupFunction<_Str2Native, _Str2Dart>('frq_irc_tag_value'); | |
| 169 | + final a = _toC(tags); | |
| 170 | + final b = _toC(key); | |
| 171 | + try { | |
| 172 | + return _takeString(f(a, b)); | |
| 173 | + } finally { | |
| 174 | + _freeArg(a); | |
| 175 | + _freeArg(b); | |
| 176 | + } | |
| 177 | +} | |
| 178 | + | |
| 179 | +String unescapeTag(String v) => _call1('frq_irc_unescape_tag', v) ?? ''; | |
| 180 | + | |
| 181 | +String escapeTagValue(String v) => _call1('frq_irc_escape_tag_value', v) ?? ''; | |
| 182 | + | |
| 183 | +/// The nick half of a `nick!user@host` prefix. | |
| 184 | +String nickOf(String prefix) => _call1('frq_irc_nick_of', prefix) ?? ''; | |
| new file mode 100644 | |||
| @@ -0,0 +1,184 @@ | |||
| 1 | +/// The Nim core, as Dart functions. | ||
| 2 | +/// | ||
| 3 | +/// This is the whole of what knows `libfrqcore.so` is a native library; see | ||
| 4 | +/// `nim/README.md` for why the logic is there rather than under `common/`. | ||
| 5 | +/// | ||
| 6 | +/// **Dart and not ClojureDart, on purpose.** The point of the Nim core is to | ||
| 7 | +/// have less Clojure, so new code on this side of the boundary is written in | ||
| 8 | +/// the language the platform speaks. It also sidesteps a real problem: | ||
| 9 | +/// `lookupFunction` takes two type arguments, and generic interop is the part | ||
| 10 | +/// of ClojureDart least worth fighting for a file that is pure marshalling. | ||
| 11 | +/// | ||
| 12 | +/// No `package:ffi` either. That package exists mostly for `Utf8` | ||
| 13 | +/// conversions, and doing them here against `dart:convert` costs about ten | ||
| 14 | +/// lines and keeps `pubspec.yaml` unchanged — which matters because every | ||
| 15 | +/// dependency added here has to work on three targets. | ||
| 16 | +/// | ||
| 17 | +/// The two rules of the ABI, wrapped so no call site repeats them: | ||
| 18 | +/// | ||
| 19 | +/// * `frq_init` runs once before anything else. [_lib] does it on the way | ||
| 20 | +/// out, so holding the handle means it has happened. | ||
| 21 | +/// * Every string the core returns is **ours to free**, with `frq_free`. | ||
| 22 | +/// [_takeString] is that, in a `finally` so a throw between the read and | ||
| 23 | +/// the free does not leak. Nim's allocator is not Dart's, so calling | ||
| 24 | +/// `malloc.free` on one of these pointers is undefined rather than merely | ||
| 25 | +/// untidy. | ||
| 26 | +library; | ||
| 27 | + | ||
| 28 | +import 'dart:convert'; | ||
| 29 | +import 'dart:ffi'; | ||
| 30 | +import 'dart:io'; | ||
| 31 | + | ||
| 32 | +// ---------------------------------------------------------------- the ABI | ||
| 33 | + | ||
| 34 | +typedef _InitNative = Void Function(); | ||
| 35 | +typedef _InitDart = void Function(); | ||
| 36 | + | ||
| 37 | +typedef _FreeNative = Void Function(Pointer<Uint8>); | ||
| 38 | +typedef _FreeDart = void Function(Pointer<Uint8>); | ||
| 39 | + | ||
| 40 | +typedef _VersionNative = Pointer<Uint8> Function(); | ||
| 41 | +typedef _VersionDart = Pointer<Uint8> Function(); | ||
| 42 | + | ||
| 43 | +typedef _Str1Native = Pointer<Uint8> Function(Pointer<Uint8>); | ||
| 44 | +typedef _Str1Dart = Pointer<Uint8> Function(Pointer<Uint8>); | ||
| 45 | + | ||
| 46 | +typedef _Str2Native = Pointer<Uint8> Function(Pointer<Uint8>, Pointer<Uint8>); | ||
| 47 | +typedef _Str2Dart = Pointer<Uint8> Function(Pointer<Uint8>, Pointer<Uint8>); | ||
| 48 | + | ||
| 49 | +/// Where to look for the library, in order. | ||
| 50 | +/// | ||
| 51 | +/// Android resolves a bare soname out of the APK's `lib/<abi>/`. A desktop | ||
| 52 | +/// build has no such rule, so the bare name is tried first (it works when the | ||
| 53 | +/// object sits beside the executable or on the loader path) and then the | ||
| 54 | +/// development path `just nim-lib` writes to. Named explicitly rather than by | ||
| 55 | +/// exporting `LD_LIBRARY_PATH` from a launcher, because a variable set in a | ||
| 56 | +/// wrapper script is a thing that works until someone starts the binary | ||
| 57 | +/// another way. | ||
| 58 | +DynamicLibrary _open() { | ||
| 59 | + if (Platform.isAndroid) return DynamicLibrary.open('libfrqcore.so'); | ||
| 60 | + // The development paths are relative to whichever directory the process | ||
| 61 | + // started in: `dart test` runs from `dart/frq_core`, a built desktop bundle | ||
| 62 | + // from the repo root. All of them are tried rather than guessing which | ||
| 63 | + // invocation this is, because the failure mode is a StateError at first use | ||
| 64 | + // rather than anything a type checker would have caught. | ||
| 65 | + for (final p in [ | ||
| 66 | + 'libfrqcore.so', | ||
| 67 | + 'build/nim/libfrqcore.so', | ||
| 68 | + '../../build/nim/libfrqcore.so', | ||
| 69 | + ]) { | ||
| 70 | + try { | ||
| 71 | + return DynamicLibrary.open(p); | ||
| 72 | + } on ArgumentError { | ||
| 73 | + continue; | ||
| 74 | + } | ||
| 75 | + } | ||
| 76 | + throw StateError( | ||
| 77 | + 'libfrqcore.so not found — build it with `just nim-lib`, or ship it ' | ||
| 78 | + 'beside the executable'); | ||
| 79 | +} | ||
| 80 | + | ||
| 81 | +final DynamicLibrary _lib = () { | ||
| 82 | + final lib = _open(); | ||
| 83 | + lib.lookupFunction<_InitNative, _InitDart>('frq_init')(); | ||
| 84 | + return lib; | ||
| 85 | +}(); | ||
| 86 | + | ||
| 87 | +final _free = _lib.lookupFunction<_FreeNative, _FreeDart>('frq_free'); | ||
| 88 | + | ||
| 89 | +/// The bytes at [p] as a string, with [p] freed afterwards. Null in, null out. | ||
| 90 | +String? _takeString(Pointer<Uint8> p) { | ||
| 91 | + if (p == nullptr) return null; | ||
| 92 | + try { | ||
| 93 | + // Walk to the NUL rather than asking for a length the ABI does not carry. | ||
| 94 | + var len = 0; | ||
| 95 | + while (p[len] != 0) { | ||
| 96 | + len++; | ||
| 97 | + } | ||
| 98 | + return utf8.decode(p.asTypedList(len)); | ||
| 99 | + } finally { | ||
| 100 | + _free(p); | ||
| 101 | + } | ||
| 102 | +} | ||
| 103 | + | ||
| 104 | +/// [s] as a NUL-terminated C string that the CALLER must free with [_freeArg]. | ||
| 105 | +/// | ||
| 106 | +/// Allocated with `malloc` from Dart's side, so it is freed from Dart's side — | ||
| 107 | +/// the mirror of the rule for what comes back. The core never takes ownership | ||
| 108 | +/// of an argument. | ||
| 109 | +Pointer<Uint8> _toC(String s) { | ||
| 110 | + final bytes = utf8.encode(s); | ||
| 111 | + final p = _malloc(bytes.length + 1).cast<Uint8>(); | ||
| 112 | + for (var i = 0; i < bytes.length; i++) { | ||
| 113 | + p[i] = bytes[i]; | ||
| 114 | + } | ||
| 115 | + p[bytes.length] = 0; | ||
| 116 | + return p; | ||
| 117 | +} | ||
| 118 | + | ||
| 119 | +// malloc/free out of libc rather than package:ffi's allocator, for the same | ||
| 120 | +// reason the rest of this file avoids that package: one less dependency to | ||
| 121 | +// carry to three targets, for two symbols that are always there. | ||
| 122 | +final DynamicLibrary _libc = | ||
| 123 | + Platform.isWindows ? DynamicLibrary.open('msvcrt.dll') : DynamicLibrary.process(); | ||
| 124 | +final _malloc = _libc | ||
| 125 | + .lookupFunction<Pointer<Void> Function(IntPtr), Pointer<Void> Function(int)>('malloc'); | ||
| 126 | +final _freeArg = | ||
| 127 | + _libc.lookupFunction<Void Function(Pointer<Uint8>), void Function(Pointer<Uint8>)>('free'); | ||
| 128 | + | ||
| 129 | +/// Call a one-string-in, one-string-out entry point. | ||
| 130 | +String? _call1(String symbol, String arg) { | ||
| 131 | + final f = _lib.lookupFunction<_Str1Native, _Str1Dart>(symbol); | ||
| 132 | + final a = _toC(arg); | ||
| 133 | + try { | ||
| 134 | + return _takeString(f(a)); | ||
| 135 | + } finally { | ||
| 136 | + _freeArg(a); | ||
| 137 | + } | ||
| 138 | +} | ||
| 139 | + | ||
| 140 | +// ------------------------------------------------------------------ public | ||
| 141 | + | ||
| 142 | +/// The core's version, for a caller that wants to check the library it found | ||
| 143 | +/// is the one it was built against. Static storage on the Nim side: the one | ||
| 144 | +/// return value that is NOT freed. | ||
| 145 | +String get version { | ||
| 146 | + final p = _lib.lookupFunction<_VersionNative, _VersionDart>('frq_version')(); | ||
| 147 | + var len = 0; | ||
| 148 | + while (p[len] != 0) { | ||
| 149 | + len++; | ||
| 150 | + } | ||
| 151 | + return utf8.decode(p.asTypedList(len)); | ||
| 152 | +} | ||
| 153 | + | ||
| 154 | +/// An IRC line, taken apart: `{raw, tags, account, prefix, command, params}`. | ||
| 155 | +/// | ||
| 156 | +/// `tags`, `account` and `prefix` are null where the line carried none, which | ||
| 157 | +/// is the distinction `frq.irc.parse` draws with nil and every caller depends | ||
| 158 | +/// on — a PRIVMSG from a server with no prefix is not the same line as one | ||
| 159 | +/// from a nick. | ||
| 160 | +Map<String, dynamic> parseLine(String line) { | ||
| 161 | + final json = _call1('frq_irc_parse_line', line); | ||
| 162 | + return jsonDecode(json ?? 'null') as Map<String, dynamic>; | ||
| 163 | +} | ||
| 164 | + | ||
| 165 | +/// One IRCv3 tag's value, unescaped — null where the tag is absent OR empty, | ||
| 166 | +/// which IRCv3 says are the same thing. | ||
| 167 | +String? tagValue(String tags, String key) { | ||
| 168 | + final f = _lib.lookupFunction<_Str2Native, _Str2Dart>('frq_irc_tag_value'); | ||
| 169 | + final a = _toC(tags); | ||
| 170 | + final b = _toC(key); | ||
| 171 | + try { | ||
| 172 | + return _takeString(f(a, b)); | ||
| 173 | + } finally { | ||
| 174 | + _freeArg(a); | ||
| 175 | + _freeArg(b); | ||
| 176 | + } | ||
| 177 | +} | ||
| 178 | + | ||
| 179 | +String unescapeTag(String v) => _call1('frq_irc_unescape_tag', v) ?? ''; | ||
| 180 | + | ||
| 181 | +String escapeTagValue(String v) => _call1('frq_irc_escape_tag_value', v) ?? ''; | ||
| 182 | + | ||
| 183 | +/// The nick half of a `nick!user@host` prefix. | ||
| 184 | +String nickOf(String prefix) => _call1('frq_irc_nick_of', prefix) ?? ''; | ||
added
dart/frq_core/pubspec.yaml +15 -0 | new file mode 100644 | ||
| @@ -0,0 +1,15 @@ | ||
| 1 | +name: frq_core | |
| 2 | +description: The Dart side of frq's Nim core — dart:ffi and nothing else. | |
| 3 | +publish_to: none | |
| 4 | +version: 0.1.0 | |
| 5 | + | |
| 6 | +# Deliberately NOT a Flutter package. The binding is dart:ffi and dart:convert, | |
| 7 | +# so it resolves and tests on the plain Dart VM — which is what lets `just | |
| 8 | +# dart-test` run in a second instead of behind a Flutter toolchain. The | |
| 9 | +# Flutter app depends on this by path; the dependency does not run the other | |
| 10 | +# way, and should not. | |
| 11 | +environment: | |
| 12 | + sdk: ">=3.0.0 <4.0.0" | |
| 13 | + | |
| 14 | +dev_dependencies: | |
| 15 | + test: ^1.25.0 | |
| new file mode 100644 | |||
| @@ -0,0 +1,15 @@ | |||
| 1 | +name: frq_core | ||
| 2 | +description: The Dart side of frq's Nim core — dart:ffi and nothing else. | ||
| 3 | +publish_to: none | ||
| 4 | +version: 0.1.0 | ||
| 5 | + | ||
| 6 | +# Deliberately NOT a Flutter package. The binding is dart:ffi and dart:convert, | ||
| 7 | +# so it resolves and tests on the plain Dart VM — which is what lets `just | ||
| 8 | +# dart-test` run in a second instead of behind a Flutter toolchain. The | ||
| 9 | +# Flutter app depends on this by path; the dependency does not run the other | ||
| 10 | +# way, and should not. | ||
| 11 | +environment: | ||
| 12 | + sdk: ">=3.0.0 <4.0.0" | ||
| 13 | + | ||
| 14 | +dev_dependencies: | ||
| 15 | + test: ^1.25.0 | ||
added
dart/frq_core/test/frq_core_test.dart +130 -0 | new file mode 100644 | ||
| @@ -0,0 +1,130 @@ | ||
| 1 | +/// The Dart side of the Nim boundary, exercised against the real library. | |
| 2 | +/// | |
| 3 | +/// Runs on the plain Dart VM — no Flutter, no emulator, no ClojureDart. That | |
| 4 | +/// is the point: the binding is the risky half of the FFI seam, and it can be | |
| 5 | +/// proven in a second rather than behind a toolchain that takes minutes. | |
| 6 | +/// | |
| 7 | +/// just dart-test | |
| 8 | +/// | |
| 9 | +/// The cases mirror `nim/tests/tircparse.nim` deliberately. Passing there and | |
| 10 | +/// failing here is a marshalling bug, which is exactly the class of fault | |
| 11 | +/// this file exists to catch. | |
| 12 | +import 'dart:io'; | |
| 13 | +import 'package:test/test.dart'; | |
| 14 | +import 'package:frq_core/frq_core.dart' as core; | |
| 15 | + | |
| 16 | +void main() { | |
| 17 | + setUpAll(() { | |
| 18 | + if (!File('../../build/nim/libfrqcore.so').existsSync()) { | |
| 19 | + throw StateError('build the core first: just nim-lib'); | |
| 20 | + } | |
| 21 | + }); | |
| 22 | + | |
| 23 | + test('the library loads and reports its version', () { | |
| 24 | + expect(core.version, '0.1.0'); | |
| 25 | + }); | |
| 26 | + | |
| 27 | + group('parseLine', () { | |
| 28 | + test('a bare line', () { | |
| 29 | + final p = core.parseLine('PING :12345'); | |
| 30 | + expect(p['command'], 'PING'); | |
| 31 | + expect(p['params'], ['12345']); | |
| 32 | + expect(p['prefix'], isNull); | |
| 33 | + expect(p['tags'], isNull); | |
| 34 | + }); | |
| 35 | + | |
| 36 | + test('a prefix is split off and the command upcased', () { | |
| 37 | + final p = core.parseLine(':nick!user@host privmsg #chan :hello there'); | |
| 38 | + expect(p['prefix'], 'nick!user@host'); | |
| 39 | + expect(p['command'], 'PRIVMSG'); | |
| 40 | + expect(p['params'], ['#chan', 'hello there']); | |
| 41 | + }); | |
| 42 | + | |
| 43 | + test('the trailing parameter keeps its spaces and colons', () { | |
| 44 | + final p = core.parseLine(':a!b@c PRIVMSG #chan :look: a b c'); | |
| 45 | + expect(p['params'], ['#chan', 'look: a b c']); | |
| 46 | + }); | |
| 47 | + | |
| 48 | + test('an empty trailing parameter is still a parameter', () { | |
| 49 | + expect(core.parseLine(':a!b@c TOPIC #chan :')['params'], ['#chan', '']); | |
| 50 | + }); | |
| 51 | + | |
| 52 | + test('tags and the account tag', () { | |
| 53 | + final p = core.parseLine('@time=x;account=alice :a!b@c PRIVMSG #c :hi'); | |
| 54 | + expect(p['tags'], 'time=x;account=alice'); | |
| 55 | + expect(p['account'], 'alice'); | |
| 56 | + expect(p['params'], ['#c', 'hi']); | |
| 57 | + }); | |
| 58 | + | |
| 59 | + test('no account tag reads as null, not empty', () { | |
| 60 | + expect(core.parseLine('@time=x :a!b@c PRIVMSG #c :hi')['account'], isNull); | |
| 61 | + }); | |
| 62 | + | |
| 63 | + test('raw is what arrived', () { | |
| 64 | + expect(core.parseLine('@a=1 :n!u@h PRIVMSG #c :x ')['raw'], | |
| 65 | + '@a=1 :n!u@h PRIVMSG #c :x'); | |
| 66 | + }); | |
| 67 | + | |
| 68 | + test('a line that is only tags does not throw', () { | |
| 69 | + final p = core.parseLine('@only=tags'); | |
| 70 | + expect(p['tags'], 'only=tags'); | |
| 71 | + expect(p['command'], ''); | |
| 72 | + }); | |
| 73 | + | |
| 74 | + test('an empty line', () { | |
| 75 | + final p = core.parseLine(''); | |
| 76 | + expect(p['command'], ''); | |
| 77 | + expect(p['params'], isEmpty); | |
| 78 | + }); | |
| 79 | + | |
| 80 | + test('non-ASCII survives the UTF-8 round trip', () { | |
| 81 | + // The whole reason the marshalling is worth testing separately: a | |
| 82 | + // handle with an emoji in it is more bytes than characters, and a | |
| 83 | + // length taken in the wrong unit truncates mid-codepoint. | |
| 84 | + final p = core.parseLine(':né!u@h PRIVMSG #c :héllo 😀 wörld'); | |
| 85 | + expect(p['prefix'], 'né!u@h'); | |
| 86 | + expect(p['params'], ['#c', 'héllo 😀 wörld']); | |
| 87 | + }); | |
| 88 | + }); | |
| 89 | + | |
| 90 | + group('tagValue', () { | |
| 91 | + test('a present value', () => expect(core.tagValue('a=1;b=2', 'b'), '2')); | |
| 92 | + test('an absent tag', () => expect(core.tagValue('a=1', 'b'), isNull)); | |
| 93 | + | |
| 94 | + test('an empty value and a bare key are both null', () { | |
| 95 | + // IRCv3 says `key` and `key=` mean the same thing. `+reply=` on a line | |
| 96 | + // answering nothing used to put a reply chip above it. | |
| 97 | + expect(core.tagValue('a=;b=2', 'a'), isNull); | |
| 98 | + expect(core.tagValue('a;b=2', 'a'), isNull); | |
| 99 | + }); | |
| 100 | + | |
| 101 | + test('the value is unescaped', () => expect(core.tagValue('t=a\\sb', 't'), 'a b')); | |
| 102 | + | |
| 103 | + test('a key that is a prefix of another does not match it', () { | |
| 104 | + expect(core.tagValue('account-x=1;account=2', 'account'), '2'); | |
| 105 | + }); | |
| 106 | + }); | |
| 107 | + | |
| 108 | + group('escapes', () { | |
| 109 | + test('round-trip', () { | |
| 110 | + for (final s in ['plain', 'a;b', 'a b', 'a\\b', 'a\r\nb', '', '😀']) { | |
| 111 | + expect(core.unescapeTag(core.escapeTagValue(s)), s, reason: s); | |
| 112 | + } | |
| 113 | + }); | |
| 114 | + }); | |
| 115 | + | |
| 116 | + group('nickOf', () { | |
| 117 | + test('a full prefix', () => expect(core.nickOf('nick!user@host'), 'nick')); | |
| 118 | + test('a server prefix', () => expect(core.nickOf('irc.freeq.at'), 'irc.freeq.at')); | |
| 119 | + }); | |
| 120 | + | |
| 121 | + test('ten thousand calls do not leak or crash the allocator', () { | |
| 122 | + // The contract this is really testing is ownership: what the core returns | |
| 123 | + // is freed with frq_free, what we pass in is freed with libc free, and | |
| 124 | + // getting either backwards corrupts a heap rather than failing a check. | |
| 125 | + for (var i = 0; i < 10000; i++) { | |
| 126 | + core.parseLine('@a=1 :n!u@h PRIVMSG #c :x'); | |
| 127 | + core.tagValue('a=1;b=2', 'b'); | |
| 128 | + } | |
| 129 | + }); | |
| 130 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,130 @@ | |||
| 1 | +/// The Dart side of the Nim boundary, exercised against the real library. | ||
| 2 | +/// | ||
| 3 | +/// Runs on the plain Dart VM — no Flutter, no emulator, no ClojureDart. That | ||
| 4 | +/// is the point: the binding is the risky half of the FFI seam, and it can be | ||
| 5 | +/// proven in a second rather than behind a toolchain that takes minutes. | ||
| 6 | +/// | ||
| 7 | +/// just dart-test | ||
| 8 | +/// | ||
| 9 | +/// The cases mirror `nim/tests/tircparse.nim` deliberately. Passing there and | ||
| 10 | +/// failing here is a marshalling bug, which is exactly the class of fault | ||
| 11 | +/// this file exists to catch. | ||
| 12 | +import 'dart:io'; | ||
| 13 | +import 'package:test/test.dart'; | ||
| 14 | +import 'package:frq_core/frq_core.dart' as core; | ||
| 15 | + | ||
| 16 | +void main() { | ||
| 17 | + setUpAll(() { | ||
| 18 | + if (!File('../../build/nim/libfrqcore.so').existsSync()) { | ||
| 19 | + throw StateError('build the core first: just nim-lib'); | ||
| 20 | + } | ||
| 21 | + }); | ||
| 22 | + | ||
| 23 | + test('the library loads and reports its version', () { | ||
| 24 | + expect(core.version, '0.1.0'); | ||
| 25 | + }); | ||
| 26 | + | ||
| 27 | + group('parseLine', () { | ||
| 28 | + test('a bare line', () { | ||
| 29 | + final p = core.parseLine('PING :12345'); | ||
| 30 | + expect(p['command'], 'PING'); | ||
| 31 | + expect(p['params'], ['12345']); | ||
| 32 | + expect(p['prefix'], isNull); | ||
| 33 | + expect(p['tags'], isNull); | ||
| 34 | + }); | ||
| 35 | + | ||
| 36 | + test('a prefix is split off and the command upcased', () { | ||
| 37 | + final p = core.parseLine(':nick!user@host privmsg #chan :hello there'); | ||
| 38 | + expect(p['prefix'], 'nick!user@host'); | ||
| 39 | + expect(p['command'], 'PRIVMSG'); | ||
| 40 | + expect(p['params'], ['#chan', 'hello there']); | ||
| 41 | + }); | ||
| 42 | + | ||
| 43 | + test('the trailing parameter keeps its spaces and colons', () { | ||
| 44 | + final p = core.parseLine(':a!b@c PRIVMSG #chan :look: a b c'); | ||
| 45 | + expect(p['params'], ['#chan', 'look: a b c']); | ||
| 46 | + }); | ||
| 47 | + | ||
| 48 | + test('an empty trailing parameter is still a parameter', () { | ||
| 49 | + expect(core.parseLine(':a!b@c TOPIC #chan :')['params'], ['#chan', '']); | ||
| 50 | + }); | ||
| 51 | + | ||
| 52 | + test('tags and the account tag', () { | ||
| 53 | + final p = core.parseLine('@time=x;account=alice :a!b@c PRIVMSG #c :hi'); | ||
| 54 | + expect(p['tags'], 'time=x;account=alice'); | ||
| 55 | + expect(p['account'], 'alice'); | ||
| 56 | + expect(p['params'], ['#c', 'hi']); | ||
| 57 | + }); | ||
| 58 | + | ||
| 59 | + test('no account tag reads as null, not empty', () { | ||
| 60 | + expect(core.parseLine('@time=x :a!b@c PRIVMSG #c :hi')['account'], isNull); | ||
| 61 | + }); | ||
| 62 | + | ||
| 63 | + test('raw is what arrived', () { | ||
| 64 | + expect(core.parseLine('@a=1 :n!u@h PRIVMSG #c :x ')['raw'], | ||
| 65 | + '@a=1 :n!u@h PRIVMSG #c :x'); | ||
| 66 | + }); | ||
| 67 | + | ||
| 68 | + test('a line that is only tags does not throw', () { | ||
| 69 | + final p = core.parseLine('@only=tags'); | ||
| 70 | + expect(p['tags'], 'only=tags'); | ||
| 71 | + expect(p['command'], ''); | ||
| 72 | + }); | ||
| 73 | + | ||
| 74 | + test('an empty line', () { | ||
| 75 | + final p = core.parseLine(''); | ||
| 76 | + expect(p['command'], ''); | ||
| 77 | + expect(p['params'], isEmpty); | ||
| 78 | + }); | ||
| 79 | + | ||
| 80 | + test('non-ASCII survives the UTF-8 round trip', () { | ||
| 81 | + // The whole reason the marshalling is worth testing separately: a | ||
| 82 | + // handle with an emoji in it is more bytes than characters, and a | ||
| 83 | + // length taken in the wrong unit truncates mid-codepoint. | ||
| 84 | + final p = core.parseLine(':né!u@h PRIVMSG #c :héllo 😀 wörld'); | ||
| 85 | + expect(p['prefix'], 'né!u@h'); | ||
| 86 | + expect(p['params'], ['#c', 'héllo 😀 wörld']); | ||
| 87 | + }); | ||
| 88 | + }); | ||
| 89 | + | ||
| 90 | + group('tagValue', () { | ||
| 91 | + test('a present value', () => expect(core.tagValue('a=1;b=2', 'b'), '2')); | ||
| 92 | + test('an absent tag', () => expect(core.tagValue('a=1', 'b'), isNull)); | ||
| 93 | + | ||
| 94 | + test('an empty value and a bare key are both null', () { | ||
| 95 | + // IRCv3 says `key` and `key=` mean the same thing. `+reply=` on a line | ||
| 96 | + // answering nothing used to put a reply chip above it. | ||
| 97 | + expect(core.tagValue('a=;b=2', 'a'), isNull); | ||
| 98 | + expect(core.tagValue('a;b=2', 'a'), isNull); | ||
| 99 | + }); | ||
| 100 | + | ||
| 101 | + test('the value is unescaped', () => expect(core.tagValue('t=a\\sb', 't'), 'a b')); | ||
| 102 | + | ||
| 103 | + test('a key that is a prefix of another does not match it', () { | ||
| 104 | + expect(core.tagValue('account-x=1;account=2', 'account'), '2'); | ||
| 105 | + }); | ||
| 106 | + }); | ||
| 107 | + | ||
| 108 | + group('escapes', () { | ||
| 109 | + test('round-trip', () { | ||
| 110 | + for (final s in ['plain', 'a;b', 'a b', 'a\\b', 'a\r\nb', '', '😀']) { | ||
| 111 | + expect(core.unescapeTag(core.escapeTagValue(s)), s, reason: s); | ||
| 112 | + } | ||
| 113 | + }); | ||
| 114 | + }); | ||
| 115 | + | ||
| 116 | + group('nickOf', () { | ||
| 117 | + test('a full prefix', () => expect(core.nickOf('nick!user@host'), 'nick')); | ||
| 118 | + test('a server prefix', () => expect(core.nickOf('irc.freeq.at'), 'irc.freeq.at')); | ||
| 119 | + }); | ||
| 120 | + | ||
| 121 | + test('ten thousand calls do not leak or crash the allocator', () { | ||
| 122 | + // The contract this is really testing is ownership: what the core returns | ||
| 123 | + // is freed with frq_free, what we pass in is freed with libc free, and | ||
| 124 | + // getting either backwards corrupts a heap rather than failing a check. | ||
| 125 | + for (var i = 0; i < 10000; i++) { | ||
| 126 | + core.parseLine('@a=1 :n!u@h PRIVMSG #c :x'); | ||
| 127 | + core.tagValue('a=1;b=2', 'b'); | ||
| 128 | + } | ||
| 129 | + }); | ||
| 130 | +} | ||
modified
flake.nix +13 -0 | @@ -459,6 +459,19 @@ | ||
| 459 | 459 | inherit (pkgs) lib; |
| 460 | 460 | in |
| 461 | 461 | { |
| 462 | + # Dart on its own, for the tests that need no Flutter: the FFI | |
| 463 | + # binding to the Nim core runs on the plain VM, and making it wait | |
| 464 | + # for a Flutter toolchain would throw away the reason it is fast. | |
| 465 | + # | |
| 466 | + # Flutter bundles a Dart, so this is a duplicate in one sense. It is | |
| 467 | + # also thirty times smaller, and the point of the boundary is that | |
| 468 | + # you can check it without the thing on the other side of it. | |
| 469 | + dart = pkgs.mkShellNoCC { | |
| 470 | + name = "frq-dart"; | |
| 471 | + packages = [ pkgs.dart pkgs.just ]; | |
| 472 | + FRQ_DART = "1"; | |
| 473 | + }; | |
| 474 | + | |
| 462 | 475 | # Nim, for `nim/` — the portable core as a native library. Just the |
| 463 | 476 | # compiler: the core has no dependencies outside Nim's own standard |
| 464 | 477 | # library, deliberately, because a dependency here is one that has |
| @@ -459,6 +459,19 @@ | |||
| 459 | inherit (pkgs) lib; | 459 | inherit (pkgs) lib; |
| 460 | in | 460 | in |
| 461 | { | 461 | { |
| 462 | + # Dart on its own, for the tests that need no Flutter: the FFI | ||
| 463 | + # binding to the Nim core runs on the plain VM, and making it wait | ||
| 464 | + # for a Flutter toolchain would throw away the reason it is fast. | ||
| 465 | + # | ||
| 466 | + # Flutter bundles a Dart, so this is a duplicate in one sense. It is | ||
| 467 | + # also thirty times smaller, and the point of the boundary is that | ||
| 468 | + # you can check it without the thing on the other side of it. | ||
| 469 | + dart = pkgs.mkShellNoCC { | ||
| 470 | + name = "frq-dart"; | ||
| 471 | + packages = [ pkgs.dart pkgs.just ]; | ||
| 472 | + FRQ_DART = "1"; | ||
| 473 | + }; | ||
| 474 | + | ||
| 462 | # Nim, for `nim/` — the portable core as a native library. Just the | 475 | # Nim, for `nim/` — the portable core as a native library. Just the |
| 463 | # compiler: the core has no dependencies outside Nim's own standard | 476 | # compiler: the core has no dependencies outside Nim's own standard |
| 464 | # library, deliberately, because a dependency here is one that has | 477 | # library, deliberately, because a dependency here is one that has |
deleted
flutter/src/frq/core/ffi.cljd +0 -76 | deleted file mode 100644 | ||
| @@ -1,76 +0,0 @@ | ||
| 1 | -(ns frq.core.ffi | |
| 2 | - "Loading `libfrqcore.so`, and the two rules for talking to it. | |
| 3 | - | |
| 4 | - **NOT YET COMPILED.** Nothing requires this namespace, so no build has | |
| 5 | - type-checked a line of it. The Nim side it calls is tested and its ABI is | |
| 6 | - proven from C (`nim/`, and `just nim-test`); this half is the shape the | |
| 7 | - binding should take and not a working one. Before trusting it: add `ffi` to | |
| 8 | - `flutter/pubspec.yaml`, require this from a native entry point, and build | |
| 9 | - `just flutter-desktop` — `.lookupFunction` needs native and Dart type | |
| 10 | - arguments, and how ClojureDart spells those is the first thing to find out. | |
| 11 | - | |
| 12 | - The Nim core is the portable logic — see `nim/README.md` for why it is | |
| 13 | - there rather than under `common/`. This namespace is the whole of what knows | |
| 14 | - it is a native library: everything above it calls ordinary functions. | |
| 15 | - | |
| 16 | - **Not required by `frq.main`.** `dart:ffi` has no web implementation, and | |
| 17 | - `clojure -M:cljd compile` walks out from one namespace, so requiring this | |
| 18 | - from the shared entry point would break the web build to serve the native | |
| 19 | - ones — the same trap `frq.main-web` exists to avoid. A caller reaches for | |
| 20 | - `frq.core.irc`, which decides. | |
| 21 | - | |
| 22 | - The two rules, both of them wrapped here so no call site repeats them: | |
| 23 | - | |
| 24 | - * `frq_init` runs once before anything else, setting Nim's runtime up. | |
| 25 | - `library` does it on the way out, so holding the handle means it is done. | |
| 26 | - * Every string the core returns is **ours to free**, with `frq_free`. | |
| 27 | - `take-string!` is that: read the bytes, hand the pointer back, and do it | |
| 28 | - in a `finally` so a throw between the two does not leak. Nim's allocator | |
| 29 | - is not Dart's, so `calloc.free` on one of these pointers is undefined | |
| 30 | - rather than merely wrong." | |
| 31 | - (:require ["dart:ffi" :as ffi] | |
| 32 | - ["dart:io" :as io] | |
| 33 | - ["package:ffi/ffi.dart" :as pffi])) | |
| 34 | - | |
| 35 | -;; The name the loader is given, per platform. Android resolves a bare soname | |
| 36 | -;; out of the APK's lib/<abi>/ directory; a desktop build has no such rule, so | |
| 37 | -;; it is looked for beside the executable and then on the usual path. | |
| 38 | -(def ^:private lib-name "libfrqcore.so") | |
| 39 | - | |
| 40 | -(defn- open-library [] | |
| 41 | - (if (.-isAndroid io/Platform) | |
| 42 | - (ffi/DynamicLibrary.open lib-name) | |
| 43 | - (try | |
| 44 | - (ffi/DynamicLibrary.open lib-name) | |
| 45 | - (catch Object _ | |
| 46 | - ;; The development path: `just nim-lib` writes into build/nim, which | |
| 47 | - ;; is not anywhere a loader looks. Named explicitly rather than by | |
| 48 | - ;; setting LD_LIBRARY_PATH, because a variable set in a launcher is a | |
| 49 | - ;; thing that works until someone starts the binary another way. | |
| 50 | - (ffi/DynamicLibrary.open "build/nim/libfrqcore.so"))))) | |
| 51 | - | |
| 52 | -(defonce ^{:doc "The handle, opened once and initialised on the way out."} | |
| 53 | - library | |
| 54 | - (delay | |
| 55 | - (let [lib (open-library) | |
| 56 | - init (.lookupFunction lib "frq_init")] | |
| 57 | - (init) | |
| 58 | - lib))) | |
| 59 | - | |
| 60 | -(defn free! | |
| 61 | - "Hand a pointer the core returned back to it." | |
| 62 | - [^ffi/Pointer p] | |
| 63 | - (when-not (.-isNull (.-address p)) | |
| 64 | - ((.lookupFunction @library "frq_free") p))) | |
| 65 | - | |
| 66 | -(defn take-string! | |
| 67 | - "The string at `p`, with `p` freed afterwards — nil for a null pointer. | |
| 68 | - | |
| 69 | - The `finally` is the point: `toDartString` decodes UTF-8 and can throw on | |
| 70 | - bytes that are not, and a throw between the read and the free is a leak | |
| 71 | - that only shows up under the malformed input this core exists to survive." | |
| 72 | - [^ffi/Pointer p] | |
| 73 | - (when (not= 0 (.-address p)) | |
| 74 | - (try | |
| 75 | - (.toDartString (.cast p)) | |
| 76 | - (finally (free! p))))) | |
| deleted file mode 100644 | |||
| @@ -1,76 +0,0 @@ | |||
| 1 | -(ns frq.core.ffi | ||
| 2 | - "Loading `libfrqcore.so`, and the two rules for talking to it. | ||
| 3 | - | ||
| 4 | - **NOT YET COMPILED.** Nothing requires this namespace, so no build has | ||
| 5 | - type-checked a line of it. The Nim side it calls is tested and its ABI is | ||
| 6 | - proven from C (`nim/`, and `just nim-test`); this half is the shape the | ||
| 7 | - binding should take and not a working one. Before trusting it: add `ffi` to | ||
| 8 | - `flutter/pubspec.yaml`, require this from a native entry point, and build | ||
| 9 | - `just flutter-desktop` — `.lookupFunction` needs native and Dart type | ||
| 10 | - arguments, and how ClojureDart spells those is the first thing to find out. | ||
| 11 | - | ||
| 12 | - The Nim core is the portable logic — see `nim/README.md` for why it is | ||
| 13 | - there rather than under `common/`. This namespace is the whole of what knows | ||
| 14 | - it is a native library: everything above it calls ordinary functions. | ||
| 15 | - | ||
| 16 | - **Not required by `frq.main`.** `dart:ffi` has no web implementation, and | ||
| 17 | - `clojure -M:cljd compile` walks out from one namespace, so requiring this | ||
| 18 | - from the shared entry point would break the web build to serve the native | ||
| 19 | - ones — the same trap `frq.main-web` exists to avoid. A caller reaches for | ||
| 20 | - `frq.core.irc`, which decides. | ||
| 21 | - | ||
| 22 | - The two rules, both of them wrapped here so no call site repeats them: | ||
| 23 | - | ||
| 24 | - * `frq_init` runs once before anything else, setting Nim's runtime up. | ||
| 25 | - `library` does it on the way out, so holding the handle means it is done. | ||
| 26 | - * Every string the core returns is **ours to free**, with `frq_free`. | ||
| 27 | - `take-string!` is that: read the bytes, hand the pointer back, and do it | ||
| 28 | - in a `finally` so a throw between the two does not leak. Nim's allocator | ||
| 29 | - is not Dart's, so `calloc.free` on one of these pointers is undefined | ||
| 30 | - rather than merely wrong." | ||
| 31 | - (:require ["dart:ffi" :as ffi] | ||
| 32 | - ["dart:io" :as io] | ||
| 33 | - ["package:ffi/ffi.dart" :as pffi])) | ||
| 34 | - | ||
| 35 | -;; The name the loader is given, per platform. Android resolves a bare soname | ||
| 36 | -;; out of the APK's lib/<abi>/ directory; a desktop build has no such rule, so | ||
| 37 | -;; it is looked for beside the executable and then on the usual path. | ||
| 38 | -(def ^:private lib-name "libfrqcore.so") | ||
| 39 | - | ||
| 40 | -(defn- open-library [] | ||
| 41 | - (if (.-isAndroid io/Platform) | ||
| 42 | - (ffi/DynamicLibrary.open lib-name) | ||
| 43 | - (try | ||
| 44 | - (ffi/DynamicLibrary.open lib-name) | ||
| 45 | - (catch Object _ | ||
| 46 | - ;; The development path: `just nim-lib` writes into build/nim, which | ||
| 47 | - ;; is not anywhere a loader looks. Named explicitly rather than by | ||
| 48 | - ;; setting LD_LIBRARY_PATH, because a variable set in a launcher is a | ||
| 49 | - ;; thing that works until someone starts the binary another way. | ||
| 50 | - (ffi/DynamicLibrary.open "build/nim/libfrqcore.so"))))) | ||
| 51 | - | ||
| 52 | -(defonce ^{:doc "The handle, opened once and initialised on the way out."} | ||
| 53 | - library | ||
| 54 | - (delay | ||
| 55 | - (let [lib (open-library) | ||
| 56 | - init (.lookupFunction lib "frq_init")] | ||
| 57 | - (init) | ||
| 58 | - lib))) | ||
| 59 | - | ||
| 60 | -(defn free! | ||
| 61 | - "Hand a pointer the core returned back to it." | ||
| 62 | - [^ffi/Pointer p] | ||
| 63 | - (when-not (.-isNull (.-address p)) | ||
| 64 | - ((.lookupFunction @library "frq_free") p))) | ||
| 65 | - | ||
| 66 | -(defn take-string! | ||
| 67 | - "The string at `p`, with `p` freed afterwards — nil for a null pointer. | ||
| 68 | - | ||
| 69 | - The `finally` is the point: `toDartString` decodes UTF-8 and can throw on | ||
| 70 | - bytes that are not, and a throw between the read and the free is a leak | ||
| 71 | - that only shows up under the malformed input this core exists to survive." | ||
| 72 | - [^ffi/Pointer p] | ||
| 73 | - (when (not= 0 (.-address p)) | ||
| 74 | - (try | ||
| 75 | - (.toDartString (.cast p)) | ||
| 76 | - (finally (free! p))))) | ||
modified
justfile +23 -0 | @@ -404,3 +404,26 @@ nim-lib: | ||
| 404 | 404 | --path:src --out:"$out/libfrqcore.so" src/frq_core.nim |
| 405 | 405 | echo "built $out/libfrqcore.so" |
| 406 | 406 | nm -D --defined-only "$out/libfrqcore.so" | grep ' T frq_' || true |
| 407 | + | |
| 408 | +# The Dart side of the Nim boundary, on the plain Dart VM. | |
| 409 | +# | |
| 410 | +# No Flutter, no emulator, no ClojureDart — `dart/frq_core` is ordinary Dart | |
| 411 | +# over `dart:ffi` and is not a Flutter package, so the test that proves the | |
| 412 | +# marshalling runs in a second. Passing `just nim-test` and failing this one is a | |
| 413 | +# marshalling bug, which is the whole reason the two suites are separate. | |
| 414 | +# | |
| 415 | +# Builds the library first: the test dlopens a real .so and there is no point | |
| 416 | +# reporting that it could not find one. | |
| 417 | +dart-test: | |
| 418 | + #!/usr/bin/env bash | |
| 419 | + set -euo pipefail | |
| 420 | + cd "{{justfile_directory()}}" | |
| 421 | + if [ -z "${FRQ_DART:-}" ]; then | |
| 422 | + # nim-lib before the re-entry, not after: it enters a shell of its own | |
| 423 | + # and doing it on the far side would build the library twice. | |
| 424 | + just nim-lib | |
| 425 | + exec {{nix}} develop .#dart --max-jobs {{jobs}} --command just dart-test | |
| 426 | + fi | |
| 427 | + cd dart/frq_core | |
| 428 | + dart pub get | |
| 429 | + dart test -r expanded | |
| @@ -404,3 +404,26 @@ nim-lib: | |||
| 404 | --path:src --out:"$out/libfrqcore.so" src/frq_core.nim | 404 | --path:src --out:"$out/libfrqcore.so" src/frq_core.nim |
| 405 | echo "built $out/libfrqcore.so" | 405 | echo "built $out/libfrqcore.so" |
| 406 | nm -D --defined-only "$out/libfrqcore.so" | grep ' T frq_' || true | 406 | nm -D --defined-only "$out/libfrqcore.so" | grep ' T frq_' || true |
| 407 | + | ||
| 408 | +# The Dart side of the Nim boundary, on the plain Dart VM. | ||
| 409 | +# | ||
| 410 | +# No Flutter, no emulator, no ClojureDart — `dart/frq_core` is ordinary Dart | ||
| 411 | +# over `dart:ffi` and is not a Flutter package, so the test that proves the | ||
| 412 | +# marshalling runs in a second. Passing `just nim-test` and failing this one is a | ||
| 413 | +# marshalling bug, which is the whole reason the two suites are separate. | ||
| 414 | +# | ||
| 415 | +# Builds the library first: the test dlopens a real .so and there is no point | ||
| 416 | +# reporting that it could not find one. | ||
| 417 | +dart-test: | ||
| 418 | + #!/usr/bin/env bash | ||
| 419 | + set -euo pipefail | ||
| 420 | + cd "{{justfile_directory()}}" | ||
| 421 | + if [ -z "${FRQ_DART:-}" ]; then | ||
| 422 | + # nim-lib before the re-entry, not after: it enters a shell of its own | ||
| 423 | + # and doing it on the far side would build the library twice. | ||
| 424 | + just nim-lib | ||
| 425 | + exec {{nix}} develop .#dart --max-jobs {{jobs}} --command just dart-test | ||
| 426 | + fi | ||
| 427 | + cd dart/frq_core | ||
| 428 | + dart pub get | ||
| 429 | + dart test -r expanded | ||
modified
nim/README.md +17 -11 | @@ -61,17 +61,23 @@ deleted as each module lands: they are the web's implementation, not dead code. | ||
| 61 | 61 | ABI is exercised from C through `dlopen`, including the allocation contract |
| 62 | 62 | under a hundred thousand parse/free cycles. That half is real. |
| 63 | 63 | |
| 64 | -The Dart binding (`flutter/src/frq/core/ffi.cljd`) is **not**: it is written | |
| 65 | -but nothing requires it, so no build has compiled it. Nothing calls the core | |
| 66 | -yet, and `common/frq/irc/parse.cljc` is still what every target actually runs. | |
| 67 | - | |
| 68 | -The next step is that binding, and it is the one with the unknown in it: | |
| 69 | -`.lookupFunction` takes native and Dart type arguments, and how ClojureDart | |
| 70 | -spells a generic interop call is the thing to establish before porting a | |
| 71 | -second module. After it: the library has to reach the targets — `jniLibs` for | |
| 72 | -the APK, beside the executable for the desktop bundle — and then | |
| 73 | -`frq.core.irc` can choose between the Nim implementation and the ClojureDart | |
| 74 | -one per target, which is also how the web keeps working. | |
| 64 | +The Dart binding is real too, and is `dart/frq_core` — **plain Dart, not | |
| 65 | +ClojureDart**. It calls the library over `dart:ffi` and is covered by 20 tests | |
| 66 | +on the Dart VM, including the UTF-8 round trip and ten thousand calls against | |
| 67 | +the ownership rules. `just dart-test` runs the pair of them in about a second. | |
| 68 | + | |
| 69 | +The binding was ClojureDart for one commit and should not have been: the Nim | |
| 70 | +core exists to have less Clojure in the tree, and `lookupFunction` takes two | |
| 71 | +type arguments, so it meant fighting generic interop to write more of the | |
| 72 | +thing being removed. In Dart it is a typedef. See `dart/README.md`. | |
| 73 | + | |
| 74 | +What is **not** done is the wiring: nothing imports `frq_core`, so | |
| 75 | +`common/frq/irc/parse.cljc` is still what every target runs. That step is its | |
| 76 | +own piece of work — the Flutter app takes the package as a path dependency | |
| 77 | +(which means a `pubspec.lock` regeneration and widening the nix build's source | |
| 78 | +root), the library has to reach each target (`jniLibs` for the APK, beside the | |
| 79 | +executable for the desktop bundle), and only then can a call site choose Nim | |
| 80 | +on native and the ClojureDart original on the web. | |
| 75 | 81 | |
| 76 | 82 | Modules still in `common/` and not yet here: `rooms`, `msgsig`, `crypto`, |
| 77 | 83 | `atproto/core`, `oauth/core`, `store`, `irc/handshake`, `irc/mutate`, |
| @@ -61,17 +61,23 @@ deleted as each module lands: they are the web's implementation, not dead code. | |||
| 61 | ABI is exercised from C through `dlopen`, including the allocation contract | 61 | ABI is exercised from C through `dlopen`, including the allocation contract |
| 62 | under a hundred thousand parse/free cycles. That half is real. | 62 | under a hundred thousand parse/free cycles. That half is real. |
| 63 | 63 | ||
| 64 | -The Dart binding (`flutter/src/frq/core/ffi.cljd`) is **not**: it is written | 64 | +The Dart binding is real too, and is `dart/frq_core` — **plain Dart, not |
| 65 | -but nothing requires it, so no build has compiled it. Nothing calls the core | 65 | +ClojureDart**. It calls the library over `dart:ffi` and is covered by 20 tests |
| 66 | -yet, and `common/frq/irc/parse.cljc` is still what every target actually runs. | 66 | +on the Dart VM, including the UTF-8 round trip and ten thousand calls against |
| 67 | - | 67 | +the ownership rules. `just dart-test` runs the pair of them in about a second. |
| 68 | -The next step is that binding, and it is the one with the unknown in it: | 68 | + |
| 69 | -`.lookupFunction` takes native and Dart type arguments, and how ClojureDart | 69 | +The binding was ClojureDart for one commit and should not have been: the Nim |
| 70 | -spells a generic interop call is the thing to establish before porting a | 70 | +core exists to have less Clojure in the tree, and `lookupFunction` takes two |
| 71 | -second module. After it: the library has to reach the targets — `jniLibs` for | 71 | +type arguments, so it meant fighting generic interop to write more of the |
| 72 | -the APK, beside the executable for the desktop bundle — and then | 72 | +thing being removed. In Dart it is a typedef. See `dart/README.md`. |
| 73 | -`frq.core.irc` can choose between the Nim implementation and the ClojureDart | 73 | + |
| 74 | -one per target, which is also how the web keeps working. | 74 | +What is **not** done is the wiring: nothing imports `frq_core`, so |
| 75 | +`common/frq/irc/parse.cljc` is still what every target runs. That step is its | ||
| 76 | +own piece of work — the Flutter app takes the package as a path dependency | ||
| 77 | +(which means a `pubspec.lock` regeneration and widening the nix build's source | ||
| 78 | +root), the library has to reach each target (`jniLibs` for the APK, beside the | ||
| 79 | +executable for the desktop bundle), and only then can a call site choose Nim | ||
| 80 | +on native and the ClojureDart original on the web. | ||
| 75 | 81 | ||
| 76 | Modules still in `common/` and not yet here: `rooms`, `msgsig`, `crypto`, | 82 | Modules still in `common/` and not yet here: `rooms`, `msgsig`, `crypto`, |
| 77 | `atproto/core`, `oauth/core`, `store`, `irc/handshake`, `irc/mutate`, | 83 | `atproto/core`, `oauth/core`, `store`, `irc/handshake`, `irc/mutate`, |