Skip to content

Commit 6b79b03

Browse files
olwangclaude
andcommitted
rsscript: compact [adapter.*] whole-boundary native bindings
Closes the named P1-3 gap — "binding whole boundaries compactly without large wrapper files". native/bindings.rssbind.toml gains an [adapter.<Namespace>] section: one `crate` plus a `functions` list (and optional `rename` map) binds an entire native namespace, so a boundary with N functions no longer needs N [bindings] lines repeating the `Namespace.` prefix and `crate::` path. Expansion happens at manifest-load time in flatten_native_bindings, producing the exact same flat `symbol -> rust target` map every consumer already uses (Rust lowering, the VM shim via package_lowering_input, and the binding-conformance checks). Single expansion point, identical downstream data => zero VM/compiled parity risk. Every method stays listed by name so the boundary remains review-visible; duplicates across an adapter and explicit [bindings] are rejected. Unit-tested for expansion, rename override, explicit+adapter composition, duplicate rejection, and equivalence to the explicit form. Documented in package-manager design §9.5 and spec §20.1-N. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent b9432a3 commit 6b79b03

4 files changed

Lines changed: 196 additions & 10 deletions

File tree

crates/rsscript/src/package/native.rs

Lines changed: 149 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,70 @@ struct CargoMetadataTarget {
4545
struct NativeBindingsManifest {
4646
#[serde(default)]
4747
bindings: BTreeMap<String, String>,
48+
/// Compact whole-boundary binding: one `[adapter.<Namespace>]` section binds
49+
/// many `Namespace.method` native functions to `<crate>::<method>` without a
50+
/// per-function line. Expands into `bindings` at load time, so every
51+
/// downstream consumer (lowering, VM shim, conformance checks) sees the same
52+
/// flat map — there is no separate adapter code path.
53+
#[serde(default)]
54+
adapter: BTreeMap<String, AdapterBinding>,
55+
}
56+
57+
#[derive(Debug, Deserialize)]
58+
struct AdapterBinding {
59+
/// The Rust wrapper crate that owns every method in this boundary.
60+
#[serde(rename = "crate")]
61+
crate_name: String,
62+
/// Method names declared under the namespace (the part after the dot). Each
63+
/// `m` binds `Namespace.m` to `<crate>::m`.
64+
#[serde(default)]
65+
functions: Vec<String>,
66+
/// Optional per-method Rust name overrides for `m` whose Rust function name
67+
/// differs from the RSScript method name (`Namespace.m -> <crate>::<rename[m]>`).
68+
#[serde(default)]
69+
rename: BTreeMap<String, String>,
70+
}
71+
72+
/// Flatten a binding manifest into the canonical `symbol -> rust target` map,
73+
/// expanding any compact `[adapter.*]` sections. Errors on malformed adapters and
74+
/// on a symbol produced by more than one source (explicit binding or adapter), so
75+
/// the binding surface stays unambiguous for review.
76+
fn flatten_native_bindings(
77+
manifest: NativeBindingsManifest,
78+
) -> Result<BTreeMap<String, String>, String> {
79+
let mut flat = manifest.bindings;
80+
for (namespace, adapter) in manifest.adapter {
81+
let crate_name = adapter.crate_name.trim();
82+
if crate_name.is_empty() {
83+
return Err(format!(
84+
"adapter `{namespace}` must set a non-empty `crate`."
85+
));
86+
}
87+
if adapter.functions.is_empty() {
88+
return Err(format!(
89+
"adapter `{namespace}` lists no `functions`; add the method names it binds or remove the section."
90+
));
91+
}
92+
for method in &adapter.functions {
93+
let method = method.trim();
94+
if method.is_empty() {
95+
return Err(format!("adapter `{namespace}` has an empty function name."));
96+
}
97+
let symbol = format!("{namespace}.{method}");
98+
let rust_method = adapter
99+
.rename
100+
.get(method)
101+
.map(String::as_str)
102+
.unwrap_or(method);
103+
let target = format!("{crate_name}::{rust_method}");
104+
if flat.insert(symbol.clone(), target).is_some() {
105+
return Err(format!(
106+
"native binding `{symbol}` is defined twice (by `[adapter.{namespace}]` and an explicit `[bindings]` entry, or duplicated)."
107+
));
108+
}
109+
}
110+
}
111+
Ok(flat)
48112
}
49113

50114
pub(super) fn package_native_rust_dependencies(
@@ -190,7 +254,7 @@ pub(super) fn package_native_bindings(
190254
.map_err(|error| format!("failed to read {}: {error}", path.display()))?;
191255
let manifest: NativeBindingsManifest = toml::from_str(&source)
192256
.map_err(|error| format!("failed to parse {}: {error}", path.display()))?;
193-
Ok(manifest.bindings)
257+
flatten_native_bindings(manifest).map_err(|error| format!("{}: {error}", path.display()))
194258
}
195259

196260
pub(super) fn native_binding_interface_sources(
@@ -1035,3 +1099,87 @@ pub(super) fn manifest_native_unsafe_boundary(manifest: &Manifest) -> bool {
10351099
.and_then(|native| native.effective_unsafe_policy())
10361100
.is_some_and(|policy| policy != "forbid")
10371101
}
1102+
1103+
#[cfg(test)]
1104+
mod adapter_binding_tests {
1105+
use super::*;
1106+
1107+
fn flatten(toml_src: &str) -> Result<BTreeMap<String, String>, String> {
1108+
let manifest: NativeBindingsManifest = toml::from_str(toml_src).expect("manifest parses");
1109+
flatten_native_bindings(manifest)
1110+
}
1111+
1112+
#[test]
1113+
fn adapter_section_expands_to_per_method_bindings() {
1114+
let flat = flatten(
1115+
"[adapter.Rayon]\ncrate = \"rss_rayon_native\"\nfunctions = [\"sum_int\", \"sort_int\"]\n",
1116+
)
1117+
.expect("adapter expands");
1118+
assert_eq!(
1119+
flat.get("Rayon.sum_int").map(String::as_str),
1120+
Some("rss_rayon_native::sum_int")
1121+
);
1122+
assert_eq!(
1123+
flat.get("Rayon.sort_int").map(String::as_str),
1124+
Some("rss_rayon_native::sort_int")
1125+
);
1126+
assert_eq!(flat.len(), 2);
1127+
}
1128+
1129+
#[test]
1130+
fn adapter_form_matches_the_explicit_form() {
1131+
let compact = flatten(
1132+
"[adapter.Rayon]\ncrate = \"rss_rayon_native\"\nfunctions = [\"sum_int\", \"sort_int\"]\n",
1133+
)
1134+
.unwrap();
1135+
let explicit = flatten(
1136+
"[bindings]\n\"Rayon.sum_int\" = \"rss_rayon_native::sum_int\"\n\"Rayon.sort_int\" = \"rss_rayon_native::sort_int\"\n",
1137+
)
1138+
.unwrap();
1139+
assert_eq!(compact, explicit);
1140+
}
1141+
1142+
#[test]
1143+
fn rename_overrides_the_rust_method_name() {
1144+
let flat = flatten(
1145+
"[adapter.File]\ncrate = \"file_native\"\nfunctions = [\"open\"]\n\n[adapter.File.rename]\nopen = \"open_file\"\n",
1146+
)
1147+
.unwrap();
1148+
assert_eq!(
1149+
flat.get("File.open").map(String::as_str),
1150+
Some("file_native::open_file")
1151+
);
1152+
}
1153+
1154+
#[test]
1155+
fn explicit_bindings_and_adapters_compose() {
1156+
let flat = flatten(
1157+
"[bindings]\n\"Extra.ping\" = \"extra::ping\"\n\n[adapter.File]\ncrate = \"file_native\"\nfunctions = [\"open\"]\n",
1158+
)
1159+
.unwrap();
1160+
assert_eq!(flat.len(), 2);
1161+
assert!(flat.contains_key("Extra.ping"));
1162+
assert!(flat.contains_key("File.open"));
1163+
}
1164+
1165+
#[test]
1166+
fn duplicate_symbol_across_adapter_and_bindings_errors() {
1167+
let error = flatten(
1168+
"[bindings]\n\"File.open\" = \"file_native::open\"\n\n[adapter.File]\ncrate = \"file_native\"\nfunctions = [\"open\"]\n",
1169+
)
1170+
.expect_err("duplicate symbol should error");
1171+
assert!(error.contains("File.open"), "error: {error}");
1172+
}
1173+
1174+
#[test]
1175+
fn empty_crate_and_empty_functions_error() {
1176+
assert!(
1177+
flatten("[adapter.File]\ncrate = \"\"\nfunctions = [\"open\"]\n").is_err(),
1178+
"empty crate must error"
1179+
);
1180+
assert!(
1181+
flatten("[adapter.File]\ncrate = \"file_native\"\nfunctions = []\n").is_err(),
1182+
"empty functions must error"
1183+
);
1184+
}
1185+
}

docs/RSScript_Package_Manager_Design_v0.6.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1877,6 +1877,31 @@ A native wrapper package may provide:
18771877
"Json.JsonError" = "rss_json_native::JsonError"
18781878
```
18791879

1880+
A whole boundary that binds many functions of one namespace to a single Rust
1881+
wrapper crate can be declared compactly with an `[adapter.<Namespace>]` section
1882+
instead of one `[bindings]` line per function:
1883+
1884+
```toml
1885+
# native/bindings.rssbind.toml
1886+
1887+
[adapter.Json]
1888+
crate = "rss_json_native"
1889+
functions = ["parse", "field_string"]
1890+
1891+
# Per-method overrides when the Rust name differs from the RSScript method:
1892+
[adapter.Json.rename]
1893+
parse = "json_parse"
1894+
field_string = "json_field_string"
1895+
```
1896+
1897+
This expands at load time to exactly the `[bindings]` entries above
1898+
(`Json.parse -> rss_json_native::json_parse`, …), so lowering, the VM shim, and
1899+
all binding checks see the identical flat map — there is no separate adapter code
1900+
path. Every bound method is still listed by name, keeping the boundary
1901+
review-visible; only the repeated `Namespace.` prefix and `crate::` path are
1902+
factored out. A symbol defined by both an adapter and an explicit `[bindings]`
1903+
entry (or by two adapters) is rejected as a duplicate.
1904+
18801905
Package checks must reject:
18811906

18821907
```text

docs/RSScript_v0.7_Spec.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5416,10 +5416,12 @@ M. Module visibility and re-export hardening
54165416
54175417
N. Native boundary and ABI adapter contracts
54185418
- RSScript already declares native functions in .rssi with explicit unsafe and
5419-
native markers.
5420-
- future work: structured native adapter protocol (Rust adapter crate per
5421-
native dependency), tooling checks that bindings exist and match declared
5422-
contracts, dependency updates as review events with semantic diff.
5419+
native markers, groups them compactly with `native module`, and binds a whole
5420+
boundary in one `[adapter.<Namespace>]` manifest section (package-manager
5421+
§9.5) that expands to per-function bindings without a separate code path.
5422+
- future work: deeper structured native adapter protocol (Rust adapter crate
5423+
per native dependency), broader tooling checks that bindings exist and match
5424+
declared contracts, dependency updates as review events with semantic diff.
54235425
- not adopted: general FFI, C header parsing, automatic binding generation
54245426
without audit, or direct pointer manipulation in RSScript source.
54255427
```

docs/spec-todo.md

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -44,11 +44,22 @@ actual driver (see "Basis" at the bottom).
4444
come up; (b) a persistent **LSP-protocol daemon** (stdio) for editors — `rss ide`
4545
is request/response, not a long-running server. Track as a follow-up.
4646

47-
- [ ] **FFI / native-ABI adapter contracts** (§3.2 general FFI, §20.1-N) —
48-
_effort: medium–large, open-ended._
49-
`native fn` declares external boundaries today; the gap is binding *whole*
50-
runtime/autogen/device boundaries compactly without large wrapper files, plus
51-
ABI-adapter conformance facts. Needs a concrete target boundary to scope "done."
47+
- [x] **FFI / native-ABI adapter contracts** (§3.2 general FFI, §20.1-N) —
48+
_compact whole-boundary binding done; deeper adapter protocol remains._
49+
The FFI surface was already mature (`native fn`, `native module` grouping,
50+
transitive binding inheritance, unbound-call error at lowering, `rss native
51+
audit`, review capability classification). Closed the named gap — "binding whole
52+
boundaries compactly without large wrapper files" — with an
53+
`[adapter.<Namespace>]` section in `native/bindings.rssbind.toml` (package-mgr
54+
§9.5): one `crate` + a `functions` list (plus optional `rename`) binds a whole
55+
namespace, expanding at load time into the same flat `symbol -> target` map every
56+
consumer already uses (lowering, VM shim, conformance checks) — **zero parity
57+
risk, single expansion point** (`flatten_native_bindings`). Duplicates across an
58+
adapter and explicit `[bindings]` are rejected. Unit-tested for expansion,
59+
rename, composition, and equivalence to the explicit form.
60+
_Remaining (deferred, §20.1-N):_ deeper structured adapter protocol (Rust adapter
61+
crate per dep), broader binding-conformance facts, dependency updates as semantic
62+
review events. General FFI / C-header parsing / auto-binding stay non-goals.
5263

5364
## P2 — real features, not blocking today
5465

0 commit comments

Comments
 (0)