jm bind — design sketch¶
Status: design note. Sibling to the
template gallery. This note captures a
"reverse" command — read a hand-written <comp>_core.h and synthesise
<comp>_ext.c from it, with no TOML and no prior jm history. The
sacred/glue contract that jm apply now ships (see
declarative-scaffolding.md) makes this
safe: _ext.c is a glue file, regenerated from the source of truth
without ever clobbering a hand-written _core.c.
Premise¶
The template gallery describes a fixed contract: every preset says what the state struct looks like, what the lifecycle trio is named, which verbs the component exposes. If a user codes to that contract by hand — in a fresh checkout, or in an existing C library they want to expose to Python — the binding is mechanically derivable. There's nothing left to ask.
jm bind <component> reads <component>_core.h, recognises the
template shape it follows, and writes a <component>_ext.c (plus
.pyi, plus the test) that matches. No just-makeit.toml, no jm new, no flags. Composes cleanly with hand-rolled or imported C code.
This is the asymmetry that makes it cheap: _ext.c only needs to know
five things, all present in a well-formed header.
| What the renderer needs | Where it lives in <comp>_core.h |
|---|---|
| State struct name + fields | typedef struct { ... } <comp>_state_t; |
| Constructor signature | <comp>_state_t *<comp>_create(...) |
step() / steps() shapes |
inline <comp>_step(...), declared <comp>_steps(...) |
| Getter / setter pairs | <comp>_get_<field>() / <comp>_set_<field>() |
| Extra methods | every other <comp>_<verb>(...) declared in the header |
The rendering side already exists — _render.render_module_ext_* and
the per-template _core.c skeletons. jm bind only needs the
front-end: parse the header into the same context dict that
make_state_ctx / make_methods_ctx produce today.
Phased rollout¶
| Phase | Scope | Effort |
|---|---|---|
| MVP | Regex header parser; scalar state fields only; one preset per call. Emits a working _ext.c for any project whose _core.h follows the template contract. |
~2 days, ~400 LOC |
| Real | Init_params recognition (ctor args that aren't state fields); output-param detection (out / output / dst / dest naming + _max_out pairing); opaque state; arbitrary custom verbs. Covers the working presets (processor, generator, consumer, reader). |
+ ~3 days |
| Robust | Replace regex with libclang AST. Handles preprocessor macros, typedef chains, declarations split across lines. Adds jm bind --check for CI parity. |
+ ~1 week |
Each phase is shippable on its own and the surface only ever grows.
The template contract (what makes a header reflectable)¶
The parser doesn't infer shape from arbitrary C. It assumes the file
follows the contract every gallery template already describes. Making
the contract formal turns each templates/*.md page into a sufficient
spec for jm bind.
A reflectable header must:
- Define a state struct at file scope:
Or, for opaque state, forward-declare it (definition in the
.c). Forward declarations bind to a handle-only Python class. - Declare the lifecycle trio with the canonical names:
<comp>_state_t *<comp>_create(...); void <comp>_destroy(<comp>_state_t *); void <comp>_reset(<comp>_state_t *);_resetis optional but conventional. - Declare per-field accessors for any field the Python class should expose: Bind emits one Python property per matching pair. Getter-only pairs bind to a read-only property.
- Name output array params
out,output,dst, ordest. These getT *(writable, allocated by Python). Every other pointer param is treated as input (const T *). - Pair variable-output methods with a sibling
_max_out:The pair is recognised by name + signature; bind allocates a sized buffer once atsize_t <comp>_<verb>_max_out(<comp>_state_t *); size_t <comp>_<verb>(<comp>_state_t *, ..., T *out);__init__and returns a zero-copy ndarray view on each call.
Every other <comp>_* declaration in the header is treated as a
custom method on the Python class. Module-level functions (not
prefixed with <comp>_) bind as free functions on the parent module.
The generator, consumer, reader, and function template pages
each describe the contract above by example — the formal version above
is just the union of what those pages already require. Variable-output
components (the event-emitter shape) follow the same contract plus a
sibling <comp>_<verb>_max_out() declaration. (blockwise is
excluded — array return is unsupported.)
The type allowlist the parser enforces per slot is the same one
documented in docs/types.md, referenced from the
"Concrete types" section of every template page. Parser and CLI share
that single source of truth — a const char * that isn't legal as a
state field is a parse error in jm bind, not just a "won't compile"
surprise.
Parser strategy¶
MVP — regex on a single pass¶
The header conventions above are line-shaped. Five regexes (state
struct, lifecycle trio, getter/setter, step / steps, every other
<comp>_*) plus a tiny type-mini-parser (the same _CTYPE_META keys
already in _types.py) cover the shipped templates. False negatives
on weird formatting are fine for an MVP — they fall through to the
"sorry, can't parse this header; fall back to TOML" path.
parse_header(path: Path) -> HeaderShape:
text = path.read_text()
state = match_state_struct(text)
ctor = match_ctor(text, comp=state.name)
step = match_step(text, comp=state.name)
gs = match_getter_setter_pairs(text, comp=state.name, fields=state.fields)
extra = match_remaining_decls(text, comp=state.name, claimed=...)
return HeaderShape(state, ctor, step, gs, extra)
HeaderShape is then translated into the same dict shape
make_state_ctx / make_methods_ctx produce today, fed through
_render.render_module_ext_*, and written to disk.
Real — convention-driven semantics¶
The harder semantic moves are still rule-based, just past lexical recognition:
- Init_params: any ctor param whose name does not match a state
field becomes an init_param. The ctor body in
.cis assumed to initialise the state via<comp>_set_<field>()calls (or direct struct assignment). - Output params: a pointer param named
out/output/dst/destis writable; any other pointer param is input. - Variable output: if
<comp>_<verb>_max_outexists alongside<comp>_<verb>, the verb binds as a variable-output method. - Opaque state: state struct present in header as forward decl
only — no
{ ... }body. Skip getter/setter discovery; emit a handle-only Python class.
Robust — libclang¶
When the regex pass fails (decl split across lines, macros expand to
the return type, typedef chain hides the int width), fall back to
libclang. Same HeaderShape output; the only thing that changes is
how it's populated. libclang is added as an optional dependency
(extras = ["bind-robust"]) so the MVP install stays slim.
--check mode¶
Reads <component>_core.h, runs the parse, renders what <comp>_ext.c
should look like, and diffs against the version on disk. Non-zero
exit on drift. This is the CI hook: the binding stays consistent with
the header without anyone having to re-run jm.
A passing --check in CI means "the C and the binding agree" — which
is exactly the property _function.run enforces today by emitting both
files from the same context.
Preservation¶
<comp>_ext.c is a glue file under the sacred/glue contract that
jm apply now ships: glue is regenerated from the source of truth on
every run, never hand-edited. jm bind follows the same rule —
re-running it re-derives _ext.c from the header wholesale.
So the workflow is:
- Run
jm bind footo derivefoo_ext.cfromfoo_core.h. - Edit
foo_core.hto add another method, andfoo_core.cto implement it._core.cis sacred — bind never touches it. - Re-run
jm bind foo— the new method gets a binding; the hand-written_core.cbody is untouched.
This is the same split jm apply enforces: glue (_ext.c, .pyi,
CMakeLists.txt) regenerates; sacred (_core.c) is the user's.
Composition with the rest of the toolchain¶
- Decoupled from
just-makeit.toml.jm bindreads the header and nothing else. It does not write to the manifest; it does not requirejm newto have run. - TOML is one front-end, header is another. Both produce the same context dict; the renderer doesn't know which one emitted it.
- Imported libraries become bindable. Drop a vendored
vendor/libfoo/foo.hinto your project;jm bindagainst it and you have Python access. (Names must follow the template contract; pure third-party headers usually don't, but a thin shim of convention-named declarations does.) - Composes with
--impl. A header bound byjm bindpairs naturally with--impl file::funcname(or--impl file::N:Mto lift a line range) to fill the_core.cbodies from existing C.
Acceptance¶
jm bind is shippable when:
- Each working preset's generated
_core.hcan be fed tojm bindand produce an_ext.cbyte-identical to (or semantically equivalent to) what the original scaffold emitted. jm bind --checkruns in CI for every bundled example and passes on every commit.- At least one bundled example uses
jm bindend-to-end — author_core.hand_core.cby hand, thenjm bindto materialise the binding.
After that: hand-import a small third-party C library (e.g. a single header from a DSP project) via a shim and use it from Python in one session. That's the validation that the contract is small enough to hit by hand.
Open questions¶
- Module shape vs. component shape.
jm bind <module>for a multi-component module — does it scan every<mod>_*_core.hinnative/inc/and emit the module aggregator? Probably yes; needs a flag to scope. - Test scaffolding. Should
jm bindalso synthesisetest_<comp>_core.cand the pytest? The headers already describe the surface; getter/setter round-trip tests fall out for free. Leaning yes; opt out with--no-tests. - Stability under future preset additions. Each new preset adds a
paragraph to the contract. Versioning the contract (a comment in
_core.hlike// jm-bind: contract-1) would letjm bindwarn when a header is using older conventions. blockwiseis out of scope. Array RETURN (T[] -> T[]) is not yet supported by the renderer — the CLI now errors cleanly on it rather than emitting a broken scaffold — so there is noblockwiseshape for bind to target. Array INPUT (arg-type T[]) works and binds fine.