From 5609ac37b35a1eeeb8645ad4f61823fd21fffe58 Mon Sep 17 00:00:00 2001 From: rossirpaulo Date: Tue, 7 Jul 2026 13:04:45 -0700 Subject: [PATCH 1/9] feat(playground): extract function param schemas in baml_project ParamSchema/FieldSchema tree for the dynamic args form: inline-expanded class/enum bodies (cycle-guarded), nullable-union folding, alias resolution, dependency-package lookup, Unsupported fallback for non-data types and TIR sentinels. Class/enum names are the canonical dotted FQN the engine registers (user.shapes.Foo) so $baml markers round-trip. --- baml_language/Cargo.lock | 1 + baml_language/crates/baml_project/Cargo.toml | 1 + baml_language/crates/baml_project/src/lib.rs | 2 + .../crates/baml_project/src/param_schema.rs | 537 ++++++++++++++++++ .../crates/baml_project/src/symbols.rs | 9 +- 5 files changed, 549 insertions(+), 1 deletion(-) create mode 100644 baml_language/crates/baml_project/src/param_schema.rs diff --git a/baml_language/Cargo.lock b/baml_language/Cargo.lock index 9856901a822..72e8555d8a7 100644 --- a/baml_language/Cargo.lock +++ b/baml_language/Cargo.lock @@ -1087,6 +1087,7 @@ dependencies = [ "lsp-types", "salsa", "serde", + "serde_json", "text-size", ] diff --git a/baml_language/crates/baml_project/Cargo.toml b/baml_language/crates/baml_project/Cargo.toml index e7e0f6b40a7..808148522d9 100644 --- a/baml_language/crates/baml_project/Cargo.toml +++ b/baml_language/crates/baml_project/Cargo.toml @@ -36,6 +36,7 @@ la-arena = { workspace = true } lsp-types = { workspace = true } salsa = { workspace = true } serde = { workspace = true, features = [ "derive" ] } +serde_json = { workspace = true } text-size = { workspace = true } [features] diff --git a/baml_language/crates/baml_project/src/lib.rs b/baml_language/crates/baml_project/src/lib.rs index 58281d454ca..d0b52d5716b 100644 --- a/baml_language/crates/baml_project/src/lib.rs +++ b/baml_language/crates/baml_project/src/lib.rs @@ -25,6 +25,7 @@ mod check; mod client_codegen; mod db; +pub mod param_schema; pub mod position; pub mod symbols; #[cfg(feature = "testing")] @@ -33,6 +34,7 @@ pub mod testing; pub use check::{CheckResult, collect_compiler2_diagnostics, collect_diagnostics}; pub use client_codegen::build_symbol_pool; pub use db::{CursorContext, EventCallback, ProjectDatabase}; +pub use param_schema::{FieldSchema, FieldSchemaField, ParamSchema}; pub use symbols::{ FunctionOrigin, FunctionSymbol, Symbol, SymbolKind, TestSymbol, list_functions, list_functions_with_metadata, list_tests_with_metadata, diff --git a/baml_language/crates/baml_project/src/param_schema.rs b/baml_language/crates/baml_project/src/param_schema.rs new file mode 100644 index 00000000000..00790a62f84 --- /dev/null +++ b/baml_language/crates/baml_project/src/param_schema.rs @@ -0,0 +1,537 @@ +//! Function-parameter schemas for the playground's dynamic args form. +//! +//! Converts a function's resolved parameter types ([`Ty`]) into a +//! self-contained, serializable [`FieldSchema`] tree: class fields and enum +//! variants are expanded inline (with a cycle guard — recursive types are +//! legal through optional/list/map positions), nullable unions fold into +//! `Optional`, type aliases resolve before dispatch, and every non-data +//! variant (functions, interfaces, type variables, TIR sentinels, …) degrades +//! to `Unsupported` rather than erroring: users hold invalid intermediate +//! states constantly while editing. +//! +//! Class and enum `name`s are the canonical dotted FQN the engine registers +//! and emits (`user.shapes.Foo` — [`QualifiedTypeName::render_dotted`] with +//! `user_facing = false`), so a `$baml: { type: name }` marker built from a +//! schema round-trips through the args wire protocol unchanged. + +use baml_compiler2_hir::package::PackageId; +use baml_compiler2_tir::{ + package_interface::{ExportedType, PackageInterface, package_interface}, + ty::{FunctionParamMode, LiteralValue, QualifiedTypeName, Ty}, +}; +use baml_db::Name; +use serde::Serialize; + +use crate::db::ProjectDatabase; + +/// Guards runaway recursion through pathological (but legal) deeply-nested +/// types. Same bound as TIR's `CycleDetector`. +const MAX_DEPTH: usize = 64; + +/// Schema for one function parameter. +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ParamSchema { + pub name: String, + /// Whether the parameter has a default value and can be omitted entirely + /// ([`FunctionParamMode::Optional`]). Distinct from a nullable type, which + /// shows up as [`FieldSchema::Optional`] in `schema`. + pub has_default: bool, + pub schema: FieldSchema, +} + +/// One class field; optionality is folded into `schema` as +/// [`FieldSchema::Optional`], not a flag here. +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct FieldSchemaField { + pub name: String, + pub schema: FieldSchema, +} + +/// A self-contained, recursively-expanded type schema for form rendering. +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum FieldSchema { + String, + Int, + Float, + Bool, + Null, + Bigint, + /// `kind` is the BEP-038 tag (`image` / `audio` / `video` / `pdf` / + /// `media`); rendered as a raw-JSON field for now. + Media { + kind: String, + }, + /// A literal type — the parameter admits exactly this value. + Literal { + value: serde_json::Value, + }, + Enum { + name: String, + values: Vec, + }, + Class { + name: String, + /// Empty when `recursive` — the cycle guard stops inline expansion on + /// a repeated class; the form falls back to raw JSON for it. + fields: Vec, + recursive: bool, + }, + List { + item: Box, + }, + Map { + key: Box, + value: Box, + }, + /// A nullable union (`T?` / `T | null`), folded to its non-null payload. + Optional { + inner: Box, + }, + Union { + variants: Vec, + }, + /// Anything the form cannot render as a typed widget; `display` is the + /// user-facing type string for labeling the raw-JSON fallback field. + Unsupported { + display: String, + }, +} + +/// Extract parameter schemas for the function `name` in `namespace_path` of +/// the user package. `None` means the function is missing from the package +/// interface (mid-edit inconsistency) — the UI treats it as "no schema +/// available", which is distinct from `Some(vec![])` ("takes no arguments"). +pub(crate) fn function_param_schemas( + db: &ProjectDatabase, + iface: &PackageInterface, + namespace_path: &[Name], + name: &Name, +) -> Option> { + let func = iface.lookup_function(namespace_path, name)?; + let cx = SchemaCx { + db, + user_iface: iface, + }; + let params = func + .params + .iter() + .enumerate() + .map(|(i, param)| ParamSchema { + name: param + .name + .as_ref() + .map_or_else(|| format!("arg{i}"), ToString::to_string), + has_default: matches!(param.mode, FunctionParamMode::Optional), + schema: cx.field_schema(¶m.ty, &mut Vec::new(), 0), + }) + .collect(); + Some(params) +} + +struct SchemaCx<'db> { + db: &'db ProjectDatabase, + user_iface: &'db PackageInterface, +} + +impl<'db> SchemaCx<'db> { + /// Resolve a type name to its exported definition: own-package types via + /// the user interface, dependency types (stdlib/builtins) via that + /// package's own Salsa-cached interface. A miss is expected mid-edit and + /// for undeclared packages — callers degrade to `Unsupported`. + fn lookup_type(&self, qtn: &QualifiedTypeName) -> Option<&'db ExportedType> { + if qtn.is_local() { + self.user_iface.lookup_type(qtn.namespace(), qtn.name()) + } else { + let pkg_id = PackageId::new(self.db, qtn.package().clone()); + package_interface(self.db, pkg_id).lookup_type(qtn.namespace(), qtn.name()) + } + } + + /// `path` holds the class/alias names currently being expanded (cycle + /// guard); `depth` counts every recursion step (structural nesting too). + fn field_schema( + &self, + ty: &Ty, + path: &mut Vec, + depth: usize, + ) -> FieldSchema { + if depth >= MAX_DEPTH { + return unsupported(ty); + } + match ty { + Ty::String { .. } => FieldSchema::String, + Ty::Int { .. } => FieldSchema::Int, + Ty::Float { .. } => FieldSchema::Float, + Ty::Bool { .. } => FieldSchema::Bool, + Ty::Null { .. } => FieldSchema::Null, + Ty::Bigint { .. } => FieldSchema::Bigint, + Ty::Media(kind, _) => FieldSchema::Media { + kind: kind.tag_str().to_string(), + }, + Ty::Literal(lit, _, _) => match literal_value(lit) { + Some(value) => FieldSchema::Literal { value }, + None => unsupported(ty), + }, + Ty::Enum(qtn, _) => match self.lookup_type(qtn) { + Some(ExportedType::Enum { variants, .. }) => FieldSchema::Enum { + name: qtn.render_dotted(false), + values: variants.iter().map(ToString::to_string).collect(), + }, + _ => unsupported(ty), + }, + // A specific-variant type (`s: Status.Active`): a single-value + // enum, so the form still emits a real enum wire value. + Ty::EnumVariant(qtn, variant, _) => match self.lookup_type(qtn) { + Some(ExportedType::Enum { .. }) => FieldSchema::Enum { + name: qtn.render_dotted(false), + values: vec![variant.to_string()], + }, + _ => unsupported(ty), + }, + Ty::Class(qtn, args, _) => { + // Generic instantiations are out of scope: the `$baml` marker + // encodes `typeArgs: []`, which the engine treats as unbound. + if !args.is_empty() { + return unsupported(ty); + } + let name = qtn.render_dotted(false); + if path.contains(qtn) { + return FieldSchema::Class { + name, + fields: Vec::new(), + recursive: true, + }; + } + match self.lookup_type(qtn) { + Some(ExportedType::Class { + fields, + generic_params, + .. + }) if generic_params.is_empty() => { + path.push(qtn.clone()); + let fields = fields + .iter() + .map(|(field_name, field_ty)| FieldSchemaField { + name: field_name.to_string(), + schema: self.field_schema(field_ty, path, depth + 1), + }) + .collect(); + path.pop(); + FieldSchema::Class { + name, + fields, + recursive: false, + } + } + _ => unsupported(ty), + } + } + // Only recursive aliases survive TIR lowering (non-recursive ones + // are pre-expanded), so a revisit here is the common case, not the + // exception. + Ty::TypeAlias(qtn, _) => { + if path.contains(qtn) { + return unsupported(ty); + } + match self.lookup_type(qtn) { + Some(ExportedType::TypeAlias { resolved, .. }) => { + path.push(qtn.clone()); + let schema = self.field_schema(resolved, path, depth + 1); + path.pop(); + schema + } + _ => unsupported(ty), + } + } + Ty::List(item, _) => FieldSchema::List { + item: Box::new(self.field_schema(item, path, depth + 1)), + }, + Ty::Map { key, value, .. } => FieldSchema::Map { + key: Box::new(self.field_schema(key, path, depth + 1)), + value: Box::new(self.field_schema(value, path, depth + 1)), + }, + Ty::Union(members, _) => { + if ty.is_nullable_union() { + let stripped = ty.strip_null(); + // `strip_null` returns the union unchanged when every + // member is null; the type is then just `null`. + if stripped.is_nullable_union() { + FieldSchema::Null + } else { + FieldSchema::Optional { + inner: Box::new(self.field_schema(&stripped, path, depth + 1)), + } + } + } else { + FieldSchema::Union { + variants: members + .iter() + .map(|member| self.field_schema(member, path, depth + 1)) + .collect(), + } + } + } + // Everything non-data: functions, interfaces, type variables, + // opaque runtime types, and the TIR sentinels (`Unknown`/`Error`/…) + // that reliably appear while the user is mid-edit. + _ => unsupported(ty), + } + } +} + +fn unsupported(ty: &Ty) -> FieldSchema { + FieldSchema::Unsupported { + display: ty.render_user_facing(), + } +} + +fn literal_value(lit: &LiteralValue) -> Option { + match lit { + LiteralValue::Int(i) => Some(serde_json::Value::from(*i)), + LiteralValue::Float(s) => s + .parse::() + .ok() + .and_then(serde_json::Number::from_f64) + .map(serde_json::Value::Number), + LiteralValue::String(s) => Some(serde_json::Value::String(s.clone())), + LiteralValue::Bool(b) => Some(serde_json::Value::Bool(*b)), + // A bigint literal can't be expressed in plain JSON args (JSON.parse + // yields a lossy number, and the encoder's bigint path needs a JS + // BigInt) — fall back to raw JSON. + LiteralValue::Bigint(_) => None, + } +} + +#[cfg(test)] +mod tests { + use serde_json::{Value, json}; + + use crate::{db::ProjectDatabase, symbols::list_functions_with_metadata}; + + fn db_with(files: &[(&str, &str)]) -> ProjectDatabase { + let mut db = ProjectDatabase::new(); + db.set_project_root(std::path::Path::new("/tmp")); + for (path, source) in files { + let full = format!("/tmp/{path}"); + db.add_or_update_file(std::path::Path::new(&full), source); + } + db + } + + /// The serialized `params` for `fn_name`, exactly as the playground + /// notification will carry them. + fn params_json(db: &ProjectDatabase, fn_name: &str) -> Value { + let functions = list_functions_with_metadata(db); + let function = functions + .iter() + .find(|f| f.name == fn_name) + .unwrap_or_else(|| panic!("function {fn_name} not found")); + serde_json::to_value( + function + .params + .as_ref() + .unwrap_or_else(|| panic!("no params extracted for {fn_name}")), + ) + .unwrap() + } + + #[test] + fn primitives_lists_and_nullable_unions() { + let db = db_with(&[( + "main.baml", + r#" + function Prim(a: int, b: string, c: bool, d: float?, e: string[], f: map) -> int { 1 } + "#, + )]); + assert_eq!( + params_json(&db, "Prim"), + json!([ + { "name": "a", "hasDefault": false, "schema": { "type": "int" } }, + { "name": "b", "hasDefault": false, "schema": { "type": "string" } }, + { "name": "c", "hasDefault": false, "schema": { "type": "bool" } }, + { "name": "d", "hasDefault": false, + "schema": { "type": "optional", "inner": { "type": "float" } } }, + { "name": "e", "hasDefault": false, + "schema": { "type": "list", "item": { "type": "string" } } }, + { "name": "f", "hasDefault": false, + "schema": { "type": "map", "key": { "type": "string" }, + "value": { "type": "float" } } }, + ]) + ); + } + + #[test] + fn nullary_function_gets_empty_schema_not_none() { + let db = db_with(&[("main.baml", "function Zero() -> int { 1 }")]); + assert_eq!(params_json(&db, "Zero"), json!([])); + } + + #[test] + fn enum_and_nested_class_expand_inline() { + let db = db_with(&[( + "main.baml", + r#" + enum Color { Red Green Blue } + class Nested { x int } + class Person { + name string + age int? + nested Nested + } + function F(p: Person, c: Color) -> int { 1 } + "#, + )]); + assert_eq!( + params_json(&db, "F"), + json!([ + { "name": "p", "hasDefault": false, "schema": { + "type": "class", "name": "user.Person", "recursive": false, + "fields": [ + { "name": "name", "schema": { "type": "string" } }, + { "name": "age", + "schema": { "type": "optional", "inner": { "type": "int" } } }, + { "name": "nested", "schema": { + "type": "class", "name": "user.Nested", "recursive": false, + "fields": [ { "name": "x", "schema": { "type": "int" } } ], + } }, + ], + } }, + { "name": "c", "hasDefault": false, "schema": { + "type": "enum", "name": "user.Color", + "values": ["Red", "Green", "Blue"], + } }, + ]) + ); + } + + #[test] + fn namespaced_class_uses_canonical_dotted_fqn() { + let db = db_with(&[ + ( + "ns_shapes/shapes.baml", + "class Box { w int }\nfunction Make(b: Box) -> int { 1 }", + ), + ("main.baml", "function Use(b: shapes.Box) -> int { 1 }"), + ]); + let expected_schema = json!({ + "type": "class", "name": "user.shapes.Box", "recursive": false, + "fields": [ { "name": "w", "schema": { "type": "int" } } ], + }); + assert_eq!( + params_json(&db, "shapes.Make")[0]["schema"], + expected_schema + ); + assert_eq!(params_json(&db, "Use")[0]["schema"], expected_schema); + } + + #[test] + fn recursive_class_is_cut_at_the_cycle() { + let db = db_with(&[( + "main.baml", + r#" + class Tree { + value int + children Tree[] + } + function Walk(t: Tree) -> int { 1 } + "#, + )]); + assert_eq!( + params_json(&db, "Walk")[0]["schema"], + json!({ + "type": "class", "name": "user.Tree", "recursive": false, + "fields": [ + { "name": "value", "schema": { "type": "int" } }, + { "name": "children", "schema": { "type": "list", "item": { + "type": "class", "name": "user.Tree", + "recursive": true, "fields": [], + } } }, + ], + }) + ); + } + + #[test] + fn recursive_alias_expands_once_then_degrades() { + let db = db_with(&[( + "main.baml", + r#" + type JSON = string | JSON[] + function G(j: JSON) -> int { 1 } + "#, + )]); + let schema = ¶ms_json(&db, "G")[0]["schema"]; + assert_eq!(schema["type"], "union"); + assert_eq!(schema["variants"][0], json!({ "type": "string" })); + assert_eq!(schema["variants"][1]["type"], "list"); + assert_eq!(schema["variants"][1]["item"]["type"], "unsupported"); + } + + #[test] + fn non_nullable_union_lists_variants() { + let db = db_with(&[( + "main.baml", + "function U(x: int | string, y: (int | string)?) -> int { 1 }", + )]); + let params = params_json(&db, "U"); + assert_eq!( + params[0]["schema"], + json!({ "type": "union", + "variants": [ { "type": "int" }, { "type": "string" } ] }) + ); + assert_eq!( + params[1]["schema"], + json!({ "type": "optional", "inner": { "type": "union", + "variants": [ { "type": "int" }, { "type": "string" } ] } }) + ); + } + + #[test] + fn unresolved_param_type_degrades_to_unsupported() { + let db = db_with(&[("main.baml", "function Bad(x: Nope) -> int { 1 }")]); + assert_eq!(params_json(&db, "Bad")[0]["schema"]["type"], "unsupported"); + } + + #[test] + fn generic_param_degrades_to_unsupported() { + let db = db_with(&[("main.baml", "function Id(x: T) -> T { x }")]); + assert_eq!(params_json(&db, "Id")[0]["schema"]["type"], "unsupported"); + } + + #[test] + fn dependency_package_class_expands_through_its_interface() { + let db = db_with(&[( + "main.baml", + "function D(d: baml.time.PlainDate) -> int { 1 }", + )]); + let schema = ¶ms_json(&db, "D")[0]["schema"]; + assert_eq!(schema["type"], "class"); + assert_eq!(schema["name"], "baml.time.PlainDate"); + assert_eq!(schema["recursive"], false); + assert_eq!( + schema["fields"][0], + json!({ "name": "_days", "schema": { "type": "int" } }) + ); + } + + #[test] + fn media_param_carries_its_kind() { + let db = db_with(&[("main.baml", "function Med(i: image) -> int { 1 }")]); + assert_eq!( + params_json(&db, "Med")[0]["schema"], + json!({ "type": "media", "kind": "image" }) + ); + } + + #[test] + fn param_with_default_sets_has_default() { + let db = db_with(&[("main.baml", "function Def(x: int, y: int = 3) -> int { 1 }")]); + let params = params_json(&db, "Def"); + assert_eq!(params[0]["hasDefault"], false); + assert_eq!(params[1]["hasDefault"], true); + assert_eq!(params[1]["schema"], json!({ "type": "int" })); + } +} diff --git a/baml_language/crates/baml_project/src/symbols.rs b/baml_language/crates/baml_project/src/symbols.rs index 306b68b03a4..5e2fc344642 100644 --- a/baml_language/crates/baml_project/src/symbols.rs +++ b/baml_language/crates/baml_project/src/symbols.rs @@ -8,9 +8,10 @@ use baml_compiler2_hir::{ file_item_tree, package::{PackageId, package_items}, }; +use baml_compiler2_tir::package_interface::package_interface; use baml_db::Name; -use crate::db::ProjectDatabase; +use crate::{db::ProjectDatabase, param_schema, param_schema::ParamSchema}; /// Symbol kind — locally defined since v1 HIR is removed. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -49,6 +50,10 @@ pub struct FunctionSymbol { pub client_name: Option, /// Whether this function is compiler-generated (`render_prompt`, `build_request`, `resolve`). pub is_sub_function: bool, + /// Parameter schemas for the playground args form. `None` means no schema + /// could be extracted (function missing from the package interface + /// mid-edit); `Some(vec![])` means the function takes no arguments. + pub params: Option>, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -108,6 +113,7 @@ pub fn list_functions(db: &ProjectDatabase) -> Vec { pub fn list_functions_with_metadata(db: &ProjectDatabase) -> Vec { let pkg_id = PackageId::new(db, Name::new("user")); let pkg = package_items(db, pkg_id); + let iface = package_interface(db, pkg_id); let mut result = Vec::new(); for (namespace_path, ns_items) in &pkg.namespaces { for (name, defn) in &ns_items.values { @@ -137,6 +143,7 @@ pub fn list_functions_with_metadata(db: &ProjectDatabase) -> Vec is_llm, client_name, is_sub_function, + params: param_schema::function_param_schemas(db, iface, namespace_path, name), }); } } From 8e59d15aab1f254a331e6919618a2478face8176 Mon Sep 17 00:00:00 2001 From: rossirpaulo Date: Tue, 7 Jul 2026 13:08:12 -0700 Subject: [PATCH 2/9] feat(playground): carry param schemas on FunctionInfo across both transports WebSocket picks the new field up automatically (source struct is serialized directly); the WASM worker path gets tsify twins plus the mandatory From mapping. None (omitted) = no schema; [] = nullary. --- .../crates/bex_project/src/bex_lsp/mod.rs | 6 + .../src/bex_lsp/multi_project/mod.rs | 1 + baml_language/crates/bex_project/src/lib.rs | 3 + .../crates/bridge_wasm/src/wasm_playground.rs | 128 ++++++++++++++++++ 4 files changed, 138 insertions(+) diff --git a/baml_language/crates/bex_project/src/bex_lsp/mod.rs b/baml_language/crates/bex_project/src/bex_lsp/mod.rs index 8dd050ae046..a44723ac247 100644 --- a/baml_language/crates/bex_project/src/bex_lsp/mod.rs +++ b/baml_language/crates/bex_project/src/bex_lsp/mod.rs @@ -74,6 +74,12 @@ pub struct FunctionInfo { pub origin: FunctionOrigin, #[serde(skip_serializing_if = "Option::is_none")] pub capabilities: Option, + /// Parameter schemas for the playground args form. `None` (omitted on the + /// wire) means no schema is available and the UI degrades to raw JSON; + /// `Some(vec![])` means the function takes no arguments. Do not collapse + /// the empty vec — the UI relies on the distinction. + #[serde(skip_serializing_if = "Option::is_none")] + pub params: Option>, } #[derive(Debug, Clone, serde::Serialize)] diff --git a/baml_language/crates/bex_project/src/bex_lsp/multi_project/mod.rs b/baml_language/crates/bex_project/src/bex_lsp/multi_project/mod.rs index 22166f34ecd..96fbe36bdd4 100644 --- a/baml_language/crates/bex_project/src/bex_lsp/multi_project/mod.rs +++ b/baml_language/crates/bex_project/src/bex_lsp/multi_project/mod.rs @@ -553,6 +553,7 @@ impl BexMulitProject { } else { None }, + params: f.params, }) .collect(); diff --git a/baml_language/crates/bex_project/src/lib.rs b/baml_language/crates/bex_project/src/lib.rs index c585da052b7..1f8327e0ab0 100644 --- a/baml_language/crates/bex_project/src/lib.rs +++ b/baml_language/crates/bex_project/src/lib.rs @@ -105,6 +105,9 @@ pub fn new_from_bytecode(bytecode: &[u8], sys_ops: SysOps) -> Result, + /// `None` = no schema available (UI degrades to raw JSON); `Some(vec![])` + /// = nullary function. The wire shape must match + /// `bex_project::FunctionInfo` exactly. + #[serde(skip_serializing_if = "Option::is_none")] + pub params: Option>, +} + +#[derive(Tsify, Serialize)] +#[tsify(into_wasm_abi)] +#[serde(rename_all = "camelCase")] +pub struct ParamSchema { + pub name: String, + pub has_default: bool, + pub schema: FieldSchema, +} + +#[derive(Tsify, Serialize)] +#[tsify(into_wasm_abi)] +#[serde(rename_all = "camelCase")] +pub struct FieldSchemaField { + pub name: String, + pub schema: FieldSchema, +} + +/// Twin of `baml_project::FieldSchema` (via `bex_project`); both serialize +/// with `tag = "type"` + camelCase so the JSON matches the WebSocket +/// transport, which serializes the source struct directly. +#[derive(Tsify, Serialize)] +#[tsify(into_wasm_abi)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum FieldSchema { + String, + Int, + Float, + Bool, + Null, + Bigint, + Media { + kind: String, + }, + Literal { + value: serde_json::Value, + }, + Enum { + name: String, + values: Vec, + }, + Class { + name: String, + fields: Vec, + recursive: bool, + }, + List { + item: Box, + }, + Map { + key: Box, + value: Box, + }, + Optional { + inner: Box, + }, + Union { + variants: Vec, + }, + Unsupported { + display: String, + }, +} + +impl From for ParamSchema { + fn from(p: bex_project::ParamSchema) -> Self { + ParamSchema { + name: p.name, + has_default: p.has_default, + schema: p.schema.into(), + } + } +} + +impl From for FieldSchemaField { + fn from(f: bex_project::FieldSchemaField) -> Self { + FieldSchemaField { + name: f.name, + schema: f.schema.into(), + } + } +} + +impl From for FieldSchema { + fn from(s: bex_project::FieldSchema) -> Self { + use bex_project::FieldSchema as Src; + match s { + Src::String => FieldSchema::String, + Src::Int => FieldSchema::Int, + Src::Float => FieldSchema::Float, + Src::Bool => FieldSchema::Bool, + Src::Null => FieldSchema::Null, + Src::Bigint => FieldSchema::Bigint, + Src::Media { kind } => FieldSchema::Media { kind }, + Src::Literal { value } => FieldSchema::Literal { value }, + Src::Enum { name, values } => FieldSchema::Enum { name, values }, + Src::Class { + name, + fields, + recursive, + } => FieldSchema::Class { + name, + fields: fields.into_iter().map(Into::into).collect(), + recursive, + }, + Src::List { item } => FieldSchema::List { + item: Box::new((*item).into()), + }, + Src::Map { key, value } => FieldSchema::Map { + key: Box::new((*key).into()), + value: Box::new((*value).into()), + }, + Src::Optional { inner } => FieldSchema::Optional { + inner: Box::new((*inner).into()), + }, + Src::Union { variants } => FieldSchema::Union { + variants: variants.into_iter().map(Into::into).collect(), + }, + Src::Unsupported { display } => FieldSchema::Unsupported { display }, + } + } } #[derive(Tsify, Serialize)] @@ -205,6 +332,7 @@ impl From for PlaygroundNotification { build_request: c.build_request, client_name: c.client_name, }), + params: f.params.map(|ps| ps.into_iter().map(Into::into).collect()), }) .collect(), diagnostics: update From 60a44edc07d258a17415e7fcd4744957b1ddae30 Mon Sep 17 00:00:00 2001 From: rossirpaulo Date: Tue, 7 Jul 2026 14:08:35 -0700 Subject: [PATCH 3/9] feat(playground): args form model, widgets, and enum wire encoding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pkg-proto: $baml {enum, value} marker encodes InboundEnumValue so expr functions receive real variants (no string→enum coercion exists) - worker-protocol: ParamSchema/FieldSchema mirror, params on FunctionInfo, add missing 'autoDerive' origin - ArgsForm: recursive schema-directed widgets over a controlled value record; pure logic split into args-form-model for unit testing - new ui primitives: Switch (radix), Select (styled native) --- typescript2/pkg-playground/src/ArgsForm.tsx | 586 ++++++++++++++++++ .../src/args-form-model.test.ts | 190 ++++++ .../pkg-playground/src/args-form-model.ts | 204 ++++++ .../src/components/ui/select.tsx | 29 + .../src/components/ui/switch.tsx | 26 + .../pkg-playground/src/worker-protocol.ts | 54 +- typescript2/pkg-proto/src/encode.ts | 22 +- .../pkg-proto/src/test/encode-decode.test.ts | 39 ++ 8 files changed, 1148 insertions(+), 2 deletions(-) create mode 100644 typescript2/pkg-playground/src/ArgsForm.tsx create mode 100644 typescript2/pkg-playground/src/args-form-model.test.ts create mode 100644 typescript2/pkg-playground/src/args-form-model.ts create mode 100644 typescript2/pkg-playground/src/components/ui/select.tsx create mode 100644 typescript2/pkg-playground/src/components/ui/switch.tsx diff --git a/typescript2/pkg-playground/src/ArgsForm.tsx b/typescript2/pkg-playground/src/ArgsForm.tsx new file mode 100644 index 00000000000..e3c1cedaa5a --- /dev/null +++ b/typescript2/pkg-playground/src/ArgsForm.tsx @@ -0,0 +1,586 @@ +/** + * Dynamic args form for the Run tab. + * + * Renders one typed widget per function parameter from the `ParamSchema` tree + * on `FunctionInfo`, dispatching recursively on `FieldSchema.type` (the same + * shape as ValueRenderer's type dispatch). Fully controlled: the single source + * of truth is the `value` record, which the host serializes into the existing + * `argsJson` pipeline on every edit. Value/marker semantics live in + * args-form-model.ts. + * + * Nodes the form can't render typed (unsupported types, media, recursion + * cut-points) degrade to a per-field raw-JSON textarea. + */ + +import { useState, type FC, type ReactNode } from 'react'; +import { ChevronRight, Plus, Trash2 } from 'lucide-react'; + +import { + activeUnionVariant, + defaultValueForSchema, + enumValue, + enumVariantOf, + isRawJsonSchema, + schemaLabel, +} from './args-form-model'; +import { Button } from './components/ui/button'; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from './components/ui/collapsible'; +import { Input } from './components/ui/input'; +import { Select } from './components/ui/select'; +import { Switch } from './components/ui/switch'; +import { ToggleGroup } from './components/ui/toggle-group'; +import { cn } from './lib/utils'; +import type { FieldSchema, ParamSchema } from './worker-protocol'; + +/** Enums up to this size render as toggle chips; larger ones as a dropdown. */ +const ENUM_TOGGLE_MAX = 5; +/** Class sections nested this deep start collapsed (ValueRenderer convention). */ +const AUTO_COLLAPSE_DEPTH = 2; + +export interface ArgsFormProps { + params: ParamSchema[]; + /** Parsed `argsJson` object; surplus keys are preserved by edits. */ + value: Record; + onChange: (next: Record) => void; + disabled?: boolean; +} + +export const ArgsForm: FC = ({ + params, + value, + onChange, + disabled, +}) => { + if (params.length === 0) { + return ( +
+ This function takes no arguments. +
+ ); + } + return ( +
+ {params.map((param) => ( + onChange({ ...value, [param.name]: v })} + onOmit={() => { + const { [param.name]: _omitted, ...rest } = value; + onChange(rest); + }} + /> + ))} +
+ ); +}; + +const ParamRow: FC<{ + param: ParamSchema; + value: unknown; + present: boolean; + disabled?: boolean; + onChange: (v: unknown) => void; + onOmit: () => void; +}> = ({ param, value, present, disabled, onChange, onOmit }) => { + const omitted = param.hasDefault && !present; + return ( +
+
+ + {param.name} + + + {schemaLabel(param.schema)} + + {param.hasDefault && ( + + )} +
+ {omitted ? ( +
+ omitted — uses the declared default +
+ ) : ( + + )} +
+ ); +}; + +interface FieldInputProps { + schema: FieldSchema; + value: unknown; + onChange: (v: unknown) => void; + depth: number; + disabled?: boolean; +} + +/** Recursive schema-directed widget dispatch. */ +const FieldInput: FC = (props) => { + const { schema } = props; + if (isRawJsonSchema(schema)) { + return ; + } + switch (schema.type) { + case 'string': + return ; + case 'int': + case 'bigint': + return ; + case 'float': + return ; + case 'bool': + return ; + case 'null': + return null; + case 'literal': + return ( + + {JSON.stringify(schema.value)} + + ); + case 'enum': + return ; + case 'class': + return ; + case 'list': + return ; + case 'map': + return ; + case 'optional': + return ; + case 'union': + return ; + // media/unsupported/recursive-class are handled by isRawJsonSchema above. + default: + return ; + } +}; + +/** Draft text that resets whenever the canonical external text changes. */ +function useDraft(canonical: string) { + const [draft, setDraft] = useState(canonical); + const [prev, setPrev] = useState(canonical); + if (canonical !== prev) { + setPrev(canonical); + setDraft(canonical); + } + return [draft, setDraft] as const; +} + +const StringField: FC = ({ value, onChange, disabled }) => ( + onChange(e.target.value)} + /> +); + +const NumberField: FC = ({ + value, + onChange, + disabled, + integer, +}) => { + const canonical = + typeof value === 'number' || typeof value === 'bigint' + ? String(value) + : ''; + const [draft, setDraft] = useDraft(canonical); + const parsed = draft.trim() === '' ? undefined : Number(draft); + const valid = + parsed === undefined || + (Number.isFinite(parsed) && (!integer || Number.isInteger(parsed))); + return ( + { + const text = e.target.value; + setDraft(text); + const num = text.trim() === '' ? undefined : Number(text); + if (num === undefined) { + onChange(undefined); + } else if ( + Number.isFinite(num) && + (!integer || Number.isInteger(num)) + ) { + onChange(num); + } + }} + /> + ); +}; + +const BoolField: FC = ({ value, onChange, disabled }) => ( + onChange(checked)} + /> +); + +const EnumField: FC< + FieldInputProps & { schema: Extract } +> = ({ schema, value, onChange, disabled }) => { + const current = enumVariantOf(value); + if (schema.values.length <= ENUM_TOGGLE_MAX) { + return ( + ({ value: v, label: v }))} + onValueChange={(v) => { + if (!disabled) onChange(enumValue(schema.name, v)); + }} + /> + ); + } + return ( + + ); +}; + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +const ClassSection: FC< + FieldInputProps & { schema: Extract } +> = ({ schema, value, onChange, depth, disabled }) => { + const [open, setOpen] = useState(depth < AUTO_COLLAPSE_DEPTH); + const obj = isPlainObject(value) ? value : {}; + const setField = (name: string, v: unknown) => + onChange({ ...obj, $baml: { type: schema.name }, [name]: v }); + return ( + + + + {schemaLabel(schema)} + + +
+ {schema.fields.map((field) => ( +
+
+ + {field.name} + + + {schemaLabel(field.schema)} + +
+ setField(field.name, v)} + depth={depth + 1} + disabled={disabled} + /> +
+ ))} +
+
+
+ ); +}; + +const ListField: FC< + FieldInputProps & { schema: Extract } +> = ({ schema, value, onChange, depth, disabled }) => { + const items = Array.isArray(value) ? value : []; + return ( +
+ {items.map((item, i) => ( +
+
+ + onChange(items.map((cur, j) => (j === i ? v : cur))) + } + depth={depth + 1} + disabled={disabled} + /> +
+ +
+ ))} + +
+ ); +}; + +/** Per-row key editor: commits a rename only when it wouldn't collide. */ +const MapKeyInput: FC<{ + mapKey: string; + siblingKeys: string[]; + disabled?: boolean; + onRename: (next: string) => void; +}> = ({ mapKey, siblingKeys, disabled, onRename }) => { + const [draft, setDraft] = useDraft(mapKey); + const collides = draft !== mapKey && siblingKeys.includes(draft); + return ( + { + setDraft(e.target.value); + if ( + e.target.value !== mapKey && + !siblingKeys.includes(e.target.value) + ) { + onRename(e.target.value); + } + }} + /> + ); +}; + +const MapField: FC< + FieldInputProps & { schema: Extract } +> = ({ schema, value, onChange, depth, disabled }) => { + const obj = + isPlainObject(value) && !('$baml' in value) + ? value + : ({} as Record); + const entries = Object.entries(obj); + const rebuild = ( + mapped: (entry: [string, unknown], i: number) => [string, unknown] | null, + ) => + onChange( + Object.fromEntries( + entries.map(mapped).filter((e): e is [string, unknown] => e !== null), + ), + ); + const freshKey = () => { + let i = entries.length + 1; + while (`key${i}` in obj) i += 1; + return `key${i}`; + }; + return ( +
+ {entries.map(([k, v], i) => ( +
+ sk)} + disabled={disabled} + onRename={(nk) => rebuild((e, j) => (j === i ? [nk, e[1]] : e))} + /> +
+ rebuild((e, j) => (j === i ? [e[0], nv] : e))} + depth={depth + 1} + disabled={disabled} + /> +
+ +
+ ))} + +
+ ); +}; + +const OptionalField: FC< + FieldInputProps & { schema: Extract } +> = ({ schema, value, onChange, depth, disabled }) => { + const isSet = value !== null && value !== undefined; + return ( +
+ + {isSet && ( + + )} +
+ ); +}; + +const UnionField: FC< + FieldInputProps & { schema: Extract } +> = ({ schema, value, onChange, depth, disabled }) => { + const detected = activeUnionVariant(value, schema.variants); + const [chosen, setChosen] = useState(0); + const active = detected >= 0 ? detected : chosen; + return ( +
+ ({ + value: String(i), + label: schemaLabel(v), + }))} + onValueChange={(v) => { + if (disabled) return; + const index = Number(v); + setChosen(index); + onChange(defaultValueForSchema(schema.variants[index])); + }} + /> + {schema.variants[active] && ( + + )} +
+ ); +}; + +/** Fallback editor for nodes without a typed widget: a JSON textarea that + * commits on every parseable edit and flags unparseable drafts. */ +const RawJsonField: FC = ({ + schema, + value, + onChange, + disabled, +}) => { + const canonical = value === undefined ? '' : JSON.stringify(value); + const [draft, setDraft] = useDraft(canonical); + let valid = true; + if (draft.trim() !== '') { + try { + JSON.parse(draft); + } catch { + valid = false; + } + } + return ( +