diff --git a/.gitattributes b/.gitattributes index fbf2f039e96..2171bb81640 100644 --- a/.gitattributes +++ b/.gitattributes @@ -8,6 +8,9 @@ # Force LF line endings for .baml files so include_str! embeds consistent # content on all platforms (avoids CRLF line-number shifts on Windows CI). *.baml text eol=lf +# Same for the args-form wire-shape golden fixture, byte-compared against +# serde_json output by baml_project's wire_shape_matches_the_ts_golden_fixture. +typescript2/pkg-playground/src/__fixtures__/*.json text eol=lf # Force LF for the checked-in SSE replay recordings. The BAML replay server # chunks them for paced streaming with `split("\n\n")`; CRLF (from autocrlf # on Windows) turns every "\n\n" into "\r\n\r\n", so the whole body streams diff --git a/baml_language/Cargo.lock b/baml_language/Cargo.lock index d14d1ad2900..7af41d61f4d 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..bbe403cb729 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,7 +34,8 @@ 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, TypeSchema}; pub use symbols::{ - FunctionOrigin, FunctionSymbol, Symbol, SymbolKind, TestSymbol, list_functions, - list_functions_with_metadata, list_tests_with_metadata, + FunctionListing, 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..850ffc3925f --- /dev/null +++ b/baml_language/crates/baml_project/src/param_schema.rs @@ -0,0 +1,898 @@ +//! Function-parameter schemas for the playground's dynamic args form. +//! +//! Converts a function's resolved parameter types ([`Ty`]) into a serializable +//! [`FieldSchema`] tree plus a shared per-project type table: named types +//! (classes, enums, aliases) are emitted once into the table and referenced by +//! [`FieldSchema::Ref`], so the wire payload is proportional to the number of +//! distinct types, not the number of paths through the type graph — aliases +//! included, since inlining them would re-expand the target per reference +//! site and blow up on alias DAGs exactly like the class-graph case. Nullable +//! unions fold into `Optional`, 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. +//! +//! Table keys and type `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 std::collections::BTreeMap; + +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; + +/// Bounds recursion through deeply-nested anonymous types +/// (`map>`) **and** long acyclic named-type chains: +/// class/alias bodies expand nested within the referencing frame, so without +/// a shared bound a generated chain of thousands of classes would overflow +/// the stack (the WASM worker's is ~1 MB). Memoization still makes each named +/// type expand at most once — a body first reached past the bound just bakes +/// `Unsupported` tails into its table entry. Same bound as TIR's +/// `CycleDetector`. +const MAX_DEPTH: usize = 64; + +/// The compiler appends this synthetic parameter to every LLM function +/// (`append_default_client_param`); `client` is a reserved name there, so a +/// trailing match can only be the injected one. The form must not render it. +const INJECTED_CLIENT_PARAM: (&str, &str) = ("client", "baml.llm.Client"); + +/// 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 named type's definition in the per-project table +/// (`ProjectUpdate.types`), keyed by canonical dotted FQN. +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(tag = "kind", rename_all = "camelCase")] +pub enum TypeSchema { + Class { fields: Vec }, + Enum { values: Vec }, + Alias { schema: FieldSchema }, +} + +/// A type schema for form rendering. Named types are [`FieldSchema::Ref`]s +/// into the type table; the entry's kind discriminates class/enum/alias. +#[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, + }, + /// A reference to a named type in the table. A dangling name (mid-edit + /// inconsistency) degrades to raw JSON in the UI. + Ref { + name: String, + }, + /// A specific-variant type (`s: Status.Active`) — self-contained (no table + /// entry needed) so the form can emit the enum wire marker directly. + EnumVariant { + name: String, + value: String, + }, + 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, inserting every named type it references into `table` +/// (shared across all functions of a project update). `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, + is_llm: bool, + table: &mut BTreeMap, +) -> Option> { + let func = iface.lookup_function(namespace_path, name)?; + let mut params = func.params.as_slice(); + if is_llm && let Some((last, rest)) = params.split_last() { + let (client_name, client_ty) = INJECTED_CLIENT_PARAM; + let is_injected_client = last + .name + .as_ref() + .is_some_and(|n| n.as_str() == client_name) + && matches!(&last.ty, Ty::Class(qtn, _, _) if qtn.render_dotted(false) == client_ty); + if is_injected_client { + params = rest; + } + } + let mut cx = SchemaCx { + db, + user_iface: iface, + table, + }; + let params = 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, 0), + }) + .collect(); + Some(params) +} + +struct SchemaCx<'db, 't> { + db: &'db ProjectDatabase, + user_iface: &'db PackageInterface, + /// Named types encountered so far, shared across every function of the + /// project update. Doubles as the occurs-check for recursive types: a + /// placeholder entry goes in before a body expands, so self-references + /// resolve to a `Ref` instead of recursing. + table: &'t mut BTreeMap, +} + +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()) + } + } + + /// `depth` counts every recursion step — structural nesting and named-type + /// body expansion alike — so native stack use is bounded by `MAX_DEPTH` + /// regardless of how the type graph is shaped. + fn field_schema(&mut self, ty: &Ty, 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, .. }) => { + let name = qtn.render_dotted(false); + let values = variants.iter().map(ToString::to_string).collect(); + self.table + .entry(name.clone()) + .or_insert(TypeSchema::Enum { values }); + FieldSchema::Ref { name } + } + _ => unsupported(ty), + }, + Ty::EnumVariant(qtn, variant, _) => match self.lookup_type(qtn) { + Some(ExportedType::Enum { .. }) => FieldSchema::EnumVariant { + name: qtn.render_dotted(false), + value: 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 self.table.contains_key(&name) { + return FieldSchema::Ref { name }; + } + match self.lookup_type(qtn) { + Some(ExportedType::Class { + fields, + generic_params, + .. + }) if generic_params.is_empty() => { + // Placeholder before expanding the body: recursive + // references hit the contains_key check above and + // resolve to a Ref, so each class expands exactly once. + self.table + .insert(name.clone(), TypeSchema::Class { fields: Vec::new() }); + let fields = fields + .iter() + .map(|(field_name, field_ty)| FieldSchemaField { + name: field_name.to_string(), + schema: self.field_schema(field_ty, depth + 1), + }) + .collect(); + self.table + .insert(name.clone(), TypeSchema::Class { fields }); + FieldSchema::Ref { name } + } + _ => unsupported(ty), + } + } + // Aliases are never pre-expanded by TIR lowering + // (`lower_type_expr_in_ns` emits `Ty::TypeAlias` for every alias + // reference), so this is the common path. Aliases are memoized + // into the table exactly like classes — inlining would re-expand + // the target per reference site, which blows up on alias DAGs + // just like the class-graph case. + Ty::TypeAlias(qtn, _) => { + let name = qtn.render_dotted(false); + if self.table.contains_key(&name) { + return FieldSchema::Ref { name }; + } + match self.lookup_type(qtn) { + Some(ExportedType::TypeAlias { resolved, .. }) => { + // Placeholder before expanding the target: recursive + // references hit the contains_key check above, so each + // alias expands exactly once. + self.table.insert( + name.clone(), + TypeSchema::Alias { + schema: FieldSchema::Null, + }, + ); + let schema = self.field_schema(resolved, depth + 1); + self.table + .insert(name.clone(), TypeSchema::Alias { schema }); + FieldSchema::Ref { name } + } + _ => unsupported(ty), + } + } + Ty::List(item, _) => FieldSchema::List { + item: Box::new(self.field_schema(item, depth + 1)), + }, + Ty::Map { key, value, .. } => FieldSchema::Map { + key: Box::new(self.field_schema(key, depth + 1)), + value: Box::new(self.field_schema(value, 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, depth + 1)), + } + } + } else { + FieldSchema::Union { + variants: members + .iter() + .map(|member| self.field_schema(member, 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::{FunctionListing, 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(listing: &FunctionListing, fn_name: &str) -> Value { + let function = listing + .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() + } + + fn types_json(listing: &FunctionListing) -> Value { + serde_json::to_value(&listing.types).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 } + "#, + )]); + let listing = list_functions_with_metadata(&db); + assert_eq!( + params_json(&listing, "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" } } }, + ]) + ); + assert_eq!(types_json(&listing), json!({})); + } + + #[test] + fn nullary_function_gets_empty_schema_not_none() { + let db = db_with(&[("main.baml", "function Zero() -> int { 1 }")]); + let listing = list_functions_with_metadata(&db); + assert_eq!(params_json(&listing, "Zero"), json!([])); + } + + #[test] + fn enum_and_nested_class_become_table_refs() { + 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 } + "#, + )]); + let listing = list_functions_with_metadata(&db); + assert_eq!( + params_json(&listing, "F"), + json!([ + { "name": "p", "hasDefault": false, + "schema": { "type": "ref", "name": "user.Person" } }, + { "name": "c", "hasDefault": false, + "schema": { "type": "ref", "name": "user.Color" } }, + ]) + ); + assert_eq!( + types_json(&listing), + json!({ + "user.Color": { "kind": "enum", "values": ["Red", "Green", "Blue"] }, + "user.Nested": { "kind": "class", "fields": [ + { "name": "x", "schema": { "type": "int" } }, + ] }, + "user.Person": { "kind": "class", "fields": [ + { "name": "name", "schema": { "type": "string" } }, + { "name": "age", + "schema": { "type": "optional", "inner": { "type": "int" } } }, + { "name": "nested", "schema": { "type": "ref", "name": "user.Nested" } }, + ] }, + }) + ); + } + + #[test] + fn shared_class_gets_one_table_entry_across_functions() { + let db = db_with(&[( + "main.baml", + r#" + class Shared { x int } + function A(s: Shared) -> int { 1 } + function B(s: Shared, t: Shared) -> int { 1 } + "#, + )]); + let listing = list_functions_with_metadata(&db); + let expected_ref = json!({ "type": "ref", "name": "user.Shared" }); + assert_eq!(params_json(&listing, "A")[0]["schema"], expected_ref); + assert_eq!(params_json(&listing, "B")[0]["schema"], expected_ref); + assert_eq!(params_json(&listing, "B")[1]["schema"], expected_ref); + assert_eq!( + types_json(&listing), + json!({ "user.Shared": { "kind": "class", "fields": [ + { "name": "x", "schema": { "type": "int" } }, + ] } }) + ); + } + + #[test] + fn diamond_dag_payload_is_linear_in_types() { + // The P0.1 reproducer: 13 classes, each with 3 fields of the next — + // inline expansion serialized this to 88 MB of params JSON. With the + // table it must stay a few KB, ∝ distinct types. + use std::fmt::Write; + let mut src = String::new(); + for i in 0..12 { + let next = i + 1; + writeln!(src, "class C{i} {{ a C{next} b C{next} c C{next} }}").unwrap(); + } + src.push_str("class C12 { x int }\nfunction F(p: C0) -> int { 1 }\n"); + let db = db_with(&[("main.baml", &src)]); + let listing = list_functions_with_metadata(&db); + let params_bytes = serde_json::to_string(¶ms_json(&listing, "F")) + .unwrap() + .len(); + let types_bytes = serde_json::to_string(&types_json(&listing)).unwrap().len(); + let total = params_bytes + types_bytes; + assert!(total < 8 * 1024, "params+types serialized to {total} bytes"); + assert_eq!( + params_json(&listing, "F")[0]["schema"], + json!({ "type": "ref", "name": "user.C0" }) + ); + } + + #[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 listing = list_functions_with_metadata(&db); + let expected_ref = json!({ "type": "ref", "name": "user.shapes.Box" }); + assert_eq!( + params_json(&listing, "shapes.Make")[0]["schema"], + expected_ref + ); + assert_eq!(params_json(&listing, "Use")[0]["schema"], expected_ref); + assert_eq!( + types_json(&listing)["user.shapes.Box"], + json!({ "kind": "class", "fields": [ + { "name": "w", "schema": { "type": "int" } }, + ] }) + ); + } + + #[test] + fn recursive_class_refers_to_itself_through_the_table() { + let db = db_with(&[( + "main.baml", + r#" + class Tree { + value int + children Tree[] + } + function Walk(t: Tree) -> int { 1 } + "#, + )]); + let listing = list_functions_with_metadata(&db); + assert_eq!( + params_json(&listing, "Walk")[0]["schema"], + json!({ "type": "ref", "name": "user.Tree" }) + ); + assert_eq!( + types_json(&listing)["user.Tree"], + json!({ "kind": "class", "fields": [ + { "name": "value", "schema": { "type": "int" } }, + { "name": "children", "schema": { + "type": "list", "item": { "type": "ref", "name": "user.Tree" }, + } }, + ] }) + ); + } + + #[test] + fn recursive_alias_gets_a_table_entry() { + let db = db_with(&[( + "main.baml", + r#" + type JSON = string | JSON[] + function G(j: JSON) -> int { 1 } + "#, + )]); + let listing = list_functions_with_metadata(&db); + assert_eq!( + params_json(&listing, "G")[0]["schema"], + json!({ "type": "ref", "name": "user.JSON" }) + ); + assert_eq!( + types_json(&listing)["user.JSON"], + json!({ "kind": "alias", "schema": { "type": "union", "variants": [ + { "type": "string" }, + { "type": "list", "item": { "type": "ref", "name": "user.JSON" } }, + ] } }) + ); + } + + #[test] + fn non_recursive_alias_gets_a_table_entry_too() { + // Aliases are memoized like classes — inlining would re-expand the + // target per reference site (exponential on alias DAGs). + let db = db_with(&[( + "main.baml", + "type Age = int\nfunction H(a: Age) -> int { 1 }", + )]); + let listing = list_functions_with_metadata(&db); + assert_eq!( + params_json(&listing, "H")[0]["schema"], + json!({ "type": "ref", "name": "user.Age" }) + ); + assert_eq!( + types_json(&listing), + json!({ "user.Age": { "kind": "alias", "schema": { "type": "int" } } }) + ); + } + + #[test] + fn alias_dag_payload_is_linear_in_types() { + // Alias twin of the diamond-DAG reproducer: each alias references the + // previous one twice. Inline expansion made this ∝ 2^15 paths + // (measured 1.9 MB / 4 s); memoized it must stay a few KB. + use std::fmt::Write; + let mut src = String::from("type A0 = int\n"); + for i in 1..=14 { + let prev = i - 1; + writeln!(src, "type A{i} = A{prev}[] | map").unwrap(); + } + src.push_str("function F(a: A14) -> int { 1 }\n"); + let db = db_with(&[("main.baml", &src)]); + let listing = list_functions_with_metadata(&db); + let total = serde_json::to_string(¶ms_json(&listing, "F")) + .unwrap() + .len() + + serde_json::to_string(&types_json(&listing)).unwrap().len(); + assert!(total < 8 * 1024, "params+types serialized to {total} bytes"); + assert_eq!( + params_json(&listing, "F")[0]["schema"], + json!({ "type": "ref", "name": "user.A14" }) + ); + } + + #[test] + fn long_acyclic_class_chain_is_depth_bounded() { + // Class/alias bodies expand nested within the referencing frame, so + // MAX_DEPTH must count named-type hops too: a generated chain of + // thousands of classes would otherwise recurse one native frame per + // hop and overflow the stack (the WASM worker's is ~1 MB). Past the + // bound the tail degrades to Unsupported instead. + use std::fmt::Write; + let mut src = String::new(); + for i in 0..100 { + let next = i + 1; + writeln!(src, "class C{i} {{ a C{next} }}").unwrap(); + } + src.push_str("class C100 { x int }\nfunction F(p: C0) -> int { 1 }\n"); + let db = db_with(&[("main.baml", &src)]); + let listing = list_functions_with_metadata(&db); + assert_eq!( + params_json(&listing, "F")[0]["schema"], + json!({ "type": "ref", "name": "user.C0" }) + ); + assert!( + listing.types.len() < 100, + "expected the chain to be cut at MAX_DEPTH, got {} entries", + listing.types.len() + ); + let cut = serde_json::to_string(&types_json(&listing)).unwrap(); + assert!( + cut.contains("\"unsupported\""), + "expected an unsupported tail" + ); + } + + #[test] + fn pure_alias_cycle_degrades_to_mutual_refs() { + // `type A = B; type B = A` compiles clean; the table ties the cycle + // with mutual refs, which the UI resolves to the raw-JSON fallback. + let db = db_with(&[( + "main.baml", + "type A = B\ntype B = A\nfunction C(x: A) -> int { 1 }", + )]); + let listing = list_functions_with_metadata(&db); + assert_eq!( + params_json(&listing, "C")[0]["schema"], + json!({ "type": "ref", "name": "user.A" }) + ); + assert_eq!( + types_json(&listing), + json!({ + "user.A": { "kind": "alias", "schema": { "type": "ref", "name": "user.B" } }, + "user.B": { "kind": "alias", "schema": { "type": "ref", "name": "user.A" } }, + }) + ); + } + + #[test] + fn enum_variant_param_is_self_contained() { + let db = db_with(&[( + "main.baml", + r#" + enum Status { Active Inactive } + function V(s: Status.Active) -> int { 1 } + "#, + )]); + let listing = list_functions_with_metadata(&db); + assert_eq!( + params_json(&listing, "V")[0]["schema"], + json!({ "type": "enumVariant", "name": "user.Status", "value": "Active" }) + ); + } + + #[test] + fn non_nullable_union_lists_variants() { + let db = db_with(&[( + "main.baml", + "function U(x: int | string, y: (int | string)?) -> int { 1 }", + )]); + let listing = list_functions_with_metadata(&db); + let params = params_json(&listing, "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 }")]); + let listing = list_functions_with_metadata(&db); + assert_eq!( + params_json(&listing, "Bad")[0]["schema"]["type"], + "unsupported" + ); + } + + #[test] + fn generic_param_degrades_to_unsupported() { + let db = db_with(&[("main.baml", "function Id(x: T) -> T { x }")]); + let listing = list_functions_with_metadata(&db); + assert_eq!( + params_json(&listing, "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 listing = list_functions_with_metadata(&db); + assert_eq!( + params_json(&listing, "D")[0]["schema"], + json!({ "type": "ref", "name": "baml.time.PlainDate" }) + ); + let entry = &types_json(&listing)["baml.time.PlainDate"]; + assert_eq!(entry["kind"], "class"); + assert_eq!( + entry["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 }")]); + let listing = list_functions_with_metadata(&db); + assert_eq!( + params_json(&listing, "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 listing = list_functions_with_metadata(&db); + let params = params_json(&listing, "Def"); + assert_eq!(params[0]["hasDefault"], false); + assert_eq!(params[1]["hasDefault"], true); + assert_eq!(params[1]["schema"], json!({ "type": "int" })); + } + + const LLM_FIXTURE: &str = r##" +client GPT4 { + provider "openai" + options { + model "gpt-4o" + api_key "test" + } +} + +function Extract(text: string) -> string { + client GPT4 + prompt #"{{ text }}"# +} + +function Plain(x: int) -> int { x } +"##; + + #[test] + fn injected_client_param_is_dropped_from_llm_functions() { + let db = db_with(&[("main.baml", LLM_FIXTURE)]); + let listing = list_functions_with_metadata(&db); + // Only the user-declared param survives; the compiler-injected + // trailing `client: baml.llm.Client` must not reach the form. + assert_eq!( + params_json(&listing, "Extract"), + json!([ + { "name": "text", "hasDefault": false, "schema": { "type": "string" } }, + ]) + ); + // Expr functions are unaffected. + assert_eq!( + params_json(&listing, "Plain"), + json!([ + { "name": "x", "hasDefault": false, "schema": { "type": "int" } }, + ]) + ); + } + + /// Pins the exact wire bytes of `params` + `types` against the golden + /// fixture that the TS side (`param-schema-golden.test.ts` in + /// pkg-playground) validates against its `worker-protocol.ts` mirror — + /// the FQN and shape contracts are otherwise enforced only by convention. + /// On drift, update the fixture and both mirrors together. + #[test] + fn wire_shape_matches_the_ts_golden_fixture() { + let db = db_with(&[( + "main.baml", + r#" + enum Color { Red Green Blue } + class Nested { x int } + class Person { + name string + age int? + color Color + nested Nested + } + type JSON = string | JSON[] + enum Status { Active Inactive } + function Golden(p: Person, c: Color, j: JSON, s: Status.Active, l: string[], m: map, u: int | string, i: image, x: int = 3) -> int { 1 } + "#, + )]); + let listing = list_functions_with_metadata(&db); + let actual = serde_json::to_string_pretty(&serde_json::json!({ + "params": params_json(&listing, "Golden"), + "types": types_json(&listing), + })) + .unwrap(); + let golden = include_str!( + "../../../../typescript2/pkg-playground/src/__fixtures__/param-schema-golden.json" + ); + // .gitattributes pins the fixture to LF, but stale Windows checkouts + // (attribute added after the file) may still hold CRLF — normalize. + let golden = golden.replace("\r\n", "\n"); + assert_eq!( + actual, + golden.trim_end(), + "wire shape drifted from the golden fixture; actual:\n{actual}" + ); + } + + #[test] + fn extraction_is_skipped_for_sub_functions_and_non_user_origins() { + let db = db_with(&[("main.baml", LLM_FIXTURE)]); + let listing = list_functions_with_metadata(&db); + let skipped: Vec<_> = listing + .functions + .iter() + .filter(|f| f.is_sub_function || f.origin != crate::FunctionOrigin::UserDefined) + .collect(); + assert!( + !skipped.is_empty(), + "fixture should produce companion/internal functions" + ); + for function in skipped { + assert!( + function.params.is_none(), + "expected no schema for {}", + function.name + ); + } + } +} diff --git a/baml_language/crates/baml_project/src/symbols.rs b/baml_language/crates/baml_project/src/symbols.rs index 306b68b03a4..762bf095470 100644 --- a/baml_language/crates/baml_project/src/symbols.rs +++ b/baml_language/crates/baml_project/src/symbols.rs @@ -8,9 +8,14 @@ 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, TypeSchema}, +}; /// Symbol kind — locally defined since v1 HIR is removed. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -49,6 +54,22 @@ 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. Named types inside are + /// [`crate::FieldSchema::Ref`]s into [`FunctionListing::types`]. `None` + /// means no schema was extracted (function missing from the package + /// interface mid-edit, or extraction skipped for companions/internal + /// functions); `Some(vec![])` means the function takes no arguments. + pub params: Option>, +} + +/// Playground function metadata plus the shared type table their param +/// schemas reference into. +#[derive(Debug, Clone)] +pub struct FunctionListing { + pub functions: Vec, + /// Every named type referenced from any function's params, defined exactly + /// once and keyed by canonical dotted FQN (`user.shapes.Foo`). + pub types: std::collections::BTreeMap, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -101,14 +122,17 @@ pub fn list_functions(db: &ProjectDatabase) -> Vec { result } -/// List user-facing functions with metadata for the playground. +/// List user-facing functions with metadata for the playground, along with +/// the shared type table their param schemas reference. /// /// Extracts LLM metadata (client name, `is_llm`) from `declarative_meta` on the /// compiler2 [`Function`](baml_compiler2_hir::item_tree::Function) item tree entry. -pub fn list_functions_with_metadata(db: &ProjectDatabase) -> Vec { +pub fn list_functions_with_metadata(db: &ProjectDatabase) -> FunctionListing { let pkg_id = PackageId::new(db, Name::new("user")); let pkg = package_items(db, pkg_id); - let mut result = Vec::new(); + let iface = package_interface(db, pkg_id); + let mut functions = Vec::new(); + let mut types = std::collections::BTreeMap::new(); for (namespace_path, ns_items) in &pkg.namespaces { for (name, defn) in &ns_items.values { if let Definition::Function(func_loc) = defn { @@ -131,18 +155,36 @@ pub fn list_functions_with_metadata(db: &ProjectDatabase) -> Vec // Sub-functions have names with '$' (e.g. MyFunc$render_prompt) let is_sub_function = name.as_str().contains('$'); - result.push(FunctionSymbol { + let origin: FunctionOrigin = func.origin.into(); + // Companions clone parent params verbatim and non-userDefined + // functions are hidden by default — extracting schemas for + // them only duplicates payload. The UI degrades to raw mode. + let params = if is_sub_function || origin != FunctionOrigin::UserDefined { + None + } else { + param_schema::function_param_schemas( + db, + iface, + namespace_path, + name, + is_llm, + &mut types, + ) + }; + + functions.push(FunctionSymbol { name: playground_function_name(namespace_path, name), - origin: func.origin.into(), + origin, is_llm, client_name, is_sub_function, + params, }); } } } - result.sort_by(|a, b| a.name.cmp(&b.name)); - result + functions.sort_by(|a, b| a.name.cmp(&b.name)); + FunctionListing { functions, types } } /// Function names exposed to the playground preserve source namespaces so the @@ -218,6 +260,7 @@ mod tests { ); let names = list_functions_with_metadata(&db) + .functions .into_iter() .map(|function| function.name) .collect::>(); 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 0f08dd13baf..7a653bb2014 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,13 @@ pub struct FunctionInfo { pub origin: FunctionOrigin, #[serde(skip_serializing_if = "Option::is_none")] pub capabilities: Option, + /// Parameter schemas for the playground args form; named types inside are + /// refs into `ProjectUpdate.types`. `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)] @@ -124,6 +131,13 @@ pub struct ProjectDiagnostic { pub struct ProjectUpdate { pub is_bex_current: bool, pub functions: Vec, + /// Shared type table for `FunctionInfo.params` refs: every named type + /// referenced from any function's schema, defined exactly once and keyed + /// by canonical dotted FQN. `None` (omitted on the wire) means the binary + /// predates the args form; `Some` may be an empty map. Same `None` ≠ + /// `Some(empty)` discipline as `params`. + #[serde(skip_serializing_if = "Option::is_none")] + pub types: Option>, pub diagnostics: Vec, } 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 6c1765d3dc4..15dbc845e99 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 @@ -714,7 +714,9 @@ impl BexMulitProject { let db_guard = project.project.db.lock().unwrap(); let db = db_guard.db(); - let functions = baml_project::list_functions_with_metadata(db) + let listing = baml_project::list_functions_with_metadata(db); + let functions = listing + .functions .into_iter() .map(|f| crate::bex_lsp::FunctionInfo { name: f.name, @@ -733,12 +735,14 @@ impl BexMulitProject { } else { None }, + params: f.params, }) .collect(); crate::bex_lsp::ProjectUpdate { is_bex_current, functions, + types: Some(listing.types), diagnostics, } } diff --git a/baml_language/crates/bex_project/src/lib.rs b/baml_language/crates/bex_project/src/lib.rs index c585da052b7..eded70c86bc 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. Named types are +/// `Ref`s into the `ProjectUpdate.types` table. +#[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, + }, + Ref { + name: String, + }, + EnumVariant { + name: String, + value: String, + }, + List { + item: Box, + }, + Map { + key: Box, + value: Box, + }, + Optional { + inner: Box, + }, + Union { + variants: Vec, + }, + Unsupported { + display: String, + }, +} + +/// Twin of `baml_project::TypeSchema` — one entry per named type in the +/// per-project table, keyed by canonical dotted FQN. +#[derive(Tsify, Serialize)] +#[tsify(into_wasm_abi)] +#[serde(tag = "kind", rename_all = "camelCase")] +pub enum TypeSchema { + Class { fields: Vec }, + Enum { values: Vec }, + Alias { schema: FieldSchema }, +} + +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::Ref { name } => FieldSchema::Ref { name }, + Src::EnumVariant { name, value } => FieldSchema::EnumVariant { name, value }, + 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 }, + } + } +} + +impl From for TypeSchema { + fn from(t: bex_project::TypeSchema) -> Self { + use bex_project::TypeSchema as Src; + match t { + Src::Class { fields } => TypeSchema::Class { + fields: fields.into_iter().map(Into::into).collect(), + }, + Src::Enum { values } => TypeSchema::Enum { values }, + Src::Alias { schema } => TypeSchema::Alias { + schema: schema.into(), + }, + } + } } #[derive(Tsify, Serialize)] @@ -58,6 +202,10 @@ pub struct ProjectDiagnostic { pub struct ProjectUpdate { pub is_bex_current: bool, pub functions: Vec, + /// Shared type table for `FunctionInfo.params` refs; `None` = binary + /// predates the args form. Must match `bex_project::ProjectUpdate`. + #[serde(skip_serializing_if = "Option::is_none")] + pub types: Option>, pub diagnostics: Vec, } @@ -205,8 +353,15 @@ 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(), + types: update.types.map(|types| { + types + .into_iter() + .map(|(name, t)| (name, t.into())) + .collect() + }), diagnostics: update .diagnostics .into_iter() @@ -294,3 +449,109 @@ pub(crate) fn send_wasm_playground_notification( }; let _ = callback.call1(&JsValue::NULL, &js_notif); } + +#[cfg(test)] +mod tests { + use super::*; + + /// The tsify twins must serialize byte-identically to the + /// `bex_project` source types: the WebSocket transport sends the source + /// structs directly, so any serde divergence here would present as a + /// worker-transport-only wire difference. Exercises every `FieldSchema` + /// and `TypeSchema` variant through the `From` mappings. + #[test] + fn schema_twins_serialize_identically_to_source_types() { + use std::collections::BTreeMap; + + use bex_project::{ + FieldSchema as Src, FieldSchemaField as SrcField, ParamSchema as SrcParam, + TypeSchema as SrcType, + }; + let src = SrcParam { + name: "p".to_string(), + has_default: true, + schema: Src::Union { + variants: vec![ + Src::String, + Src::Int, + Src::Float, + Src::Bool, + Src::Null, + Src::Bigint, + Src::Media { + kind: "image".to_string(), + }, + Src::Literal { + value: serde_json::json!({ "k": [1, "two", true, null] }), + }, + Src::Ref { + name: "user.Person".to_string(), + }, + Src::EnumVariant { + name: "user.Status".to_string(), + value: "Active".to_string(), + }, + Src::List { + item: Box::new(Src::String), + }, + Src::Map { + key: Box::new(Src::String), + value: Box::new(Src::Float), + }, + Src::Unsupported { + display: "callback".to_string(), + }, + ], + }, + }; + let twin: ParamSchema = src.clone().into(); + assert_eq!( + serde_json::to_value(&src).unwrap(), + serde_json::to_value(&twin).unwrap(), + ); + + let src_types = BTreeMap::from([ + ( + "user.Person".to_string(), + SrcType::Class { + fields: vec![SrcField { + name: "age".to_string(), + schema: Src::Optional { + inner: Box::new(Src::Int), + }, + }], + }, + ), + ( + "user.Color".to_string(), + SrcType::Enum { + values: vec!["Red".to_string(), "Green".to_string()], + }, + ), + ( + "user.JSON".to_string(), + SrcType::Alias { + schema: Src::Union { + variants: vec![ + Src::String, + Src::List { + item: Box::new(Src::Ref { + name: "user.JSON".to_string(), + }), + }, + ], + }, + }, + ), + ]); + let twin_types: BTreeMap = src_types + .clone() + .into_iter() + .map(|(name, t)| (name, t.into())) + .collect(); + assert_eq!( + serde_json::to_value(&src_types).unwrap(), + serde_json::to_value(&twin_types).unwrap(), + ); + } +} diff --git a/typescript2/app-vscode-webview/src/ExecutionPanel.strict-mode.test.tsx b/typescript2/app-vscode-webview/src/ExecutionPanel.strict-mode.test.tsx index 38c94cb4b14..4451f20f9cd 100644 --- a/typescript2/app-vscode-webview/src/ExecutionPanel.strict-mode.test.tsx +++ b/typescript2/app-vscode-webview/src/ExecutionPanel.strict-mode.test.tsx @@ -10,11 +10,11 @@ import { type WorkerOutMessage, } from '@b/pkg-playground'; -describe('ExecutionPanel StrictMode lifecycle', () => { - beforeAll(() => { - HTMLElement.prototype.scrollTo ??= vi.fn(); - }); +beforeAll(() => { + HTMLElement.prototype.scrollTo ??= vi.fn(); +}); +describe('ExecutionPanel StrictMode lifecycle', () => { it('keeps the run-store listener alive after StrictMode effect replay', async () => { const port = new FakeRuntimePort(); @@ -176,6 +176,484 @@ describe('ExecutionPanel StrictMode lifecycle', () => { }); }); +describe('ExecutionPanel args form', () => { + it('renders the args form from param schemas and serializes edits into argsJson', async () => { + const port = new FakeRuntimePort(); + + render(); + + act(() => { + port.emit({ + type: 'playgroundNotification', + notification: { type: 'listProjects', projects: ['project'] }, + }); + port.emit({ + type: 'playgroundNotification', + notification: { + type: 'updateProject', + project: 'project', + update: { + isBexCurrent: true, + functions: [ + { + name: 'Greet', + kind: 'expr', + origin: 'userDefined', + params: [ + { name: 'name', hasDefault: false, schema: { type: 'string' } }, + { + name: 'color', + hasDefault: false, + schema: { type: 'ref', name: 'user.Color' }, + }, + ], + }, + ], + types: { + 'user.Color': { + kind: 'enum', + values: ['Red', 'Green', 'Blue'], + }, + }, + diagnostics: [], + }, + }, + }); + }); + + fireEvent.click( + await screen.findByRole('button', { name: 'Functions (1)' }), + ); + fireEvent.click(await screen.findByRole('button', { name: 'Greet' })); + + // The form renders one widget per param: a string input and enum chips. + const nameInput = await screen.findByPlaceholderText('text'); + fireEvent.change(nameInput, { target: { value: 'Ada' } }); + fireEvent.click(await screen.findByRole('button', { name: 'Green' })); + + // Raw view shows the same argsJson the form writes — including the enum + // wire marker — proving the form and raw input share one state. + fireEvent.click(await screen.findByRole('button', { name: 'raw' })); + const rawInput = await screen.findByPlaceholderText('{"key": "value"}'); + const parsed = JSON.parse((rawInput as HTMLInputElement).value); + expect(parsed).toEqual({ + name: 'Ada', + color: { $baml: { enum: 'user.Color', value: 'Green' } }, + }); + + fireEvent.click(await screen.findByRole('button', { name: 'Run' })); + await waitFor(() => { + expect( + port.sent.some( + (msg) => msg.type === 'startRun' && msg.functionName === 'Greet', + ), + ).toBe(true); + }); + + // Cmd/Ctrl+Enter from an args editor field runs with the current args. + // (The form was toggled to raw above, so target the raw input.) + const runCount = port.sent.filter((msg) => msg.type === 'startRun').length; + fireEvent.keyDown(rawInput, { key: 'Enter', metaKey: true }); + await waitFor(() => { + expect(port.sent.filter((msg) => msg.type === 'startRun').length).toBe( + runCount + 1, + ); + }); + }); + + it('preserves seeded defaults when switching functions and back', async () => { + const port = new FakeRuntimePort(); + + render(); + + act(() => { + port.emit({ + type: 'playgroundNotification', + notification: { type: 'listProjects', projects: ['project'] }, + }); + port.emit({ + type: 'playgroundNotification', + notification: { + type: 'updateProject', + project: 'project', + update: { + isBexCurrent: true, + functions: [ + { + name: 'Greet', + kind: 'expr', + origin: 'userDefined', + params: [ + { + name: 'color', + hasDefault: false, + schema: { type: 'ref', name: 'user.Color' }, + }, + ], + }, + { name: 'Zero', kind: 'expr', origin: 'userDefined', params: [] }, + ], + types: { + 'user.Color': { + kind: 'enum', + values: ['Red', 'Green', 'Blue'], + }, + }, + diagnostics: [], + }, + }, + }); + }); + + fireEvent.click( + await screen.findByRole('button', { name: 'Functions (2)' }), + ); + fireEvent.click(await screen.findByRole('button', { name: 'Greet' })); + // Wait for the seed effect, then bounce to Zero and back. + const seeded = { + color: { $baml: { enum: 'user.Color', value: 'Red' } }, + }; + fireEvent.click(await screen.findByRole('button', { name: 'raw' })); + const rawInput = await screen.findByPlaceholderText('{"key": "value"}'); + await waitFor(() => { + expect(JSON.parse((rawInput as HTMLInputElement).value)).toEqual(seeded); + }); + + fireEvent.click(await screen.findByRole('button', { name: 'Zero' })); + fireEvent.click(await screen.findByRole('button', { name: 'Greet' })); + + const rawAgain = await screen.findByPlaceholderText('{"key": "value"}'); + await waitFor(() => { + expect(JSON.parse((rawAgain as HTMLInputElement).value)).toEqual(seeded); + }); + }); + + it('keeps an explicitly chosen union variant selected when values overlap', async () => { + const port = new FakeRuntimePort(); + + render(); + + act(() => { + port.emit({ + type: 'playgroundNotification', + notification: { type: 'listProjects', projects: ['project'] }, + }); + port.emit({ + type: 'playgroundNotification', + notification: { + type: 'updateProject', + project: 'project', + update: { + isBexCurrent: true, + functions: [ + { + name: 'Mix', + kind: 'expr', + origin: 'userDefined', + params: [ + { + name: 'x', + hasDefault: false, + schema: { + type: 'union', + variants: [{ type: 'int' }, { type: 'float' }], + }, + }, + ], + }, + ], + types: {}, + diagnostics: [], + }, + }, + }); + }); + + fireEvent.click( + await screen.findByRole('button', { name: 'Functions (1)' }), + ); + fireEvent.click(await screen.findByRole('button', { name: 'Mix' })); + + // Seeded to the first variant's default (int 0), which also inhabits + // float. Picking float must stick — first-match detection alone would + // snap the selection back to int and integer-gate the input. + const floatChip = await screen.findByRole('button', { name: 'float' }); + fireEvent.click(floatChip); + expect(floatChip.className).toContain('bg-vsc-accent'); + + const numberInput = await screen.findByPlaceholderText('0.0'); + fireEvent.change(numberInput, { target: { value: '1.5' } }); + expect(floatChip.className).toContain('bg-vsc-accent'); + + fireEvent.click(await screen.findByRole('button', { name: 'raw' })); + const rawInput = await screen.findByPlaceholderText('{"key": "value"}'); + expect(JSON.parse((rawInput as HTMLInputElement).value)).toEqual({ + x: 1.5, + }); + }); + + it('renders a recursive class fully typed at each expanded depth', async () => { + const port = new FakeRuntimePort(); + + render(); + + act(() => { + port.emit({ + type: 'playgroundNotification', + notification: { type: 'listProjects', projects: ['project'] }, + }); + port.emit({ + type: 'playgroundNotification', + notification: { + type: 'updateProject', + project: 'project', + update: { + isBexCurrent: true, + functions: [ + { + name: 'Walk', + kind: 'expr', + origin: 'userDefined', + params: [ + { + name: 't', + hasDefault: false, + schema: { type: 'ref', name: 'user.Tree' }, + }, + ], + }, + ], + types: { + 'user.Tree': { + kind: 'class', + fields: [ + { name: 'value', schema: { type: 'int' } }, + { + name: 'children', + schema: { + type: 'list', + item: { type: 'ref', name: 'user.Tree' }, + }, + }, + ], + }, + }, + diagnostics: [], + }, + }, + }); + }); + + fireEvent.click( + await screen.findByRole('button', { name: 'Functions (1)' }), + ); + fireEvent.click(await screen.findByRole('button', { name: 'Walk' })); + + // Root Tree section is open; one typed int widget for `value`. + expect(await screen.findAllByPlaceholderText('0')).toHaveLength(1); + + // Adding a child materializes a nested Tree section. It starts collapsed + // (depth ≥ 2) with its content unmounted — expanding it resolves the ref + // lazily and renders typed widgets: no raw-JSON cut-point at any depth. + fireEvent.click(await screen.findByRole('button', { name: 'add item' })); + const treeSections = await screen.findAllByRole('button', { + name: 'Tree', + }); + expect(treeSections).toHaveLength(2); + expect(screen.getAllByPlaceholderText('0')).toHaveLength(1); + fireEvent.click(treeSections[1]); + await waitFor(() => { + expect(screen.getAllByPlaceholderText('0')).toHaveLength(2); + }); + + fireEvent.change(screen.getAllByPlaceholderText('0')[1], { + target: { value: '7' }, + }); + fireEvent.click(await screen.findByRole('button', { name: 'raw' })); + const rawInput = await screen.findByPlaceholderText('{"key": "value"}'); + expect(JSON.parse((rawInput as HTMLInputElement).value)).toEqual({ + t: { + $baml: { type: 'user.Tree' }, + value: 0, + children: [ + { $baml: { type: 'user.Tree' }, value: 7, children: [] }, + ], + }, + }); + }); + + it('renders a self-referential alias as raw JSON instead of recursing', async () => { + // `type A = A | int` compiles clean and produces a table entry whose + // schema contains a ref back to itself with no value boundary in + // between. The render path must cut the cycle (raw-JSON fallback for the + // re-entrant ref), not stack-overflow. + const port = new FakeRuntimePort(); + + render(); + + act(() => { + port.emit({ + type: 'playgroundNotification', + notification: { type: 'listProjects', projects: ['project'] }, + }); + port.emit({ + type: 'playgroundNotification', + notification: { + type: 'updateProject', + project: 'project', + update: { + isBexCurrent: true, + functions: [ + { + name: 'Loop', + kind: 'expr', + origin: 'userDefined', + params: [ + { + name: 'a', + hasDefault: false, + schema: { type: 'ref', name: 'user.A' }, + }, + ], + }, + ], + types: { + 'user.A': { + kind: 'alias', + schema: { + type: 'union', + variants: [ + { type: 'ref', name: 'user.A' }, + { type: 'int' }, + ], + }, + }, + }, + diagnostics: [], + }, + }, + }); + }); + + fireEvent.click( + await screen.findByRole('button', { name: 'Functions (1)' }), + ); + fireEvent.click(await screen.findByRole('button', { name: 'Loop' })); + + // Union chips render; the self-referential variant is reachable but its + // widget degrades to the raw-JSON textarea. + fireEvent.click(await screen.findByRole('button', { name: 'A' })); + expect( + await screen.findByPlaceholderText('JSON (A)'), + ).toBeInTheDocument(); + }); + + it('normalizes a bare-enum host seed into the wire marker', async () => { + const port = new FakeRuntimePort(); + + render(); + + act(() => { + port.emit({ + type: 'playgroundNotification', + notification: { type: 'listProjects', projects: ['project'] }, + }); + port.emit({ + type: 'playgroundNotification', + notification: { + type: 'updateProject', + project: 'project', + update: { + isBexCurrent: true, + functions: [ + { + name: 'IsRed', + kind: 'expr', + origin: 'userDefined', + params: [ + { + name: 'c', + hasDefault: false, + schema: { type: 'ref', name: 'user.Color' }, + }, + ], + }, + ], + types: { + 'user.Color': { + kind: 'enum', + values: ['Red', 'Green', 'Blue'], + }, + }, + diagnostics: [], + }, + }, + }); + }); + + fireEvent.click( + await screen.findByRole('button', { name: 'Functions (1)' }), + ); + fireEvent.click(await screen.findByRole('button', { name: 'IsRed' })); + + // The bare string would encode untyped (no String→Enum coercion exists); + // hydration must rewrite it to the marker even though the host seed is + // non-empty (so the seeding effect correctly stays away). + fireEvent.click(await screen.findByRole('button', { name: 'raw' })); + const rawInput = await screen.findByPlaceholderText('{"key": "value"}'); + await waitFor(() => { + expect(JSON.parse((rawInput as HTMLInputElement).value)).toEqual({ + c: { $baml: { enum: 'user.Color', value: 'Red' } }, + }); + }); + }); + + it('shows the no-arguments state for a nullary schema and still runs', async () => { + const port = new FakeRuntimePort(); + + render(); + + act(() => { + port.emit({ + type: 'playgroundNotification', + notification: { type: 'listProjects', projects: ['project'] }, + }); + port.emit({ + type: 'playgroundNotification', + notification: { + type: 'updateProject', + project: 'project', + update: { + isBexCurrent: true, + functions: [ + { name: 'Zero', kind: 'expr', origin: 'userDefined', params: [] }, + ], + diagnostics: [], + }, + }, + }); + }); + + fireEvent.click( + await screen.findByRole('button', { name: 'Functions (1)' }), + ); + fireEvent.click(await screen.findByRole('button', { name: 'Zero' })); + + expect( + await screen.findByText('This function takes no arguments.'), + ).toBeInTheDocument(); + + fireEvent.click(await screen.findByRole('button', { name: 'Run' })); + await waitFor(() => { + expect( + port.sent.some( + (msg) => msg.type === 'startRun' && msg.functionName === 'Zero', + ), + ).toBe(true); + }); + }); +}); + class FakeRuntimePort implements RuntimePort { sent: WorkerInMessage[] = []; private handlers = new Set<(msg: WorkerOutMessage) => void>(); diff --git a/typescript2/pkg-playground/src/ArgsForm.tsx b/typescript2/pkg-playground/src/ArgsForm.tsx new file mode 100644 index 00000000000..dd68a07cc07 --- /dev/null +++ b/typescript2/pkg-playground/src/ArgsForm.tsx @@ -0,0 +1,642 @@ +/** + * 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). Named types are `ref`s resolved + * against the per-project type table (`ProjectUpdate.types`), provided via + * context; class sections resolve lazily on expand (Radix unmounts closed + * `CollapsibleContent`), which is what makes recursive types fully typed at + * every depth the user opens. 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, dangling refs) + * degrade to a per-field raw-JSON textarea. + */ + +import { + createContext, + useContext, + useMemo, + useState, + type FC, + type ReactNode, +} from 'react'; +import { ChevronRight, Plus, Trash2 } from 'lucide-react'; + +import { + activeUnionVariant, + defaultValueForSchema, + enumValue, + enumVariantOf, + isPlainObject, + isRawJsonSchema, + resolveRef, + schemaLabel, + typeLookupFrom, + valueMatchesSchema, + type TypeLookup, +} 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 { Textarea } from './components/ui/textarea'; +import { ToggleGroup } from './components/ui/toggle-group'; +import { cn } from './lib/utils'; +import type { + FieldSchema, + FieldSchemaField, + ParamSchema, + TypeSchema, +} 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; + +const TypeLookupContext = createContext(() => undefined); + +export interface ArgsFormProps { + params: ParamSchema[]; + /** Per-project named-type table the schemas' refs resolve against. */ + types?: Record; + /** Parsed `argsJson` object; surplus keys are preserved by edits. */ + value: Record; + onChange: (next: Record) => void; +} + +export const ArgsForm: FC = ({ + params, + types, + value, + onChange, +}) => { + const lookup = useMemo(() => typeLookupFrom(types), [types]); + 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); + }} + /> + ))} +
+
+ ); +}; + +/** Shared field header: name plus a faint type label. */ +const FieldLabel: FC<{ name: string; schema: FieldSchema; extra?: ReactNode }> = + ({ name, schema, extra }) => ( +
+ {name} + + {schemaLabel(schema)} + + {extra} +
+ ); + +const ParamRow: FC<{ + param: ParamSchema; + value: unknown; + present: boolean; + onChange: (v: unknown) => void; + onOmit: () => void; +}> = ({ param, value, present, onChange, onOmit }) => { + const lookup = useContext(TypeLookupContext); + const omitted = param.hasDefault && !present; + return ( +
+ + set + + on + ? onChange(defaultValueForSchema(param.schema, lookup)) + : onOmit() + } + /> + + ) + } + /> + {omitted ? ( +
+ omitted — uses the declared default +
+ ) : ( + + )} +
+ ); +}; + +interface FieldInputProps { + schema: FieldSchema; + value: unknown; + onChange: (v: unknown) => void; + depth: number; + /** Ref names already unwrapped without descending into a child value — + * guards self-referential alias schemas (`type A = A | int` compiles + * clean) from recursing the render unboundedly. Same-value hops + * (ref→alias target, union variant, optional inner) thread it through; + * descending into a child value (class field, list item, map entry) + * resets it, so legitimately recursive types still render at every + * depth the user opens. */ + refPath?: readonly string[]; +} + +/** Recursive schema-directed widget dispatch. */ +const FieldInput: FC = (props) => { + const lookup = useContext(TypeLookupContext); + const { schema, refPath = [] } = props; + if (isRawJsonSchema(schema, lookup)) { + 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 'enumVariant': + return ( + + ); + case 'ref': { + // isRawJsonSchema handled dangling refs above, so this resolves. + const resolved = resolveRef(schema.name, lookup); + if (resolved === undefined) return ; + if (resolved.kind === 'enum') { + return ( + + ); + } + if (resolved.kind === 'schema') { + if (refPath.includes(schema.name)) { + return ; + } + return ( + + ); + } + return ( + + ); + } + case 'list': + return ; + case 'map': + return ; + case 'optional': + return ; + case 'union': + return ; + // media/unsupported/unknown tags 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 }) => ( + onChange(e.target.value)} + /> +); + +const NumberField: FC = ({ + value, + onChange, + integer, +}) => { + const canonical = + typeof value === 'number' || typeof value === 'bigint' + ? String(value) + : ''; + const [draft, setDraft] = useDraft(canonical); + const parse = (text: string): number | null => { + const trimmed = text.trim(); + if (trimmed === '') return null; + const num = Number(trimmed); + return Number.isFinite(num) && (!integer || Number.isInteger(num)) + ? num + : null; + }; + // An empty or unparseable draft is an error state, not a deletion: the last + // committed value stays in place so required keys never silently drop out + // of argsJson. + return ( + { + setDraft(e.target.value); + const num = parse(e.target.value); + if (num !== null) onChange(num); + }} + /> + ); +}; + +const BoolField: FC = ({ value, onChange }) => ( + onChange(checked)} + /> +); + +const EnumField: FC< + FieldInputProps & { enumName: string; values: string[] } +> = ({ enumName, values, value, onChange }) => { + const current = enumVariantOf(value); + if (values.length <= ENUM_TOGGLE_MAX) { + return ( + ({ value: v, label: v }))} + onValueChange={(v) => onChange(enumValue(enumName, v))} + /> + ); + } + return ( +
+ +
+ ); +}; + +const ClassSection: FC< + FieldInputProps & { typeName: string; fields: FieldSchemaField[] } +> = ({ typeName, fields, schema, value, onChange, depth }) => { + const [open, setOpen] = useState(depth < AUTO_COLLAPSE_DEPTH); + const obj = isPlainObject(value) ? value : {}; + const setField = (name: string, v: unknown) => + onChange({ ...obj, $baml: { type: typeName }, [name]: v }); + return ( + + + + {schemaLabel(schema)} + + {/* Closed content is unmounted — nested refs (including back into this + class) only resolve and render when the user opens the section. */} + +
+ {fields.map((field) => ( +
+ + setField(field.name, v)} + depth={depth + 1} + /> +
+ ))} +
+
+
+ ); +}; + +const ListField: FC< + FieldInputProps & { schema: Extract } +> = ({ schema, value, onChange, depth }) => { + const lookup = useContext(TypeLookupContext); + const items = Array.isArray(value) ? value : []; + return ( +
+ {items.map((item, i) => ( +
+
+ + onChange(items.map((cur, j) => (j === i ? v : cur))) + } + depth={depth + 1} + /> +
+ +
+ ))} + +
+ ); +}; + +/** Per-row key editor: commits a rename only when it wouldn't collide. */ +const MapKeyInput: FC<{ + mapKey: string; + siblingKeys: string[]; + onRename: (next: string) => void; +}> = ({ mapKey, siblingKeys, 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 }) => { + const lookup = useContext(TypeLookupContext); + 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)} + onRename={(nk) => rebuild((e, j) => (j === i ? [nk, e[1]] : e))} + /> +
+ rebuild((e, j) => (j === i ? [e[0], nv] : e))} + depth={depth + 1} + /> +
+ +
+ ))} + +
+ ); +}; + +const OptionalField: FC< + FieldInputProps & { schema: Extract } +> = ({ schema, value, onChange, depth, refPath }) => { + const lookup = useContext(TypeLookupContext); + const isSet = value !== null && value !== undefined; + return ( +
+ + {isSet && ( + + )} +
+ ); +}; + +const UnionField: FC< + FieldInputProps & { schema: Extract } +> = ({ schema, value, onChange, depth, refPath }) => { + const lookup = useContext(TypeLookupContext); + const detected = activeUnionVariant(value, schema.variants, lookup); + const [chosen, setChosen] = useState(0); + // The explicit choice wins as long as the value inhabits it: first-match + // detection alone would snap `float` back to `int` (0 matches int first) + // and make overlapping variants unreachable. + const chosenSchema = schema.variants[chosen]; + const active = + detected === -1 + ? chosen + : chosenSchema !== undefined && + valueMatchesSchema(value, chosenSchema, lookup) + ? chosen + : detected; + return ( +
+ ({ + value: String(i), + label: schemaLabel(v), + }))} + onValueChange={(v) => { + const index = Number(v); + setChosen(index); + onChange(defaultValueForSchema(schema.variants[index], lookup)); + }} + /> + {schema.variants[active] && ( + + )} +
+ ); +}; + +/** Fallback editor for nodes without a typed widget: a JSON textarea that + * commits on every parseable edit. Like NumberField, an empty or unparseable + * draft is an error state, not a deletion — the last committed value stays + * in place (so e.g. emptying a map value doesn't delete the row) and the + * invalid style flags the draft. */ +const RawJsonField: FC = ({ schema, value, onChange }) => { + const canonical = value === undefined ? '' : JSON.stringify(value); + const [draft, setDraft] = useDraft(canonical); + const parse = (text: string): { ok: true; value: unknown } | { ok: false } => { + if (text.trim() === '') return { ok: false }; + try { + return { ok: true, value: JSON.parse(text) }; + } catch { + return { ok: false }; + } + }; + return ( +