nandi/frqpublic Fork 0
f7aea3b
Commits
Clone
git clone https://git.rickub.com/nandi/frq.git
git clone ssh://git@rickub.com/nandi/frq.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

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>
nandi committed 2026-09-18T19:33:28-07:00 Browse files
f7aea3b parent: 438b247
modified .gitignore +4 -0
@@ -4,6 +4,10 @@
44 # Nim's own object/cache tree, and the test binaries `nim c -r` leaves beside
55 # the sources — one per tests/t*.nim, named without an extension.
66 /nim/nimcache/
7+
8+# Dart's per-package resolution output.
9+/dart/*/.dart_tool/
10+/dart/*/pubspec.lock
711 /nim/tests/t*
812 !/nim/tests/t*.nim
913
@@ -4,6 +4,10 @@
4 # Nim's own object/cache tree, and the test binaries `nim c -r` leaves beside4 # 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*.nim12 !/nim/tests/t*.nim
9 13
modified .gitlab-ci.yml +12 -0
@@ -27,3 +27,15 @@ nim-test:
2727 image: nimlang/nim:2.2.0-alpine
2828 script:
2929 - 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-alpine27 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"; done29 - 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:
4040 - uses: actions/checkout@v4
4141 - run: cd nim && for t in tests/t*.nim; do nim c -r --hints:off --path:src "$t"; done
4242
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+
4357 web:
4458 runs-on: ubuntu-latest
45- needs: [check-common, nim-test]
59+ needs: [check-common, nim-test, dart-test]
4660 # What it spends its time on is the ClojureDart compile and, on a cold
4761 # toolchain, fetching the pinned Flutter/JDK/Clojure tarballs.
4862 timeout-minutes: 30
@@ -40,9 +40,23 @@ jobs:
40 - uses: actions/checkout@v440 - uses: actions/checkout@v4
41 - run: cd nim && for t in tests/t*.nim; do nim c -r --hints:off --path:src "$t"; done41 - 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-latest58 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 cold60 # 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: 3062 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:
7777 Nim's allocator is not Dart's.
7878 * `frq_init` runs once before anything else.
7979
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.
8188
8289 A module is not deleted from `common/` when its Nim version lands: the web
8390 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 web89 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's90 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:
1717 common/ .cljc portable: the screens, the state, the protocol — no dart:
1818 flutter/ .cljd the Flutter half, and the host's answers — flutter/README.md
1919 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
2021 ```
2122
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.
2627
2728 What `common/` needs of the host it asks `common/frq/io.cljc` for — the seam,
2829 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.md18 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.md19 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 under23+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 calls24+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` for25+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 @@
459459 inherit (pkgs) lib;
460460 in
461461 {
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+
462475 # Nim, for `nim/` — the portable core as a native library. Just the
463476 # compiler: the core has no dependencies outside Nim's own standard
464477 # library, deliberately, because a dependency here is one that has
@@ -459,6 +459,19 @@
459 inherit (pkgs) lib;459 inherit (pkgs) lib;
460 in460 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 the475 # Nim, for `nim/` — the portable core as a native library. Just the
463 # compiler: the core has no dependencies outside Nim's own standard476 # compiler: the core has no dependencies outside Nim's own standard
464 # library, deliberately, because a dependency here is one that has477 # 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:
404404 --path:src --out:"$out/libfrqcore.so" src/frq_core.nim
405405 echo "built $out/libfrqcore.so"
406406 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.nim404 --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_' || true406 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.
6161 ABI is exercised from C through `dlopen`, including the allocation contract
6262 under a hundred thousand parse/free cycles. That half is real.
6363
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.
7581
7682 Modules still in `common/` and not yet here: `rooms`, `msgsig`, `crypto`,
7783 `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 contract61 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 written64+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 core65+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 ClojureDart69+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 a70+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` for71+type arguments, so it meant fighting generic interop to write more of the
72-the APK, beside the executable for the desktop bundle — and then72+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 ClojureDart73+
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`,