mirror of
https://github.com/zap-proto/zip.git
synced 2026-10-19 15:38:57 +00:00
A Rust developer writes Rust handlers, with Rust types and Rust doc comments,
and gets every projection from that source — the same deal a Go developer has
had. No .zap is written by hand and no Go program declares a Rust service's
ops.
The split falls on a measured seam. What is native per language is DECLARING an
op and RUNNING it; what is shared is the projection logic, driven from a neutral
description of the registry.
Manifest the registry with the Go taken out: ops, types, fields, wire
names, prose. App.Manifest fills one in by reflection; a
proc-macro fills one in as a Rust crate compiles.
Project* the OpenAPI document, the MCP tool list, the CLI and the ZAP
IDL, read off a Manifest. Go's own three now go through them —
the whole existing suite is the oracle — so Go keeps no private
path for them and the other languages cannot rot.
cmd/zipc the build-time projector. It reads a manifest, or the fragments
a compiler wrote, and writes the four documents.
rust/ zip in Rust: #[zip::ops] on an impl block, #[derive(zip::Wire)]
on a type, a router, a JSON binder, an HTTP door and a ZAP door.
The wire is generated by zapgen from wire.zap; nothing here
spells a byte offset by hand and nothing links Go.
The layout rule moved out of the manifest and into the projector, so the offsets
in a .zap are derived once for all three languages rather than three times;
TestProjectZAPMatchesRegistry pins that derivation against the layout the Go
encoder actually writes.
One service, written twice — corpus/info in Go and rust/examples/info in Rust,
fourteen ops each — is the gate. The OpenAPI document, the MCP tool list and the
CLI command tree come out byte-identical, and the ZAP schema agrees on every
struct, type and offset; it differs only where a field carries the declaring
language's own case convention and where the ledger names a type the way the
person who must change it would grep for it.
157 lines
4.6 KiB
Go
157 lines
4.6 KiB
Go
// SPDX-License-Identifier: BSD-3-Clause-Eco
|
|
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
package zip
|
|
|
|
import (
|
|
"strings"
|
|
)
|
|
|
|
// Where a field sits, derived once for every language.
|
|
//
|
|
// A ZAP field IS an offset and a width, and the rule that assigns them is the
|
|
// same rule wherever the type was declared: walk the fields in declaration
|
|
// order, align each to its own width, and round the fixed section up to eight.
|
|
// Go's encoder derives it at run time from a reflect.Type (internal/zapenc), and
|
|
// it has to, because a Go value is encoded reflectively. Nothing else needs to:
|
|
// a description already says what each field is, so the offsets follow from the
|
|
// description alone.
|
|
//
|
|
// So the rule lives HERE, once, and the .zap a Rust service publishes is laid
|
|
// out by the same code that lays out a Go one. That the two agree with the Go
|
|
// ENCODER is not assumed either — TestProjectZAPMatchesRegistry compares this
|
|
// against the layout zapenc computes over the same types, which is the only way
|
|
// a schema and the bytes it describes stay one thing.
|
|
|
|
// slot is one field's place in the fixed section.
|
|
type slot struct {
|
|
at int
|
|
repr string // the IDL's spelling: bool, u32, text, bytes, list<...>, bytes_fixed[N]
|
|
elem string // a list's element spelling
|
|
}
|
|
|
|
// layout is a struct's fixed section: one slot per field, in declaration order.
|
|
type layoutOut struct {
|
|
slots []slot
|
|
size int
|
|
}
|
|
|
|
// layout assigns td's fields their slots, or names the first field that has no
|
|
// fixed form at all.
|
|
func (p *projector) layout(td *TypeDesc, inside map[string]bool) (layoutOut, bool) {
|
|
if inside[td.ID] {
|
|
return layoutOut{}, false // a field is an offset and a width; a recursive type has none.
|
|
}
|
|
inside[td.ID] = true
|
|
defer delete(inside, td.ID)
|
|
|
|
out := layoutOut{}
|
|
at := 0
|
|
for _, f := range td.Fields {
|
|
s, ok := p.slotOf(f.Type, inside)
|
|
if !ok {
|
|
return layoutOut{}, false
|
|
}
|
|
w := slotWidth(s.repr)
|
|
a := w
|
|
if n, fixed := fixedLen(s.repr); fixed {
|
|
a, w = 1, n
|
|
}
|
|
at = (at + a - 1) &^ (a - 1)
|
|
s.at = at
|
|
at += w
|
|
out.slots = append(out.slots, s)
|
|
}
|
|
out.size = (at + 7) &^ 7
|
|
return out, true
|
|
}
|
|
|
|
// slotOf is one field's spelling and whether it has a fixed form at all.
|
|
func (p *projector) slotOf(r TypeRef, inside map[string]bool) (slot, bool) {
|
|
switch {
|
|
case r.Map != nil:
|
|
return slot{}, false // no map in the IDL: a key set is not a layout.
|
|
case r.List != nil:
|
|
if r.Len > 0 {
|
|
// A fixed-length list of anything but bytes has no wire form: an
|
|
// element is one value at one width, and a run of them inline is not
|
|
// a slot this layout has.
|
|
return slot{}, false
|
|
}
|
|
e, ok := p.elemOf(*r.List, inside)
|
|
if !ok {
|
|
return slot{}, false
|
|
}
|
|
return slot{repr: "list<" + e + ">", elem: e}, true
|
|
case r.Ref != "":
|
|
td := p.types[r.Ref]
|
|
if td == nil {
|
|
return slot{}, false
|
|
}
|
|
switch td.Kind {
|
|
case "struct":
|
|
// A nested value crosses as a complete message in an {offset,length}
|
|
// slot, so it is `bytes` here — and only if it lays out at all.
|
|
if _, ok := p.layout(td, inside); !ok {
|
|
return slot{}, false
|
|
}
|
|
return slot{repr: "bytes"}, true
|
|
case "list":
|
|
if td.Len > 0 {
|
|
return slot{}, false
|
|
}
|
|
e, ok := p.elemOf(*td.Elem, inside)
|
|
if !ok {
|
|
return slot{}, false
|
|
}
|
|
return slot{repr: "list<" + e + ">", elem: e}, true
|
|
case "map":
|
|
return slot{}, false
|
|
}
|
|
return primSlot(td.Repr)
|
|
}
|
|
return primSlot(r.Prim)
|
|
}
|
|
|
|
// elemOf is a list element's spelling. A list of lists has no form: the element
|
|
// of a list is one value at one width, and a list is neither.
|
|
func (p *projector) elemOf(r TypeRef, inside map[string]bool) (string, bool) {
|
|
s, ok := p.slotOf(r, inside)
|
|
if !ok || strings.HasPrefix(s.repr, "list<") {
|
|
return "", false
|
|
}
|
|
return s.repr, true
|
|
}
|
|
|
|
// primSlot is a primitive's spelling, or a refusal for one that has none.
|
|
func primSlot(prim string) (slot, bool) {
|
|
switch prim {
|
|
case "bool", "i8", "i16", "i32", "i64", "u8", "u16", "u32", "u64", "f32", "f64", "bytes", "text":
|
|
return slot{repr: prim}, true
|
|
case "string":
|
|
return slot{repr: "text"}, true
|
|
}
|
|
if _, ok := fixedLen(prim); ok {
|
|
return slot{repr: prim}, true
|
|
}
|
|
return slot{}, false
|
|
}
|
|
|
|
// slotWidth is the fixed-area size of one spelling. Everything variable — text,
|
|
// bytes, a list, a nested value — carries an offset and a length.
|
|
func slotWidth(repr string) int {
|
|
switch {
|
|
case repr == "bool" || repr == "i8" || repr == "u8":
|
|
return 1
|
|
case repr == "i16" || repr == "u16":
|
|
return 2
|
|
case repr == "i32" || repr == "u32" || repr == "f32":
|
|
return 4
|
|
}
|
|
if n, ok := fixedLen(repr); ok {
|
|
return n
|
|
}
|
|
return 8
|
|
}
|