Skip to content

Commit c6f25d0

Browse files
authored
Merge pull request #698 from casselc/agent/ffi-struct-layouts
Add declarative FFI struct layouts
2 parents cbd86da + b98659c commit c6f25d0

12 files changed

Lines changed: 838 additions & 353 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- **Declarative `jolt.ffi` struct layouts:** `(ffi/layout [:struct ...])`
13+
compiles a literal, data-only descriptor into immutable ABI metadata derived
14+
by Chez. `layout-size`, `layout-alignment`, and `field-offset` expose the
15+
native layout, while `read-field` and `write-field` access scalar fields by
16+
keyword path. Layouts support fixed-size scalar fields and nested structs;
17+
arrays, unions, bitfields, packing, and recursive descriptors are not yet
18+
supported.
19+
1020
### Fixed
1121

1222
- **Static fields resolve through every access path.** Three related bugs in
@@ -130,7 +140,6 @@ the `var` special form reports real errors.
130140
namespaced symbol (the namespace part used to be dropped). `(var String)`
131141
still succeeds and returns the class-holding var — jolt keeps imported
132142
classes in vars, a deliberate superset of the JVM, which refuses.
133-
134143
## [0.7.21] - 2026-08-22
135144

136145
A patch release that gives core vars their documentation: `(meta #'map)` used

‎Makefile‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -489,10 +489,12 @@ cts: testbin
489489
# FFI: bind native functions (typed foreign-procedure), memory, and that a
490490
# :blocking call is collect-safe (a parked thread doesn't pin the collector).
491491
# The widths gate covers the exact scalar vocabulary across both halves of the
492-
# API: runtime memory access and compiler-emitted procedures/callables.
492+
# API: runtime memory access and compiler-emitted procedures/callables. The
493+
# layout gate compares declarative struct metadata and field access against C.
493494
ffi:
494495
@$(CHEZ) --script test/chez/ffi-binding-test.ss
495496
@sh test/chez/ffi-widths-test.sh "$(CHEZ)"
497+
@sh test/chez/ffi-layout-test.sh "$(CHEZ)"
496498

497499
# Transients: mutable backing, snapshot on persistent!, and linear-time builds.
498500
transient:

‎host/chez/seed/image.ss‎

Lines changed: 195 additions & 175 deletions
Large diffs are not rendered by default.

‎host/gambit/seed/image.ss‎

Lines changed: 195 additions & 175 deletions
Large diffs are not rendered by default.

‎jolt-core/jolt/analyzer.clj‎

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -899,6 +899,75 @@
899899
(host-new (or (deftype-ctor-class ctx class) class)
900900
(mapv #(analyze ctx % env) args)))
901901

902+
;; Literal, data-only struct descriptors. Keep the analyzer representation free
903+
;; of reader objects so it survives self-hosting and can be embedded in the IR.
904+
(def ^:private ffi-layout-scalars
905+
#{"int" "uint" "int8" "i8" "uint8" "u8" "byte" "char"
906+
"int16" "short" "uint16" "ushort" "int32" "uint32"
907+
"long" "ulong" "int64" "uint64" "size_t" "ssize_t" "iptr" "uptr"
908+
"double" "float" "pointer" "void*"})
909+
910+
(declare analyze-ffi-layout-type)
911+
912+
(defn- analyze-ffi-layout-struct [form]
913+
(when-not (form-vec? form)
914+
(throw (str "jolt.ffi layout descriptor must be [:struct [[field type] ...]], got "
915+
(pr-str form))))
916+
(let [parts (vec (form-vec-items form))]
917+
(when-not (and (= 2 (count parts))
918+
(form-keyword? (nth parts 0))
919+
(nil? (namespace (nth parts 0)))
920+
(= "struct" (name (nth parts 0)))
921+
(form-vec? (nth parts 1)))
922+
(throw (str "jolt.ffi layout descriptor must be [:struct [[field type] ...]], got "
923+
(pr-str form))))
924+
(let [field-forms (vec (form-vec-items (nth parts 1)))]
925+
(when (empty? field-forms)
926+
(throw "jolt.ffi struct descriptor must contain at least one field"))
927+
(loop [remaining field-forms names #{} fields []]
928+
(if (empty? remaining)
929+
{:ffi-kind :struct :fields fields}
930+
(let [field (first remaining)]
931+
(when-not (form-vec? field)
932+
(throw (str "jolt.ffi struct field must be [keyword type], got "
933+
(pr-str field))))
934+
(let [fp (vec (form-vec-items field))]
935+
(when-not (= 2 (count fp))
936+
(throw (str "jolt.ffi struct field must be [keyword type], got "
937+
(pr-str field))))
938+
(let [field-name (nth fp 0)]
939+
(when-not (and (form-keyword? field-name)
940+
(nil? (namespace field-name)))
941+
(throw (str "jolt.ffi struct field name must be an unqualified keyword, got "
942+
(pr-str field-name))))
943+
(let [nm (name field-name)]
944+
(when (contains? names nm)
945+
(throw (str "jolt.ffi struct field names must be unique; duplicate :" nm)))
946+
(recur (rest remaining)
947+
(conj names nm)
948+
(conj fields {:name nm
949+
:type (analyze-ffi-layout-type (nth fp 1))})))))))))))
950+
951+
(defn- analyze-ffi-layout-type [form]
952+
(cond
953+
(form-keyword? form)
954+
(let [n (name form)]
955+
(when-not (and (nil? (namespace form)) (contains? ffi-layout-scalars n))
956+
(throw (str "jolt.ffi struct field type must be a fixed-size scalar or nested struct; got "
957+
(pr-str form))))
958+
n)
959+
960+
(form-vec? form) (analyze-ffi-layout-struct form)
961+
962+
:else
963+
(throw (str "jolt.ffi struct field type must be a fixed-size scalar or nested struct; got "
964+
(pr-str form)))))
965+
966+
(defn- analyze-ffi-layout [items]
967+
(when-not (= 2 (count items))
968+
(throw "jolt.ffi/layout expects one literal struct descriptor"))
969+
{:op :ffi-layout :layout (analyze-ffi-layout-struct (nth items 1))})
970+
902971
;; jolt.ffi/__cfn: the low-level foreign-function form a jolt library
903972
;; uses (via the jolt.ffi/foreign-fn macro) to bind native code. Shape:
904973
;; (jolt.ffi/__cfn "c_symbol" [:argtype ...] :rettype) ; non-blocking
@@ -1235,6 +1304,11 @@
12351304
(and (form-sym? head) (= "jolt.ffi" (form-sym-ns head))
12361305
(= "__cfn" (form-sym-name head)))
12371306
(analyze-ffi-fn ctx items env)
1307+
;; jolt.ffi/layout expands to this literal-only leaf. The back end
1308+
;; asks Chez for the actual ABI size, alignment and offsets.
1309+
(and (form-sym? head) (= "jolt.ffi" (form-sym-ns head))
1310+
(= "__layout" (form-sym-name head)))
1311+
(analyze-ffi-layout items)
12381312
;; jolt.ffi/__ccallable — the foreign-callback special form (the fn is a
12391313
;; child expression, analyzed here).
12401314
(and (form-sym? head) (= "jolt.ffi" (form-sym-ns head))

‎jolt-core/jolt/backend_scheme.clj‎

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1138,6 +1138,92 @@
11381138
"uint8" "unsigned-8" "u8" "unsigned-8" "byte" "unsigned-8" "char" "char"})
11391139
(defn- ffi-type->chez [t]
11401140
(or (ffi-types t) (throw (ex-info (str "jolt.ffi: unknown foreign type :" t) {}))))
1141+
1142+
(defn- emit-ffi-layout-ftype [layout]
1143+
(str "(struct "
1144+
(str/join
1145+
" "
1146+
(map-indexed
1147+
(fn [i field]
1148+
(str "[f" i " "
1149+
(if (string? (:type field))
1150+
(ffi-type->chez (:type field))
1151+
(emit-ffi-layout-ftype (:type field)))
1152+
"]"))
1153+
(:fields layout)))
1154+
")"))
1155+
1156+
(defn- ffi-layout-entries
1157+
([layout] (ffi-layout-entries layout [] []))
1158+
([layout public-path emitted-path]
1159+
(mapcat
1160+
(fn [i field]
1161+
(let [pp (conj public-path (:name field))
1162+
ep (conj emitted-path (str "f" i))
1163+
entry {:path pp :emitted-path ep :type (:type field)}]
1164+
(if (string? (:type field))
1165+
[entry]
1166+
(cons entry (ffi-layout-entries (:type field) pp ep)))))
1167+
(range (count (:fields layout)))
1168+
(:fields layout))))
1169+
1170+
(defn- emit-layout-path [path]
1171+
(str "(jolt-vector "
1172+
(str/join " " (map (fn [n] (str "(keyword #f " (chez-str-lit n) ")")) path))
1173+
")"))
1174+
1175+
(defn- emit-layout-descriptor [layout]
1176+
(str "(jolt-vector (keyword #f \"struct\") (jolt-vector "
1177+
(str/join
1178+
" "
1179+
(map (fn [field]
1180+
(str "(jolt-vector (keyword #f " (chez-str-lit (:name field)) ") "
1181+
(if (string? (:type field))
1182+
(str "(keyword #f " (chez-str-lit (:type field)) ")")
1183+
(emit-layout-descriptor (:type field)))
1184+
")"))
1185+
(:fields layout)))
1186+
"))"))
1187+
1188+
(defn- emit-ffi-layout [node]
1189+
(let [layout (:layout node)
1190+
type-name (fresh-label "jolt_ffi_layout")
1191+
align-name (fresh-label "jolt_ffi_layout_align")
1192+
entries (vec (ffi-layout-entries layout))
1193+
base (str "(make-ftype-pointer " type-name " 0)")
1194+
offsets
1195+
(str "(jolt-hash-map "
1196+
(str/join
1197+
" "
1198+
(map (fn [entry]
1199+
(str (emit-layout-path (:path entry)) " "
1200+
"(ftype-pointer-address (ftype-&ref " type-name " ("
1201+
(str/join " " (:emitted-path entry)) ") " base "))"))
1202+
entries))
1203+
")")
1204+
types
1205+
(str "(jolt-hash-map "
1206+
(str/join
1207+
" "
1208+
(keep (fn [entry]
1209+
(when (string? (:type entry))
1210+
(str (emit-layout-path (:path entry)) " "
1211+
"(keyword #f " (chez-str-lit (:type entry)) ")")))
1212+
entries))
1213+
")")]
1214+
(str "(let () "
1215+
"(define-ftype " type-name " " (emit-ffi-layout-ftype layout) ") "
1216+
"(define-ftype " align-name " (struct [prefix unsigned-8] [value " type-name "])) "
1217+
"(jolt-hash-map "
1218+
"(keyword \"jolt.ffi\" \"layout\") #t "
1219+
"(keyword #f \"descriptor\") " (emit-layout-descriptor layout) " "
1220+
"(keyword #f \"size\") (ftype-sizeof " type-name ") "
1221+
"(keyword #f \"alignment\") "
1222+
"(ftype-pointer-address (ftype-&ref " align-name " (value) "
1223+
"(make-ftype-pointer " align-name " 0))) "
1224+
"(keyword \"jolt.ffi\" \"offsets\") " offsets " "
1225+
"(keyword \"jolt.ffi\" \"types\") " types "))")))
1226+
11411227
(defn- emit-ffi-fn [node]
11421228
;; A "varargs" marker in the argtype vector declares the binding variadic and
11431229
;; marks the FIXED/VARIADIC boundary: types before it are the named
@@ -2171,6 +2257,7 @@
21712257
:let (emit-let node)
21722258
:loop (emit-loop node)
21732259
:recur (emit-recur node)
2260+
:ffi-layout (emit-ffi-layout node)
21742261
:ffi-fn (emit-ffi-fn node)
21752262
:ffi-callable (emit-ffi-callable node)
21762263
:fn (emit-fn node)

‎jolt-core/jolt/ir.clj‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,7 @@
135135
;; :set-var :the-var :val :val (:the-var is a leaf the-var node)
136136
;; :set-field :obj :field :val :obj :val
137137
;; :defmacro :ns :name :fn :fn
138+
;; :ffi-layout :layout —
138139
;; :ffi-fn :csym :argtypes :rettype —
139140
;; :ffi-callable :fn :argtypes :rettype :fn
140141
;; :regex :source —
@@ -164,7 +165,7 @@
164165
(def node-ops
165166
#{:const :local :var :the-var :host :host-static :host-new :if :do :invoke :def
166167
:let :loop :recur :fn :vector :map :set :quote :throw :coerce :try :host-call
167-
:set-var :set-field :defmacro :ffi-fn :ffi-callable :regex :inst :uuid :bigdec
168+
:set-var :set-field :defmacro :ffi-layout :ffi-fn :ffi-callable :regex :inst :uuid :bigdec
168169
:the-ns})
169170

170171
;; op -> the keys a node of that op must carry. Optional keys (:init, annotations)
@@ -179,6 +180,7 @@
179180
:throw [:expr] :coerce [:kind :expr] :try [:body]
180181
:host-call [:target :method :args] :set-var [:the-var :val]
181182
:set-field [:obj :field :val] :defmacro [:ns :name :fn]
183+
:ffi-layout [:layout]
182184
:ffi-fn [:csym :argtypes :rettype] :ffi-callable [:fn :argtypes :rettype]
183185
:regex [:source] :inst [:source] :uuid [:source] :bigdec [:source]
184186
:the-ns [:name]})

‎stdlib/jolt/ffi.clj‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,54 @@
4040
is the inverse — it wraps a jolt fn as a C-callable function pointer so C can
4141
call back into jolt (e.g. GTK signal handlers); free-callable releases it.")
4242

43+
(defmacro layout
44+
"Compile a literal [:struct [[field type] ...]] descriptor into immutable ABI
45+
layout data. Field names are unique unqualified keywords; fields are fixed-size
46+
scalars or nested structs. Chez supplies size, alignment, and offsets."
47+
[descriptor]
48+
(list 'jolt.ffi/__layout descriptor))
49+
50+
(defn- checked-layout [layout]
51+
(when-not (and (map? layout) (= true (:jolt.ffi/layout layout)))
52+
(throw (ex-info "jolt.ffi: expected a compiled layout" {:layout layout})))
53+
layout)
54+
55+
(defn- checked-field-path [path]
56+
(let [p (if (keyword? path) [path] path)]
57+
(when-not (and (vector? p) (pos? (count p))
58+
(every? #(and (keyword? %) (nil? (namespace %))) p))
59+
(throw (ex-info "jolt.ffi: field path must be an unqualified keyword or non-empty vector of them"
60+
{:path path})))
61+
p))
62+
63+
(defn layout-size [layout] (:size (checked-layout layout)))
64+
(defn layout-alignment [layout] (:alignment (checked-layout layout)))
65+
66+
(defn field-offset [layout path]
67+
(let [layout (checked-layout layout)
68+
path (checked-field-path path)
69+
offsets (:jolt.ffi/offsets layout)]
70+
(when-not (contains? offsets path)
71+
(throw (ex-info "jolt.ffi: unknown layout field path" {:path path})))
72+
(get offsets path)))
73+
74+
(defn- field-type [layout path]
75+
(let [types (:jolt.ffi/types layout)]
76+
(when-not (contains? types path)
77+
(throw (ex-info "jolt.ffi: field path names a struct, not a scalar field"
78+
{:path path})))
79+
(get types path)))
80+
81+
(defn read-field [pointer layout path]
82+
(let [layout (checked-layout layout)
83+
path (checked-field-path path)]
84+
(jolt.ffi/read pointer (field-type layout path) (field-offset layout path))))
85+
86+
(defn write-field [pointer layout path value]
87+
(let [layout (checked-layout layout)
88+
path (checked-field-path path)]
89+
(jolt.ffi/write pointer (field-type layout path) (field-offset layout path) value)))
90+
4391
;; foreign-fn binds C symbol `csym` to a typed callable. Expands to the __cfn
4492
;; special form (always fully-qualified, so an :as alias on jolt.ffi resolves):
4593
;; the analyzer/back end turn it into a Chez foreign-procedure.

‎test/chez/ffi-layout-helper.c‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
/* C ABI witnesses for declarative jolt.ffi layouts. */
2+
#include <stddef.h>
3+
#include <stdint.h>
4+
5+
#ifdef _WIN32
6+
#define JOLT_LAYOUT_EXPORT __declspec(dllexport)
7+
#else
8+
#define JOLT_LAYOUT_EXPORT __attribute__((visibility("default")))
9+
#endif
10+
11+
struct jolt_layout_flat {
12+
int32_t year;
13+
uint8_t month;
14+
uint8_t day;
15+
};
16+
17+
struct jolt_layout_padded {
18+
uint8_t tag;
19+
double value;
20+
uint16_t tail;
21+
};
22+
23+
struct jolt_layout_nested {
24+
uint8_t tag;
25+
struct jolt_layout_flat date;
26+
uint16_t tail;
27+
};
28+
29+
#define WITNESS(name, expr) \
30+
JOLT_LAYOUT_EXPORT size_t jolt_layout_##name(void) { return (expr); }
31+
32+
WITNESS(flat_size, sizeof(struct jolt_layout_flat))
33+
WITNESS(flat_align, _Alignof(struct jolt_layout_flat))
34+
WITNESS(flat_year, offsetof(struct jolt_layout_flat, year))
35+
WITNESS(flat_month, offsetof(struct jolt_layout_flat, month))
36+
WITNESS(flat_day, offsetof(struct jolt_layout_flat, day))
37+
WITNESS(padded_size, sizeof(struct jolt_layout_padded))
38+
WITNESS(padded_align, _Alignof(struct jolt_layout_padded))
39+
WITNESS(padded_value, offsetof(struct jolt_layout_padded, value))
40+
WITNESS(padded_tail, offsetof(struct jolt_layout_padded, tail))
41+
WITNESS(nested_size, sizeof(struct jolt_layout_nested))
42+
WITNESS(nested_align, _Alignof(struct jolt_layout_nested))
43+
WITNESS(nested_date, offsetof(struct jolt_layout_nested, date))
44+
WITNESS(nested_year, offsetof(struct jolt_layout_nested, date.year))
45+
WITNESS(nested_month, offsetof(struct jolt_layout_nested, date.month))
46+
WITNESS(nested_tail, offsetof(struct jolt_layout_nested, tail))

‎test/chez/ffi-layout-test.sh‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
#!/bin/sh
2+
# Build the struct-layout C witness outside the tree, run the compiler-level and
3+
# public API checks, and retain the complete temporary directory on failure.
4+
set -u
5+
6+
CHEZ=${1-}
7+
[ -n "$CHEZ" ] || { echo "usage: $0 <chez> [jolt]" >&2; exit 2; }
8+
JOLT=${2-bin/jolt}
9+
C=test/chez/ffi-layout-helper.c
10+
[ -f "$C" ] || { echo "missing $C (run from repo root)" >&2; exit 2; }
11+
[ -x "$JOLT" ] || { echo "missing executable jolt: $JOLT" >&2; exit 2; }
12+
13+
OUT=$(mktemp -d) || exit 1
14+
[ -n "$OUT" ] && [ -d "$OUT" ] || {
15+
echo "mktemp returned no usable ffi-layout artifact directory" >&2
16+
exit 1
17+
}
18+
case "$(uname -s)" in
19+
Darwin) EXT=dylib ;;
20+
MINGW*|MSYS*|CYGWIN*) EXT=dll ;;
21+
*) EXT=so ;;
22+
esac
23+
SO=$OUT/jolt-ffi-layout-helper.$EXT
24+
cleanup() {
25+
status=$?
26+
if [ "$status" -eq 0 ]; then
27+
rm -f "$SO"
28+
rmdir "$OUT"
29+
else
30+
echo "retained ffi-layout artifacts: $OUT" >&2
31+
fi
32+
}
33+
trap cleanup EXIT
34+
CC_BIN=${CC:-cc}
35+
case "$(uname -s)" in
36+
Darwin) "$CC_BIN" -std=c11 -dynamiclib -o "$SO" "$C" ;;
37+
MINGW*|MSYS*|CYGWIN*) "$CC_BIN" -std=c11 -shared -o "$SO" "$C" ;;
38+
*) "$CC_BIN" -std=c11 -shared -fPIC -o "$SO" "$C" ;;
39+
esac || { echo "cc failed to build $C" >&2; exit 1; }
40+
JOLT_FFI_LAYOUT_HELPER=$SO "$CHEZ" --script test/chez/ffi-layout-test.ss
41+
JOLT_NO_USER_DEPS=1 JOLT_FFI_LAYOUT_HELPER=$SO \
42+
"$JOLT" run test/chez/jolt-ffi-layout-test.clj

0 commit comments

Comments
 (0)