Skip to content

Commit 1b10739

Browse files
committed
feat(host-api): add structured result schemas
1 parent 4b74986 commit 1b10739

6 files changed

Lines changed: 452 additions & 9 deletions

File tree

src/compiler/host_call_resolve.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -931,6 +931,14 @@ fn schema_label(schema: &crate::host_api::HostTypeSchema) -> String {
931931
HostTypeSchema::Bytes => "bytes".to_string(),
932932
HostTypeSchema::Array(inner) => format!("array<{}>", schema_label(inner)),
933933
HostTypeSchema::Map(inner) => format!("map<{}>", schema_label(inner)),
934+
HostTypeSchema::Object(fields) => {
935+
let fields = fields
936+
.iter()
937+
.map(|(name, schema)| format!("{name}: {}", schema_label(schema)))
938+
.collect::<Vec<_>>()
939+
.join(", ");
940+
format!("{{{fields}}}")
941+
}
934942
HostTypeSchema::Optional(inner) => format!("optional<{}>", schema_label(inner)),
935943
HostTypeSchema::Callable { params, result } => {
936944
let params = params

src/compiler/host_conversion.rs

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,12 @@ impl HostTypeSchema {
4949
HostTypeSchema::Bytes => TypeSchema::Bytes,
5050
HostTypeSchema::Array(inner) => TypeSchema::Array(Box::new(inner.to_compiler_schema())),
5151
HostTypeSchema::Map(inner) => TypeSchema::Map(Box::new(inner.to_compiler_schema())),
52+
HostTypeSchema::Object(fields) => TypeSchema::Object(
53+
fields
54+
.iter()
55+
.map(|(name, schema)| (name.clone(), schema.to_compiler_schema()))
56+
.collect(),
57+
),
5258
HostTypeSchema::Optional(inner) => {
5359
TypeSchema::Optional(Box::new(inner.to_compiler_schema()))
5460
}
@@ -86,9 +92,13 @@ pub(crate) fn to_host_schema(schema: &TypeSchema) -> HostTypeSchema {
8692
HostTypeSchema::Array(Box::new(to_host_schema(rest)))
8793
}
8894
TypeSchema::Map(inner) => HostTypeSchema::Map(Box::new(to_host_schema(inner))),
89-
TypeSchema::Object(_) | TypeSchema::Named(_, _) => {
90-
HostTypeSchema::Map(Box::new(HostTypeSchema::Unknown))
91-
}
95+
TypeSchema::Object(fields) => HostTypeSchema::Object(
96+
fields
97+
.iter()
98+
.map(|(name, schema)| (name.clone(), to_host_schema(schema)))
99+
.collect(),
100+
),
101+
TypeSchema::Named(_, _) => HostTypeSchema::Map(Box::new(HostTypeSchema::Unknown)),
92102
TypeSchema::Callable { params, result } => HostTypeSchema::Callable {
93103
params: params.iter().map(to_host_schema).collect(),
94104
result: Box::new(to_host_schema(result)),
@@ -99,7 +109,10 @@ pub(crate) fn to_host_schema(schema: &TypeSchema) -> HostTypeSchema {
99109

100110
#[cfg(test)]
101111
mod tests {
112+
use std::collections::{BTreeMap, HashMap};
113+
102114
use super::super::TypeSchema;
115+
use super::to_host_schema;
103116
use crate::host_api::HostTypeSchema;
104117
use crate::host_api::ResourceTypeKey;
105118

@@ -161,6 +174,27 @@ mod tests {
161174
);
162175
}
163176

177+
#[test]
178+
fn object_schema_round_trips_through_compiler_schema() {
179+
let host = HostTypeSchema::Object(BTreeMap::from([
180+
(
181+
"error".to_string(),
182+
HostTypeSchema::Optional(Box::new(HostTypeSchema::String)),
183+
),
184+
("ok".to_string(), HostTypeSchema::Bool),
185+
]));
186+
let compiler = TypeSchema::Object(HashMap::from([
187+
(
188+
"error".to_string(),
189+
TypeSchema::Optional(Box::new(TypeSchema::String)),
190+
),
191+
("ok".to_string(), TypeSchema::Bool),
192+
]));
193+
194+
assert_eq!(host.to_compiler_schema(), compiler);
195+
assert_eq!(to_host_schema(&compiler), host);
196+
}
197+
164198
#[test]
165199
fn to_compiler_schema_scalars_are_direct() {
166200
assert_eq!(

src/compiler/semantic_model.rs

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -660,7 +660,14 @@ impl SemanticModel {
660660
TypeSchema::ArrayTupleRest { prefix: _, rest: _ } => {
661661
HostTypeSchema::Array(Box::new(HostTypeSchema::Unknown))
662662
}
663-
TypeSchema::Object(_) => HostTypeSchema::Map(Box::new(HostTypeSchema::Unknown)),
663+
TypeSchema::Object(fields) => HostTypeSchema::Object(
664+
fields
665+
.iter()
666+
.map(|(name, schema)| {
667+
(name.clone(), self.compiler_schema_to_host_schema(schema))
668+
})
669+
.collect(),
670+
),
664671
}
665672
}
666673

@@ -1730,6 +1737,8 @@ fn normalize_module_path(path: std::path::PathBuf) -> std::path::PathBuf {
17301737

17311738
#[cfg(test)]
17321739
mod tests {
1740+
use std::collections::{BTreeMap, HashMap};
1741+
17331742
use super::*;
17341743
use crate::compiler::ir::{
17351744
Expr, LocalDeclSite, ParsedCallSite, ParsedCallTarget, ParsedLexicalScope,
@@ -2123,6 +2132,29 @@ mod tests {
21232132
// Signature help
21242133
// ------------------------------------------------------------------
21252134

2135+
#[test]
2136+
fn compiler_schema_to_host_schema_preserves_object_fields() {
2137+
let model = SemanticModel::new(test_ir(), SourceMap::new(), test_catalog(), Vec::new());
2138+
let compiler = TypeSchema::Object(HashMap::from([
2139+
(
2140+
"error".to_string(),
2141+
TypeSchema::Optional(Box::new(TypeSchema::String)),
2142+
),
2143+
("ok".to_string(), TypeSchema::Bool),
2144+
]));
2145+
2146+
assert_eq!(
2147+
model.compiler_schema_to_host_schema(&compiler),
2148+
HostTypeSchema::Object(BTreeMap::from([
2149+
(
2150+
"error".to_string(),
2151+
HostTypeSchema::Optional(Box::new(HostTypeSchema::String)),
2152+
),
2153+
("ok".to_string(), HostTypeSchema::Bool),
2154+
]))
2155+
);
2156+
}
2157+
21262158
#[test]
21272159
fn callable_signature_with_no_calls_returns_none() {
21282160
let catalog = test_catalog();

src/host_api.rs

Lines changed: 166 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
//! an attacker can influence catalog bytes. Treat `HostApiFingerprint` as a
4848
//! convenience equality key, not a MAC.
4949
50+
use std::collections::BTreeMap;
5051
use std::fmt;
5152

5253
use serde::de::{self, DeserializeSeed, MapAccess, SeqAccess, VariantAccess, Visitor};
@@ -87,6 +88,8 @@ pub const MAX_HOST_CATALOG_FUNCTIONS: usize = 1_024;
8788

8889
/// Maximum byte length of names on host parameter records.
8990
pub const MAX_HOST_PARAMETER_NAME_LEN: usize = 128;
91+
/// Maximum byte length of fixed-shape host object field names.
92+
pub const MAX_HOST_OBJECT_FIELD_NAME_LEN: usize = 128;
9093

9194
/// Maximum byte length of host resource/function documentation.
9295
pub const MAX_HOST_DESCRIPTION_LEN: usize = 4_096;
@@ -355,6 +358,10 @@ pub enum HostTypeSchema {
355358
Bytes,
356359
Array(Box<HostTypeSchema>),
357360
Map(Box<HostTypeSchema>),
361+
/// A fixed-shape record whose field names and value schemas are known.
362+
/// Runtime values use the ordinary map carrier, while compilers retain the
363+
/// structural field types for checked member access.
364+
Object(BTreeMap<String, HostTypeSchema>),
358365
Optional(Box<HostTypeSchema>),
359366
Callable {
360367
params: Vec<HostTypeSchema>,
@@ -425,6 +432,9 @@ impl Serialize for HostTypeSchema {
425432
Self::Map(inner) => {
426433
serializer.serialize_newtype_variant("HostTypeSchema", 9, "Map", inner)
427434
}
435+
Self::Object(fields) => {
436+
serializer.serialize_newtype_variant("HostTypeSchema", 13, "Object", fields)
437+
}
428438
Self::Optional(inner) => {
429439
serializer.serialize_newtype_variant("HostTypeSchema", 10, "Optional", inner)
430440
}
@@ -457,6 +467,7 @@ enum HostTypeSchemaVariant {
457467
Optional,
458468
Callable,
459469
Resource,
470+
Object,
460471
}
461472

462473
struct HostTypeSchemaSeed<'a> {
@@ -489,7 +500,7 @@ impl<'de> DeserializeSeed<'de> for HostTypeSchemaSeed<'_> {
489500
"HostTypeSchema",
490501
&[
491502
"Unknown", "Null", "Int", "Float", "Number", "Bool", "String", "Bytes", "Array",
492-
"Map", "Optional", "Callable", "Resource",
503+
"Map", "Optional", "Callable", "Resource", "Object",
493504
],
494505
HostTypeSchemaVisitor {
495506
budget: self.budget,
@@ -547,6 +558,15 @@ impl<'de> Visitor<'de> for HostTypeSchemaVisitor<'_> {
547558
})
548559
.map(|inner| HostTypeSchema::Map(Box::new(inner)))
549560
}
561+
HostTypeSchemaVariant::Object => {
562+
let depth = next_schema_depth::<A::Error>(self.depth)?;
563+
access
564+
.newtype_variant_seed(ObjectSchemaSeed {
565+
budget: self.budget,
566+
depth,
567+
})
568+
.map(HostTypeSchema::Object)
569+
}
550570
HostTypeSchemaVariant::Optional => {
551571
let depth = next_schema_depth::<A::Error>(self.depth)?;
552572
access
@@ -570,6 +590,67 @@ impl<'de> Visitor<'de> for HostTypeSchemaVisitor<'_> {
570590
}
571591
}
572592

593+
struct ObjectSchemaSeed<'a> {
594+
budget: &'a mut ComplexityBudget,
595+
depth: usize,
596+
}
597+
598+
impl<'de> DeserializeSeed<'de> for ObjectSchemaSeed<'_> {
599+
type Value = BTreeMap<String, HostTypeSchema>;
600+
601+
fn deserialize<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
602+
where
603+
D: serde::Deserializer<'de>,
604+
{
605+
deserializer.deserialize_map(ObjectSchemaVisitor {
606+
budget: self.budget,
607+
depth: self.depth,
608+
})
609+
}
610+
}
611+
612+
struct ObjectSchemaVisitor<'a> {
613+
budget: &'a mut ComplexityBudget,
614+
depth: usize,
615+
}
616+
617+
impl<'de> Visitor<'de> for ObjectSchemaVisitor<'_> {
618+
type Value = BTreeMap<String, HostTypeSchema>;
619+
620+
fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
621+
formatter.write_str("a bounded fixed-field object schema")
622+
}
623+
624+
fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
625+
where
626+
A: MapAccess<'de>,
627+
{
628+
bounded_map_size_hint(
629+
map.size_hint(),
630+
"object schema",
631+
MAX_HOST_SCHEMA_PROPERTIES - self.budget.properties,
632+
)?;
633+
let mut fields = BTreeMap::new();
634+
while let Some(name) = map.next_key_seed(BoundedStringSeed {
635+
field: "host object field name",
636+
limit: MAX_HOST_OBJECT_FIELD_NAME_LEN,
637+
})? {
638+
if fields.contains_key(&name) {
639+
return Err(de::Error::custom(format!(
640+
"duplicate object field {name:?}"
641+
)));
642+
}
643+
let schema = map.next_value_seed(HostTypeSchemaSeed {
644+
budget: self.budget,
645+
depth: self.depth,
646+
property: true,
647+
})?;
648+
fields.insert(name, schema);
649+
}
650+
Ok(fields)
651+
}
652+
}
653+
573654
fn next_schema_depth<E>(depth: usize) -> Result<usize, E>
574655
where
575656
E: de::Error,
@@ -761,6 +842,16 @@ impl fmt::Display for HostTypeSchema {
761842
Self::Bytes => write!(f, "bytes"),
762843
Self::Array(inner) => write!(f, "array<{inner}>"),
763844
Self::Map(inner) => write!(f, "map<{inner}>"),
845+
Self::Object(fields) => {
846+
write!(f, "{{")?;
847+
for (index, (name, schema)) in fields.iter().enumerate() {
848+
if index > 0 {
849+
write!(f, ", ")?;
850+
}
851+
write!(f, "{name}: {schema}")?;
852+
}
853+
write!(f, "}}")
854+
}
764855
Self::Optional(inner) => write!(f, "optional<{inner}>"),
765856
Self::Callable { params, result } => {
766857
write!(f, "fn(")?;
@@ -2059,6 +2150,28 @@ where
20592150
})?;
20602151
pending.push((inner, child_depth));
20612152
}
2153+
HostTypeSchema::Object(fields) => {
2154+
budget.charge_properties(fields.len())?;
2155+
let child_depth =
2156+
depth
2157+
.checked_add(1)
2158+
.ok_or(HostSchemaValidationError::IntegerOverflow {
2159+
field: "schema depth",
2160+
})?;
2161+
pending.try_reserve(fields.len()).map_err(|_| {
2162+
HostSchemaValidationError::AllocationFailed {
2163+
field: "schema traversal",
2164+
}
2165+
})?;
2166+
for (name, schema) in fields.iter().rev() {
2167+
bounded_string_error(
2168+
"host object field name",
2169+
name.len(),
2170+
MAX_HOST_OBJECT_FIELD_NAME_LEN,
2171+
)?;
2172+
pending.push((schema, child_depth));
2173+
}
2174+
}
20622175
HostTypeSchema::Callable { params, result } => {
20632176
budget.charge_properties(params.len())?;
20642177
let child_depth =
@@ -3003,6 +3116,14 @@ fn try_push_type(
30033116
})?;
30043117
pending.push(inner);
30053118
}
3119+
HostTypeSchema::Object(fields) => {
3120+
push_tag(bytes, b'o');
3121+
push_len(bytes, fields.len())?;
3122+
for (name, schema) in fields {
3123+
push_len_str(bytes, name)?;
3124+
try_push_type(bytes, schema)?;
3125+
}
3126+
}
30063127
HostTypeSchema::Optional(inner) => {
30073128
push_tag(bytes, b'?');
30083129
pending.try_reserve(1).map_err(|_| {
@@ -3074,9 +3195,53 @@ fn fnv1a(bytes: &[u8]) -> u64 {
30743195
}
30753196
#[cfg(test)]
30763197
mod tests {
3198+
use std::collections::BTreeMap;
3199+
30773200
use super::*;
30783201
use serde_json::json;
30793202

3203+
#[test]
3204+
fn object_schema_rejects_oversized_field_names() {
3205+
let field = "x".repeat(MAX_HOST_OBJECT_FIELD_NAME_LEN + 1);
3206+
let schema = HostTypeSchema::Object(BTreeMap::from([(field, HostTypeSchema::Bool)]));
3207+
3208+
assert_eq!(
3209+
schema.validate(),
3210+
Err(HostSchemaValidationError::StringTooLong {
3211+
field: "host object field name",
3212+
len: MAX_HOST_OBJECT_FIELD_NAME_LEN + 1,
3213+
limit: MAX_HOST_OBJECT_FIELD_NAME_LEN,
3214+
})
3215+
);
3216+
}
3217+
3218+
#[test]
3219+
fn object_schema_serde_display_and_resource_walk_are_structural() {
3220+
let schema = HostTypeSchema::Object(BTreeMap::from([
3221+
(
3222+
"handle".to_string(),
3223+
HostTypeSchema::Optional(Box::new(HostTypeSchema::Resource(io_file_key()))),
3224+
),
3225+
("ok".to_string(), HostTypeSchema::Bool),
3226+
]));
3227+
3228+
let encoded = serde_json::to_value(&schema).expect("serialize object schema");
3229+
let decoded: HostTypeSchema =
3230+
serde_json::from_value(encoded).expect("deserialize object schema");
3231+
3232+
assert_eq!(decoded, schema);
3233+
assert_eq!(
3234+
schema.to_string(),
3235+
"{handle: optional<resource<io.file>>, ok: bool}"
3236+
);
3237+
assert!(schema.contains_resource());
3238+
assert_eq!(schema.resource_key(), None);
3239+
let mut keys = Vec::new();
3240+
schema.collect_resource_keys(&mut keys);
3241+
let expected_key = io_file_key();
3242+
assert_eq!(keys, vec![&expected_key]);
3243+
}
3244+
30803245
fn io_file_key() -> ResourceTypeKey {
30813246
ResourceTypeKey::new("io.file").expect("valid key")
30823247
}

0 commit comments

Comments
 (0)