From af5ef7146a5b3ad9271591f7e756a0158b5203e4 Mon Sep 17 00:00:00 2001 From: Mohamed Mansour Date: Fri, 6 Mar 2026 17:38:41 -0800 Subject: [PATCH] feat(protocol): add CSS token hoisting with comment-based signal bindings Extract CSS custom property usages (var() calls) at build time across all components and entry page styles. The sorted, deduplicated token names are included in the protocol output, enabling host runtimes to resolve only the design tokens an application actually needs. Protocol schema: - Add 'repeated string tokens = 2' to WebUIProtocol message - Add WebUIProtocol::new() and ::with_tokens() constructors - Replace all struct literals with constructor calls (109 sites) CSS token extraction (webui-parser/css_parser.rs): - Add CssParser::extract_tokens() for var() usage extraction - Add CssParser::extract_definitions() for custom property definitions - Add CssParser::extract_tokens_and_definitions() for single-parse path - Iterative tree-sitter-css AST walk (no recursion) - Handle nested fallbacks: var(--a, var(--b, var(--c))) extracts all three - Exclude locally-defined properties (--name: value) from token set - Implement Debug for CssParser (tree-sitter Parser lacks it) Component registry (webui-parser/component_registry.rs): - Add css_tokens field to Component struct - Extract tokens automatically during register_component() and register_component_from_paths() - ComponentRegistry owns a reusable CssParser instance (not per-call) Token collection during parsing (webui-parser/lib.rs): - Add token_store and token_definitions to HtmlParser - Merge component css_tokens on first component encounter - Extract tokens and definitions from inline + "#; + let app_dir = create_app_dir(&[ + ("index.html", html), + ("my-btn.html", ""), + ( + "my-btn.css", + ".btn { color: var(--text-color); margin: var(--spacing-m); }", + ), + ]); + let out_dir = TempDir::new().unwrap(); + + build(app_dir.path(), out_dir.path(), "index.html").unwrap(); + + let bytes = fs::read(out_dir.path().join("protocol.bin")).unwrap(); + let protocol = WebUIProtocol::from_protobuf(&bytes).unwrap(); + + // Both tokens are defined in entry :root — should not be in protocol + assert!( + protocol.tokens.is_empty(), + "Entry-defined tokens should be excluded: {:?}", + protocol.tokens + ); + } } diff --git a/crates/webui-cli/src/commands/common.rs b/crates/webui-cli/src/commands/common.rs index fb581579..4652f1d3 100644 --- a/crates/webui-cli/src/commands/common.rs +++ b/crates/webui-cli/src/commands/common.rs @@ -59,6 +59,8 @@ pub struct BuildOutput { pub fragment_count: usize, /// Number of registered components pub component_count: usize, + /// Number of unique CSS tokens discovered + pub token_count: usize, } /// Build the protocol from an app directory. @@ -122,6 +124,10 @@ pub fn build_protocol(app_dir: &Path, args: &AppArgs) -> Result { }) .collect(); + // Collect CSS tokens before consuming the parser + let tokens = parser.take_tokens(); + let token_count = tokens.len(); + // Build protocol (consumes parser) let fragment_records = parser.into_fragment_records(); let fragment_count: usize = fragment_records.values().map(|v| v.fragments.len()).sum(); @@ -133,14 +139,13 @@ pub fn build_protocol(app_dir: &Path, args: &AppArgs) -> Result { .map(|(tag, css)| (format!("{tag}.css"), css)) .collect(); - let protocol = WebUIProtocol { - fragments: fragment_records, - }; + let protocol = WebUIProtocol::with_tokens(fragment_records, tokens); Ok(BuildOutput { protocol, css_files, fragment_count, component_count, + token_count, }) } diff --git a/crates/webui-cli/src/commands/inspect.rs b/crates/webui-cli/src/commands/inspect.rs index 1860d45a..8639ce3f 100644 --- a/crates/webui-cli/src/commands/inspect.rs +++ b/crates/webui-cli/src/commands/inspect.rs @@ -1,79 +1,79 @@ -use anyhow::{Context, Result}; -use clap::Args; -use expand_tilde::expand_tilde; -use std::path::PathBuf; -use webui_protocol::WebUIProtocol; - -#[derive(Args)] -pub struct InspectArgs { - /// Path to a protocol.bin file - pub file: PathBuf, -} - -pub fn execute(args: &InspectArgs) -> Result<()> { - let input_file = expand_tilde(&args.file) - .with_context(|| format!("Failed to expand input path: {}", args.file.display()))? - .into_owned(); - - let protocol = WebUIProtocol::from_protobuf_file(&input_file) - .with_context(|| format!("Failed to read {}", args.file.display()))?; - - let json = protocol - .to_json_pretty() - .context("Failed to serialize to JSON")?; - - println!("{json}"); - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - use std::collections::HashMap; - use std::fs; - use tempfile::TempDir; - use webui_protocol::{FragmentList, WebUIFragment, WebUIProtocol}; - - #[test] - fn test_inspect_outputs_valid_json() { - let mut fragments = HashMap::new(); - fragments.insert( - "index.html".to_string(), - FragmentList { - fragments: vec![ - WebUIFragment::raw("Hello"), - WebUIFragment::signal("name", false), - ], - }, - ); - let protocol = WebUIProtocol { fragments }; - - let dir = TempDir::new().unwrap(); - let path = dir.path().join("protocol.bin"); - protocol.to_protobuf_file(&path).unwrap(); - - let loaded = WebUIProtocol::from_protobuf_file(&path).unwrap(); - let json = loaded.to_json_pretty().unwrap(); - let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); - assert!(parsed.get("fragments").is_some()); - assert!(parsed["fragments"]["index.html"]["fragments"].is_array()); - } - - #[test] - fn test_inspect_missing_file() { - let result = execute(&InspectArgs { - file: PathBuf::from("/nonexistent/protocol.bin"), - }); - assert!(result.is_err()); - } - - #[test] - fn test_inspect_invalid_file() { - let dir = TempDir::new().unwrap(); - let path = dir.path().join("bad.bin"); - fs::write(&path, b"not a protobuf").unwrap(); - - let result = execute(&InspectArgs { file: path }); - assert!(result.is_err()); - } -} +use anyhow::{Context, Result}; +use clap::Args; +use expand_tilde::expand_tilde; +use std::path::PathBuf; +use webui_protocol::WebUIProtocol; + +#[derive(Args)] +pub struct InspectArgs { + /// Path to a protocol.bin file + pub file: PathBuf, +} + +pub fn execute(args: &InspectArgs) -> Result<()> { + let input_file = expand_tilde(&args.file) + .with_context(|| format!("Failed to expand input path: {}", args.file.display()))? + .into_owned(); + + let protocol = WebUIProtocol::from_protobuf_file(&input_file) + .with_context(|| format!("Failed to read {}", args.file.display()))?; + + let json = protocol + .to_json_pretty() + .context("Failed to serialize to JSON")?; + + println!("{json}"); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + use std::fs; + use tempfile::TempDir; + use webui_protocol::{FragmentList, WebUIFragment, WebUIProtocol}; + + #[test] + fn test_inspect_outputs_valid_json() { + let mut fragments = HashMap::new(); + fragments.insert( + "index.html".to_string(), + FragmentList { + fragments: vec![ + WebUIFragment::raw("Hello"), + WebUIFragment::signal("name", false), + ], + }, + ); + let protocol = WebUIProtocol::new(fragments); + + let dir = TempDir::new().unwrap(); + let path = dir.path().join("protocol.bin"); + protocol.to_protobuf_file(&path).unwrap(); + + let loaded = WebUIProtocol::from_protobuf_file(&path).unwrap(); + let json = loaded.to_json_pretty().unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert!(parsed.get("fragments").is_some()); + assert!(parsed["fragments"]["index.html"]["fragments"].is_array()); + } + + #[test] + fn test_inspect_missing_file() { + let result = execute(&InspectArgs { + file: PathBuf::from("/nonexistent/protocol.bin"), + }); + assert!(result.is_err()); + } + + #[test] + fn test_inspect_invalid_file() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("bad.bin"); + fs::write(&path, b"not a protobuf").unwrap(); + + let result = execute(&InspectArgs { file: path }); + assert!(result.is_err()); + } +} diff --git a/crates/webui-ffi/src/lib.rs b/crates/webui-ffi/src/lib.rs index 61e426e6..b397244c 100644 --- a/crates/webui-ffi/src/lib.rs +++ b/crates/webui-ffi/src/lib.rs @@ -1,383 +1,381 @@ -//! WebUI FFI (Foreign Function Interface) for interoperability with other languages. -//! -//! This crate provides C-compatible APIs for the WebUI handler to be used from languages -//! like Go, C#, Python, etc. -//! -//! ## Quick Start -//! -//! The simplest way to render an HTML template with data: -//! -//! ```c -//! char *result = webui_render("

{{title}}

", "{\"title\":\"Hello\"}"); -//! if (result == NULL) { -//! const char *err = webui_last_error(); -//! // handle error... -//! } else { -//! // use result... -//! webui_free(result); -//! } -//! ``` -//! -//! ## Error Handling -//! -//! All functions that can fail return `NULL` on error. Call [`webui_last_error`] to -//! retrieve a human-readable error message. The error string is valid until the next -//! FFI call on the same thread (follows the POSIX `dlerror()` pattern). - -use serde_json::Value; -use std::cell::RefCell; -use std::ffi::{CStr, CString}; -use std::os::raw::{c_char, c_void}; -use webui_handler::plugin::FastHydrationPlugin; -use webui_handler::{ResponseWriter, WebUIHandler}; -use webui_parser::HtmlParser; -use webui_protocol::WebUIProtocol; - -// --------------------------------------------------------------------------- -// Thread-local error storage (POSIX dlerror() pattern) -// --------------------------------------------------------------------------- - -thread_local! { - /// Stores the last error message for the current thread. - static LAST_ERROR: RefCell> = const { RefCell::new(None) }; -} - -/// Record an error message so that `webui_last_error()` can return it. -fn set_last_error(msg: impl Into) { - let msg = msg.into(); - // If the message itself contains a NUL byte, truncate at that point. - let c_string = CString::new(msg).unwrap_or_else(|e| { - let nul_pos = e.nul_position(); - let mut bytes = e.into_vec(); - bytes.truncate(nul_pos); - CString::new(bytes).expect("truncated string should be NUL-free") - }); - LAST_ERROR.with(|cell| { - cell.replace(Some(c_string)); - }); -} - -/// Clear any previously stored error. -fn clear_last_error() { - LAST_ERROR.with(|cell| { - cell.replace(None); - }); -} - -// --------------------------------------------------------------------------- -// Internal helpers -// --------------------------------------------------------------------------- - -/// Opaque context wrapping a `WebUIHandler`. -struct HandlerContext { - handler: WebUIHandler, -} - -/// A simple string buffer for collecting rendered output. -struct StringResponseWriter { - content: String, -} - -impl StringResponseWriter { - fn new() -> Self { - Self { - content: String::new(), - } - } -} - -impl ResponseWriter for StringResponseWriter { - fn write(&mut self, content: &str) -> webui_handler::Result<()> { - self.content.push_str(content); - Ok(()) - } - - fn end(&mut self) -> webui_handler::Result<()> { - Ok(()) - } -} - -// --------------------------------------------------------------------------- -// FFI: error reporting -// --------------------------------------------------------------------------- - -/// Return the last error message, or `NULL` if no error has occurred. -/// -/// The returned pointer is valid until the next FFI call **on the same thread**. -/// Callers **must not** free the returned pointer. -/// -/// # Thread Safety -/// -/// Each thread has its own independent error state. -#[no_mangle] -pub extern "C" fn webui_last_error() -> *const c_char { - LAST_ERROR.with(|cell| { - let borrow = cell.borrow(); - match borrow.as_ref() { - Some(c_str) => c_str.as_ptr(), - None => std::ptr::null(), - } - }) -} - -// --------------------------------------------------------------------------- -// FFI: handler lifecycle -// --------------------------------------------------------------------------- - -/// Create a new WebUI handler instance. -/// -/// Returns an opaque pointer that must be passed to other `webui_handler_*` -/// functions and eventually freed with [`webui_handler_destroy`]. -#[no_mangle] -pub extern "C" fn webui_handler_create() -> *mut c_void { - let handler = WebUIHandler::new(); - let context = Box::new(HandlerContext { handler }); - Box::into_raw(context) as *mut c_void -} - -/// Create a new WebUI handler instance with a named plugin. -/// -/// # Arguments -/// -/// * `plugin_id` - Null-terminated UTF-8 string identifying the plugin. -/// Currently supported: `"fast"`. Pass `NULL` for no plugin. -/// -/// # Returns -/// -/// An opaque pointer that must be freed with [`webui_handler_destroy`], -/// or `NULL` on error (call [`webui_last_error`] for details). -/// -/// # Safety -/// -/// `plugin_id` must be a valid null-terminated UTF-8 string, or `NULL`. -#[no_mangle] -pub unsafe extern "C" fn webui_handler_create_with_plugin(plugin_id: *const c_char) -> *mut c_void { - clear_last_error(); - - let handler = if plugin_id.is_null() { - WebUIHandler::new() - } else { - match CStr::from_ptr(plugin_id).to_str() { - Ok("fast") => WebUIHandler::with_plugin(Box::new(FastHydrationPlugin::new())), - Ok(unknown) => { - set_last_error(format!("unknown plugin: {unknown}")); - return std::ptr::null_mut(); - } - Err(e) => { - set_last_error(format!("invalid UTF-8 in plugin_id: {e}")); - return std::ptr::null_mut(); - } - } - }; - - let context = Box::new(HandlerContext { handler }); - Box::into_raw(context) as *mut c_void -} - -/// Destroy a WebUI handler instance. -/// -/// # Safety -/// -/// `handler_ptr` must be a valid pointer returned by [`webui_handler_create`], -/// or `NULL` (in which case this function is a no-op). -#[no_mangle] -pub unsafe extern "C" fn webui_handler_destroy(handler_ptr: *mut c_void) { - if !handler_ptr.is_null() { - let _ = Box::from_raw(handler_ptr as *mut HandlerContext); - } -} - -// --------------------------------------------------------------------------- -// FFI: protobuf-based render (existing API, now with error reporting) -// --------------------------------------------------------------------------- - -/// Render a WebUI protocol (protobuf binary) with JSON data. -/// -/// # Arguments -/// -/// * `handler_ptr` - Pointer returned by [`webui_handler_create`]. -/// * `protocol_data` - Pointer to protobuf binary data. -/// * `protocol_len` - Length of the protobuf data in bytes. -/// * `data_json` - Null-terminated JSON string with the render state. -/// -/// # Returns -/// -/// A pointer to a null-terminated UTF-8 string with the rendered HTML, or -/// `NULL` on error. The caller **must** free the returned string with -/// [`webui_free`]. On error, call [`webui_last_error`] for details. -/// -/// # Safety -/// -/// * `handler_ptr` must be a valid pointer returned by [`webui_handler_create`]. -/// * `protocol_data` must point to `protocol_len` bytes of valid memory. -/// * `data_json` must be a valid null-terminated UTF-8 string. -#[no_mangle] -pub unsafe extern "C" fn webui_handler_render( - handler_ptr: *mut c_void, - protocol_data: *const u8, - protocol_len: usize, - data_json: *const c_char, -) -> *mut c_char { - clear_last_error(); - - if handler_ptr.is_null() || protocol_data.is_null() || data_json.is_null() { - set_last_error("one or more required arguments are null"); - return std::ptr::null_mut(); - } - - let context = &mut *(handler_ptr as *mut HandlerContext); - - // Create byte slice from protobuf binary data - let protocol_bytes = std::slice::from_raw_parts(protocol_data, protocol_len); - - let data_str = match CStr::from_ptr(data_json).to_str() { - Ok(s) => s, - Err(e) => { - set_last_error(format!("invalid UTF-8 in data_json: {e}")); - return std::ptr::null_mut(); - } - }; - - // Parse protocol from protobuf binary data - let protocol = match WebUIProtocol::from_protobuf(protocol_bytes) { - Ok(p) => p, - Err(e) => { - set_last_error(format!("failed to parse protobuf protocol: {e}")); - return std::ptr::null_mut(); - } - }; - - // Parse data JSON - let data: Value = match serde_json::from_str(data_str) { - Ok(d) => d, - Err(e) => { - set_last_error(format!("failed to parse data JSON: {e}")); - return std::ptr::null_mut(); - } - }; - - // Render - let mut writer = StringResponseWriter::new(); - match context.handler.render(&protocol, &data, &mut writer) { - Ok(_) => match CString::new(writer.content) { - Ok(s) => s.into_raw(), - Err(e) => { - set_last_error(format!("rendered output contains interior NUL byte: {e}")); - std::ptr::null_mut() - } - }, - Err(e) => { - set_last_error(format!("render failed: {e}")); - std::ptr::null_mut() - } - } -} - -// --------------------------------------------------------------------------- -// FFI: HTML-based render (new high-level API) -// --------------------------------------------------------------------------- - -/// Parse an HTML template and render it with JSON data in a single call. -/// -/// This is the **recommended entry point** for Go, C#, and Python consumers. -/// It eliminates the need for callers to deal with protobuf serialisation. -/// -/// # Arguments -/// -/// * `html` - Null-terminated UTF-8 string containing the HTML template. -/// * `data_json` - Null-terminated UTF-8 JSON string with the render state. -/// -/// # Returns -/// -/// A pointer to a null-terminated UTF-8 string with the rendered HTML, or -/// `NULL` on error. The caller **must** free the returned string with -/// [`webui_free`]. On error, call [`webui_last_error`] for details. -/// -/// # Safety -/// -/// Both `html` and `data_json` must be valid null-terminated UTF-8 strings. -#[no_mangle] -pub unsafe extern "C" fn webui_render( - html: *const c_char, - data_json: *const c_char, -) -> *mut c_char { - clear_last_error(); - - if html.is_null() || data_json.is_null() { - set_last_error("html and data_json must not be null"); - return std::ptr::null_mut(); - } - - // --- Extract C strings --------------------------------------------------- - let html_str = match CStr::from_ptr(html).to_str() { - Ok(s) => s, - Err(e) => { - set_last_error(format!("invalid UTF-8 in html: {e}")); - return std::ptr::null_mut(); - } - }; - - let data_str = match CStr::from_ptr(data_json).to_str() { - Ok(s) => s, - Err(e) => { - set_last_error(format!("invalid UTF-8 in data_json: {e}")); - return std::ptr::null_mut(); - } - }; - - // --- Parse HTML template into a WebUI protocol --------------------------- - let mut parser = HtmlParser::new(); - if let Err(e) = parser.parse("index.html", html_str) { - set_last_error(format!("HTML parse error: {e}")); - return std::ptr::null_mut(); - } - - let protocol = WebUIProtocol { - fragments: parser.into_fragment_records(), - }; - - // --- Parse JSON state ---------------------------------------------------- - let data: Value = match serde_json::from_str(data_str) { - Ok(d) => d, - Err(e) => { - set_last_error(format!("failed to parse data JSON: {e}")); - return std::ptr::null_mut(); - } - }; - - // --- Render -------------------------------------------------------------- - let mut handler = WebUIHandler::new(); - let mut writer = StringResponseWriter::new(); - - match handler.render(&protocol, &data, &mut writer) { - Ok(_) => match CString::new(writer.content) { - Ok(s) => s.into_raw(), - Err(e) => { - set_last_error(format!("rendered output contains interior NUL byte: {e}")); - std::ptr::null_mut() - } - }, - Err(e) => { - set_last_error(format!("render failed: {e}")); - std::ptr::null_mut() - } - } -} - -// --------------------------------------------------------------------------- -// FFI: memory management -// --------------------------------------------------------------------------- - -/// Free a string returned by a WebUI FFI function. -/// -/// # Safety -/// -/// `string_ptr` must be a pointer returned by a WebUI FFI function (e.g. -/// [`webui_handler_render`] or [`webui_render`]), or `NULL` -/// (in which case this function is a no-op). -#[no_mangle] -pub unsafe extern "C" fn webui_free(string_ptr: *mut c_char) { - if !string_ptr.is_null() { - let _ = CString::from_raw(string_ptr); - } -} +//! WebUI FFI (Foreign Function Interface) for interoperability with other languages. +//! +//! This crate provides C-compatible APIs for the WebUI handler to be used from languages +//! like Go, C#, Python, etc. +//! +//! ## Quick Start +//! +//! The simplest way to render an HTML template with data: +//! +//! ```c +//! char *result = webui_render("

{{title}}

", "{\"title\":\"Hello\"}"); +//! if (result == NULL) { +//! const char *err = webui_last_error(); +//! // handle error... +//! } else { +//! // use result... +//! webui_free(result); +//! } +//! ``` +//! +//! ## Error Handling +//! +//! All functions that can fail return `NULL` on error. Call [`webui_last_error`] to +//! retrieve a human-readable error message. The error string is valid until the next +//! FFI call on the same thread (follows the POSIX `dlerror()` pattern). + +use serde_json::Value; +use std::cell::RefCell; +use std::ffi::{CStr, CString}; +use std::os::raw::{c_char, c_void}; +use webui_handler::plugin::FastHydrationPlugin; +use webui_handler::{ResponseWriter, WebUIHandler}; +use webui_parser::HtmlParser; +use webui_protocol::WebUIProtocol; + +// --------------------------------------------------------------------------- +// Thread-local error storage (POSIX dlerror() pattern) +// --------------------------------------------------------------------------- + +thread_local! { + /// Stores the last error message for the current thread. + static LAST_ERROR: RefCell> = const { RefCell::new(None) }; +} + +/// Record an error message so that `webui_last_error()` can return it. +fn set_last_error(msg: impl Into) { + let msg = msg.into(); + // If the message itself contains a NUL byte, truncate at that point. + let c_string = CString::new(msg).unwrap_or_else(|e| { + let nul_pos = e.nul_position(); + let mut bytes = e.into_vec(); + bytes.truncate(nul_pos); + CString::new(bytes).expect("truncated string should be NUL-free") + }); + LAST_ERROR.with(|cell| { + cell.replace(Some(c_string)); + }); +} + +/// Clear any previously stored error. +fn clear_last_error() { + LAST_ERROR.with(|cell| { + cell.replace(None); + }); +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +/// Opaque context wrapping a `WebUIHandler`. +struct HandlerContext { + handler: WebUIHandler, +} + +/// A simple string buffer for collecting rendered output. +struct StringResponseWriter { + content: String, +} + +impl StringResponseWriter { + fn new() -> Self { + Self { + content: String::new(), + } + } +} + +impl ResponseWriter for StringResponseWriter { + fn write(&mut self, content: &str) -> webui_handler::Result<()> { + self.content.push_str(content); + Ok(()) + } + + fn end(&mut self) -> webui_handler::Result<()> { + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// FFI: error reporting +// --------------------------------------------------------------------------- + +/// Return the last error message, or `NULL` if no error has occurred. +/// +/// The returned pointer is valid until the next FFI call **on the same thread**. +/// Callers **must not** free the returned pointer. +/// +/// # Thread Safety +/// +/// Each thread has its own independent error state. +#[no_mangle] +pub extern "C" fn webui_last_error() -> *const c_char { + LAST_ERROR.with(|cell| { + let borrow = cell.borrow(); + match borrow.as_ref() { + Some(c_str) => c_str.as_ptr(), + None => std::ptr::null(), + } + }) +} + +// --------------------------------------------------------------------------- +// FFI: handler lifecycle +// --------------------------------------------------------------------------- + +/// Create a new WebUI handler instance. +/// +/// Returns an opaque pointer that must be passed to other `webui_handler_*` +/// functions and eventually freed with [`webui_handler_destroy`]. +#[no_mangle] +pub extern "C" fn webui_handler_create() -> *mut c_void { + let handler = WebUIHandler::new(); + let context = Box::new(HandlerContext { handler }); + Box::into_raw(context) as *mut c_void +} + +/// Create a new WebUI handler instance with a named plugin. +/// +/// # Arguments +/// +/// * `plugin_id` - Null-terminated UTF-8 string identifying the plugin. +/// Currently supported: `"fast"`. Pass `NULL` for no plugin. +/// +/// # Returns +/// +/// An opaque pointer that must be freed with [`webui_handler_destroy`], +/// or `NULL` on error (call [`webui_last_error`] for details). +/// +/// # Safety +/// +/// `plugin_id` must be a valid null-terminated UTF-8 string, or `NULL`. +#[no_mangle] +pub unsafe extern "C" fn webui_handler_create_with_plugin(plugin_id: *const c_char) -> *mut c_void { + clear_last_error(); + + let handler = if plugin_id.is_null() { + WebUIHandler::new() + } else { + match CStr::from_ptr(plugin_id).to_str() { + Ok("fast") => WebUIHandler::with_plugin(Box::new(FastHydrationPlugin::new())), + Ok(unknown) => { + set_last_error(format!("unknown plugin: {unknown}")); + return std::ptr::null_mut(); + } + Err(e) => { + set_last_error(format!("invalid UTF-8 in plugin_id: {e}")); + return std::ptr::null_mut(); + } + } + }; + + let context = Box::new(HandlerContext { handler }); + Box::into_raw(context) as *mut c_void +} + +/// Destroy a WebUI handler instance. +/// +/// # Safety +/// +/// `handler_ptr` must be a valid pointer returned by [`webui_handler_create`], +/// or `NULL` (in which case this function is a no-op). +#[no_mangle] +pub unsafe extern "C" fn webui_handler_destroy(handler_ptr: *mut c_void) { + if !handler_ptr.is_null() { + let _ = Box::from_raw(handler_ptr as *mut HandlerContext); + } +} + +// --------------------------------------------------------------------------- +// FFI: protobuf-based render (existing API, now with error reporting) +// --------------------------------------------------------------------------- + +/// Render a WebUI protocol (protobuf binary) with JSON data. +/// +/// # Arguments +/// +/// * `handler_ptr` - Pointer returned by [`webui_handler_create`]. +/// * `protocol_data` - Pointer to protobuf binary data. +/// * `protocol_len` - Length of the protobuf data in bytes. +/// * `data_json` - Null-terminated JSON string with the render state. +/// +/// # Returns +/// +/// A pointer to a null-terminated UTF-8 string with the rendered HTML, or +/// `NULL` on error. The caller **must** free the returned string with +/// [`webui_free`]. On error, call [`webui_last_error`] for details. +/// +/// # Safety +/// +/// * `handler_ptr` must be a valid pointer returned by [`webui_handler_create`]. +/// * `protocol_data` must point to `protocol_len` bytes of valid memory. +/// * `data_json` must be a valid null-terminated UTF-8 string. +#[no_mangle] +pub unsafe extern "C" fn webui_handler_render( + handler_ptr: *mut c_void, + protocol_data: *const u8, + protocol_len: usize, + data_json: *const c_char, +) -> *mut c_char { + clear_last_error(); + + if handler_ptr.is_null() || protocol_data.is_null() || data_json.is_null() { + set_last_error("one or more required arguments are null"); + return std::ptr::null_mut(); + } + + let context = &mut *(handler_ptr as *mut HandlerContext); + + // Create byte slice from protobuf binary data + let protocol_bytes = std::slice::from_raw_parts(protocol_data, protocol_len); + + let data_str = match CStr::from_ptr(data_json).to_str() { + Ok(s) => s, + Err(e) => { + set_last_error(format!("invalid UTF-8 in data_json: {e}")); + return std::ptr::null_mut(); + } + }; + + // Parse protocol from protobuf binary data + let protocol = match WebUIProtocol::from_protobuf(protocol_bytes) { + Ok(p) => p, + Err(e) => { + set_last_error(format!("failed to parse protobuf protocol: {e}")); + return std::ptr::null_mut(); + } + }; + + // Parse data JSON + let data: Value = match serde_json::from_str(data_str) { + Ok(d) => d, + Err(e) => { + set_last_error(format!("failed to parse data JSON: {e}")); + return std::ptr::null_mut(); + } + }; + + // Render + let mut writer = StringResponseWriter::new(); + match context.handler.render(&protocol, &data, &mut writer) { + Ok(_) => match CString::new(writer.content) { + Ok(s) => s.into_raw(), + Err(e) => { + set_last_error(format!("rendered output contains interior NUL byte: {e}")); + std::ptr::null_mut() + } + }, + Err(e) => { + set_last_error(format!("render failed: {e}")); + std::ptr::null_mut() + } + } +} + +// --------------------------------------------------------------------------- +// FFI: HTML-based render (new high-level API) +// --------------------------------------------------------------------------- + +/// Parse an HTML template and render it with JSON data in a single call. +/// +/// This is the **recommended entry point** for Go, C#, and Python consumers. +/// It eliminates the need for callers to deal with protobuf serialisation. +/// +/// # Arguments +/// +/// * `html` - Null-terminated UTF-8 string containing the HTML template. +/// * `data_json` - Null-terminated UTF-8 JSON string with the render state. +/// +/// # Returns +/// +/// A pointer to a null-terminated UTF-8 string with the rendered HTML, or +/// `NULL` on error. The caller **must** free the returned string with +/// [`webui_free`]. On error, call [`webui_last_error`] for details. +/// +/// # Safety +/// +/// Both `html` and `data_json` must be valid null-terminated UTF-8 strings. +#[no_mangle] +pub unsafe extern "C" fn webui_render( + html: *const c_char, + data_json: *const c_char, +) -> *mut c_char { + clear_last_error(); + + if html.is_null() || data_json.is_null() { + set_last_error("html and data_json must not be null"); + return std::ptr::null_mut(); + } + + // --- Extract C strings --------------------------------------------------- + let html_str = match CStr::from_ptr(html).to_str() { + Ok(s) => s, + Err(e) => { + set_last_error(format!("invalid UTF-8 in html: {e}")); + return std::ptr::null_mut(); + } + }; + + let data_str = match CStr::from_ptr(data_json).to_str() { + Ok(s) => s, + Err(e) => { + set_last_error(format!("invalid UTF-8 in data_json: {e}")); + return std::ptr::null_mut(); + } + }; + + // --- Parse HTML template into a WebUI protocol --------------------------- + let mut parser = HtmlParser::new(); + if let Err(e) = parser.parse("index.html", html_str) { + set_last_error(format!("HTML parse error: {e}")); + return std::ptr::null_mut(); + } + + let protocol = WebUIProtocol::new(parser.into_fragment_records()); + + // --- Parse JSON state ---------------------------------------------------- + let data: Value = match serde_json::from_str(data_str) { + Ok(d) => d, + Err(e) => { + set_last_error(format!("failed to parse data JSON: {e}")); + return std::ptr::null_mut(); + } + }; + + // --- Render -------------------------------------------------------------- + let mut handler = WebUIHandler::new(); + let mut writer = StringResponseWriter::new(); + + match handler.render(&protocol, &data, &mut writer) { + Ok(_) => match CString::new(writer.content) { + Ok(s) => s.into_raw(), + Err(e) => { + set_last_error(format!("rendered output contains interior NUL byte: {e}")); + std::ptr::null_mut() + } + }, + Err(e) => { + set_last_error(format!("render failed: {e}")); + std::ptr::null_mut() + } + } +} + +// --------------------------------------------------------------------------- +// FFI: memory management +// --------------------------------------------------------------------------- + +/// Free a string returned by a WebUI FFI function. +/// +/// # Safety +/// +/// `string_ptr` must be a pointer returned by a WebUI FFI function (e.g. +/// [`webui_handler_render`] or [`webui_render`]), or `NULL` +/// (in which case this function is a no-op). +#[no_mangle] +pub unsafe extern "C" fn webui_free(string_ptr: *mut c_char) { + if !string_ptr.is_null() { + let _ = CString::from_raw(string_ptr); + } +} diff --git a/crates/webui-handler/benches/handler_bench.rs b/crates/webui-handler/benches/handler_bench.rs index 37f028b9..d919e018 100644 --- a/crates/webui-handler/benches/handler_bench.rs +++ b/crates/webui-handler/benches/handler_bench.rs @@ -1,467 +1,467 @@ -use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion, Throughput}; -use serde_json::json; -use serde_json::Value; -use std::collections::HashMap; -use std::hint::black_box; -use webui_handler::plugin::FastHydrationPlugin; -use webui_handler::{ResponseWriter, WebUIHandler}; -use webui_protocol::{ - ComparisonOperator, ConditionExpr, FragmentList, LogicalOperator, WebUIFragment, WebUIProtocol, -}; - -struct BenchWriter { - output: String, -} - -impl BenchWriter { - fn new(capacity: usize) -> Self { - Self { - output: String::with_capacity(capacity), - } - } - - fn clear(&mut self) { - self.output.clear(); - } - - fn len(&self) -> usize { - self.output.len() - } -} - -impl ResponseWriter for BenchWriter { - fn write(&mut self, content: &str) -> webui_handler::Result<()> { - self.output.push_str(content); - Ok(()) - } - - fn end(&mut self) -> webui_handler::Result<()> { - Ok(()) - } -} - -fn build_state(item_count: usize) -> Value { - let mut items = Vec::with_capacity(item_count); - - for idx in 0..item_count { - items.push(json!({ - "id": idx, - "name": format!("Item {}", idx), - "enabled": idx % 2 == 0 - })); - } - - json!({ - "title": "Benchmark Title", - "is_disabled": false, - "theme": "dark", - "size": "md", - "show_footer": true, - "footer": "Footer Content", - "items": items - }) -} - -fn build_mixed_protocol() -> WebUIProtocol { - let mut fragments = HashMap::new(); - - fragments.insert( - "index.html".to_string(), - FragmentList { - fragments: vec![ - WebUIFragment::raw("
"), - WebUIFragment::component("card"), - WebUIFragment::raw("
"), - ], - }, - ); - - fragments.insert( - "card".to_string(), - FragmentList { - fragments: vec![ - WebUIFragment::raw(""), - WebUIFragment::raw("

"), - WebUIFragment::signal("title", false), - WebUIFragment::raw("

    "), - WebUIFragment::for_loop("item", "items", "item-frag"), - WebUIFragment::raw("
"), - WebUIFragment::if_cond(ConditionExpr::identifier("show_footer"), "footer-frag"), - // Simulate parser-plugin payload consumed by FastHydrationPlugin. - WebUIFragment::plugin((3u32).to_le_bytes().to_vec()), - WebUIFragment::raw(""), - ], - }, - ); - - fragments.insert( - "class-template".to_string(), - FragmentList { - fragments: vec![ - WebUIFragment::raw("card "), - WebUIFragment::signal("theme", false), - WebUIFragment::raw(" size-"), - WebUIFragment::signal("size", false), - ], - }, - ); - - fragments.insert( - "item-frag".to_string(), - FragmentList { - fragments: vec![ - WebUIFragment::raw(""), - WebUIFragment::signal("item.name", false), - WebUIFragment::raw(""), - ], - }, - ); - - fragments.insert( - "footer-frag".to_string(), - FragmentList { - fragments: vec![ - WebUIFragment::raw("
"), - WebUIFragment::signal("footer", false), - WebUIFragment::raw("
"), - ], - }, - ); - - WebUIProtocol { fragments } -} - -fn handler_plugin_fast_bench(c: &mut Criterion) { - let mut group = c.benchmark_group("handler_plugin_fast"); - let protocol = build_mixed_protocol(); - let state = build_state(120); - - let mut baseline_handler = WebUIHandler::new(); - let mut baseline_writer = BenchWriter::new(16 * 1024); - baseline_handler - .handle(&protocol, &state, &mut baseline_writer) - .unwrap_or_else(|error| panic!("baseline render failed: {error}")); - group.throughput(Throughput::Bytes(baseline_writer.len() as u64)); - - group.bench_function(BenchmarkId::new("render", "without_plugin"), |b| { - let mut handler = WebUIHandler::new(); - let mut writer = BenchWriter::new(16 * 1024); - - b.iter(|| { - writer.clear(); - handler - .handle(black_box(&protocol), black_box(&state), &mut writer) - .unwrap_or_else(|error| panic!("render without plugin failed: {error}")); - }); - }); - - group.bench_function(BenchmarkId::new("render", "with_fast_plugin"), |b| { - let mut handler = WebUIHandler::with_plugin(Box::new(FastHydrationPlugin::new())); - let mut writer = BenchWriter::new(24 * 1024); - - b.iter(|| { - writer.clear(); - handler - .handle(black_box(&protocol), black_box(&state), &mut writer) - .unwrap_or_else(|error| panic!("render with fast plugin failed: {error}")); - }); - }); - - group.finish(); -} - -fn handler_loop_scaling_bench(c: &mut Criterion) { - let mut group = c.benchmark_group("handler_loop_scaling"); - let protocol = build_mixed_protocol(); - - for &count in &[10usize, 100, 500, 2000] { - let state = build_state(count); - - // Pre-render to measure output size for throughput - let mut handler = WebUIHandler::new(); - let mut writer = BenchWriter::new(count * 80 + 1024); - handler - .handle(&protocol, &state, &mut writer) - .unwrap_or_else(|error| panic!("loop scaling warmup failed for {count}: {error}")); - group.throughput(Throughput::Bytes(writer.len() as u64)); - - group.bench_with_input(BenchmarkId::new("items", count), &state, |b, st| { - let mut h = WebUIHandler::new(); - let mut w = BenchWriter::new(count * 80 + 1024); - - b.iter(|| { - w.clear(); - h.handle(black_box(&protocol), black_box(st), &mut w) - .unwrap_or_else(|error| panic!("loop scaling render failed: {error}")); - }); - }); - } - - group.finish(); -} - -fn build_condition_protocol() -> WebUIProtocol { - let mut fragments = HashMap::new(); - - fragments.insert( - "index.html".to_string(), - FragmentList { - fragments: vec![ - WebUIFragment::raw("
"), - // Simple identifier condition - WebUIFragment::if_cond(ConditionExpr::identifier("isAdmin"), "admin-frag"), - // Predicate condition (equality) - WebUIFragment::if_cond( - ConditionExpr::predicate("status", ComparisonOperator::Equal, "'active'"), - "status-frag", - ), - // Negated condition - WebUIFragment::if_cond( - ConditionExpr::negated(ConditionExpr::identifier("isDisabled")), - "enabled-frag", - ), - // Compound AND condition - WebUIFragment::if_cond( - ConditionExpr::compound( - ConditionExpr::identifier("isLoggedIn"), - LogicalOperator::And, - ConditionExpr::identifier("hasPermission"), - ), - "auth-frag", - ), - // Compound OR condition - WebUIFragment::if_cond( - ConditionExpr::compound( - ConditionExpr::identifier("isOwner"), - LogicalOperator::Or, - ConditionExpr::identifier("isAdmin"), - ), - "access-frag", - ), - WebUIFragment::raw("
"), - ], - }, - ); - - for (id, content) in [ - ("admin-frag", "
Admin Mode
"), - ("status-frag", "Active"), - ("enabled-frag", ""), - ("auth-frag", ""), - ("access-frag", "
Access granted
"), - ] { - fragments.insert( - id.to_string(), - FragmentList { - fragments: vec![WebUIFragment::raw(content)], - }, - ); - } - - WebUIProtocol { fragments } -} - -fn build_condition_state() -> Value { - json!({ - "isAdmin": true, - "status": "active", - "isDisabled": false, - "isLoggedIn": true, - "hasPermission": true, - "isOwner": false, - }) -} - -fn handler_condition_variety_bench(c: &mut Criterion) { - let mut group = c.benchmark_group("handler_condition_variety"); - let protocol = build_condition_protocol(); - - let state_true = build_condition_state(); - let mut handler = WebUIHandler::new(); - let mut writer = BenchWriter::new(1024); - handler - .handle(&protocol, &state_true, &mut writer) - .unwrap_or_else(|error| panic!("condition warmup failed: {error}")); - group.throughput(Throughput::Bytes(writer.len() as u64)); - - group.bench_function("all_true", |b| { - let mut h = WebUIHandler::new(); - let mut w = BenchWriter::new(1024); - b.iter(|| { - w.clear(); - h.handle(black_box(&protocol), black_box(&state_true), &mut w) - .unwrap_or_else(|error| panic!("condition render failed: {error}")); - }); - }); - - let state_mixed = json!({ - "isAdmin": false, - "status": "inactive", - "isDisabled": true, - "isLoggedIn": true, - "hasPermission": false, - "isOwner": false, - }); - - group.bench_function("mixed", |b| { - let mut h = WebUIHandler::new(); - let mut w = BenchWriter::new(1024); - b.iter(|| { - w.clear(); - h.handle(black_box(&protocol), black_box(&state_mixed), &mut w) - .unwrap_or_else(|error| panic!("condition mixed render failed: {error}")); - }); - }); - - group.finish(); -} - -fn build_nested_component_protocol() -> WebUIProtocol { - let mut fragments = HashMap::new(); - - fragments.insert( - "index.html".to_string(), - FragmentList { - fragments: vec![ - WebUIFragment::raw(""), - WebUIFragment::component("app"), - WebUIFragment::raw(""), - ], - }, - ); - - fragments.insert( - "app".to_string(), - FragmentList { - fragments: vec![ - WebUIFragment::raw("

"), - WebUIFragment::signal("title", false), - WebUIFragment::raw("

"), - WebUIFragment::signal("items.length", false), - WebUIFragment::raw(" items
    "), - WebUIFragment::for_loop("item", "items", "item-component"), - WebUIFragment::raw("
"), - ], - }, - ); - - fragments.insert( - "item-component".to_string(), - FragmentList { - fragments: vec![ - WebUIFragment::raw(""), - WebUIFragment::signal("item.name", false), - WebUIFragment::raw(""), - WebUIFragment::if_cond(ConditionExpr::identifier("item.enabled"), "enabled-badge"), - WebUIFragment::raw(""), - ], - }, - ); - - fragments.insert( - "enabled-badge".to_string(), - FragmentList { - fragments: vec![WebUIFragment::raw("")], - }, - ); - - WebUIProtocol { fragments } -} - -fn handler_nested_components_bench(c: &mut Criterion) { - let mut group = c.benchmark_group("handler_nested_components"); - let protocol = build_nested_component_protocol(); - let state = build_state(50); - - let mut handler = WebUIHandler::new(); - let mut writer = BenchWriter::new(8 * 1024); - handler - .handle(&protocol, &state, &mut writer) - .unwrap_or_else(|error| panic!("nested components warmup failed: {error}")); - group.throughput(Throughput::Bytes(writer.len() as u64)); - - group.bench_function("three_levels_50_items", |b| { - let mut h = WebUIHandler::new(); - let mut w = BenchWriter::new(8 * 1024); - b.iter(|| { - w.clear(); - h.handle(black_box(&protocol), black_box(&state), &mut w) - .unwrap_or_else(|error| panic!("nested components render failed: {error}")); - }); - }); - - group.finish(); -} - -fn build_signal_protocol(signal_path: &str) -> WebUIProtocol { - let mut fragments = HashMap::new(); - fragments.insert( - "index.html".to_string(), - FragmentList { - fragments: vec![ - WebUIFragment::raw("
"), - WebUIFragment::signal(signal_path, false), - WebUIFragment::raw("
"), - ], - }, - ); - WebUIProtocol { fragments } -} - -fn handler_state_depth_bench(c: &mut Criterion) { - let mut group = c.benchmark_group("handler_state_depth"); - - let cases: Vec<(&str, &str, Value)> = vec![ - ("flat", "name", json!({"name": "Alice"})), - ("depth_2", "user.name", json!({"user": {"name": "Alice"}})), - ( - "depth_3", - "user.profile.name", - json!({"user": {"profile": {"name": "Alice"}}}), - ), - ( - "depth_5", - "a.b.c.d.name", - json!({"a": {"b": {"c": {"d": {"name": "Alice"}}}}}), - ), - ]; - - for (label, path, state) in &cases { - let protocol = build_signal_protocol(path); - - group.bench_function(*label, |b| { - let mut h = WebUIHandler::new(); - let mut w = BenchWriter::new(256); - b.iter(|| { - w.clear(); - h.handle(black_box(&protocol), black_box(state), &mut w) - .unwrap_or_else(|error| { - panic!("state depth render failed for {label}: {error}") - }); - }); - }); - } - - group.finish(); -} - -criterion_group!( - benches, - handler_plugin_fast_bench, - handler_loop_scaling_bench, - handler_condition_variety_bench, - handler_nested_components_bench, - handler_state_depth_bench -); -criterion_main!(benches); +use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion, Throughput}; +use serde_json::json; +use serde_json::Value; +use std::collections::HashMap; +use std::hint::black_box; +use webui_handler::plugin::FastHydrationPlugin; +use webui_handler::{ResponseWriter, WebUIHandler}; +use webui_protocol::{ + ComparisonOperator, ConditionExpr, FragmentList, LogicalOperator, WebUIFragment, WebUIProtocol, +}; + +struct BenchWriter { + output: String, +} + +impl BenchWriter { + fn new(capacity: usize) -> Self { + Self { + output: String::with_capacity(capacity), + } + } + + fn clear(&mut self) { + self.output.clear(); + } + + fn len(&self) -> usize { + self.output.len() + } +} + +impl ResponseWriter for BenchWriter { + fn write(&mut self, content: &str) -> webui_handler::Result<()> { + self.output.push_str(content); + Ok(()) + } + + fn end(&mut self) -> webui_handler::Result<()> { + Ok(()) + } +} + +fn build_state(item_count: usize) -> Value { + let mut items = Vec::with_capacity(item_count); + + for idx in 0..item_count { + items.push(json!({ + "id": idx, + "name": format!("Item {}", idx), + "enabled": idx % 2 == 0 + })); + } + + json!({ + "title": "Benchmark Title", + "is_disabled": false, + "theme": "dark", + "size": "md", + "show_footer": true, + "footer": "Footer Content", + "items": items + }) +} + +fn build_mixed_protocol() -> WebUIProtocol { + let mut fragments = HashMap::new(); + + fragments.insert( + "index.html".to_string(), + FragmentList { + fragments: vec![ + WebUIFragment::raw("
"), + WebUIFragment::component("card"), + WebUIFragment::raw("
"), + ], + }, + ); + + fragments.insert( + "card".to_string(), + FragmentList { + fragments: vec![ + WebUIFragment::raw(""), + WebUIFragment::raw("

"), + WebUIFragment::signal("title", false), + WebUIFragment::raw("

    "), + WebUIFragment::for_loop("item", "items", "item-frag"), + WebUIFragment::raw("
"), + WebUIFragment::if_cond(ConditionExpr::identifier("show_footer"), "footer-frag"), + // Simulate parser-plugin payload consumed by FastHydrationPlugin. + WebUIFragment::plugin((3u32).to_le_bytes().to_vec()), + WebUIFragment::raw(""), + ], + }, + ); + + fragments.insert( + "class-template".to_string(), + FragmentList { + fragments: vec![ + WebUIFragment::raw("card "), + WebUIFragment::signal("theme", false), + WebUIFragment::raw(" size-"), + WebUIFragment::signal("size", false), + ], + }, + ); + + fragments.insert( + "item-frag".to_string(), + FragmentList { + fragments: vec![ + WebUIFragment::raw(""), + WebUIFragment::signal("item.name", false), + WebUIFragment::raw(""), + ], + }, + ); + + fragments.insert( + "footer-frag".to_string(), + FragmentList { + fragments: vec![ + WebUIFragment::raw("
"), + WebUIFragment::signal("footer", false), + WebUIFragment::raw("
"), + ], + }, + ); + + WebUIProtocol::new(fragments) +} + +fn handler_plugin_fast_bench(c: &mut Criterion) { + let mut group = c.benchmark_group("handler_plugin_fast"); + let protocol = build_mixed_protocol(); + let state = build_state(120); + + let mut baseline_handler = WebUIHandler::new(); + let mut baseline_writer = BenchWriter::new(16 * 1024); + baseline_handler + .handle(&protocol, &state, &mut baseline_writer) + .unwrap_or_else(|error| panic!("baseline render failed: {error}")); + group.throughput(Throughput::Bytes(baseline_writer.len() as u64)); + + group.bench_function(BenchmarkId::new("render", "without_plugin"), |b| { + let mut handler = WebUIHandler::new(); + let mut writer = BenchWriter::new(16 * 1024); + + b.iter(|| { + writer.clear(); + handler + .handle(black_box(&protocol), black_box(&state), &mut writer) + .unwrap_or_else(|error| panic!("render without plugin failed: {error}")); + }); + }); + + group.bench_function(BenchmarkId::new("render", "with_fast_plugin"), |b| { + let mut handler = WebUIHandler::with_plugin(Box::new(FastHydrationPlugin::new())); + let mut writer = BenchWriter::new(24 * 1024); + + b.iter(|| { + writer.clear(); + handler + .handle(black_box(&protocol), black_box(&state), &mut writer) + .unwrap_or_else(|error| panic!("render with fast plugin failed: {error}")); + }); + }); + + group.finish(); +} + +fn handler_loop_scaling_bench(c: &mut Criterion) { + let mut group = c.benchmark_group("handler_loop_scaling"); + let protocol = build_mixed_protocol(); + + for &count in &[10usize, 100, 500, 2000] { + let state = build_state(count); + + // Pre-render to measure output size for throughput + let mut handler = WebUIHandler::new(); + let mut writer = BenchWriter::new(count * 80 + 1024); + handler + .handle(&protocol, &state, &mut writer) + .unwrap_or_else(|error| panic!("loop scaling warmup failed for {count}: {error}")); + group.throughput(Throughput::Bytes(writer.len() as u64)); + + group.bench_with_input(BenchmarkId::new("items", count), &state, |b, st| { + let mut h = WebUIHandler::new(); + let mut w = BenchWriter::new(count * 80 + 1024); + + b.iter(|| { + w.clear(); + h.handle(black_box(&protocol), black_box(st), &mut w) + .unwrap_or_else(|error| panic!("loop scaling render failed: {error}")); + }); + }); + } + + group.finish(); +} + +fn build_condition_protocol() -> WebUIProtocol { + let mut fragments = HashMap::new(); + + fragments.insert( + "index.html".to_string(), + FragmentList { + fragments: vec![ + WebUIFragment::raw("
"), + // Simple identifier condition + WebUIFragment::if_cond(ConditionExpr::identifier("isAdmin"), "admin-frag"), + // Predicate condition (equality) + WebUIFragment::if_cond( + ConditionExpr::predicate("status", ComparisonOperator::Equal, "'active'"), + "status-frag", + ), + // Negated condition + WebUIFragment::if_cond( + ConditionExpr::negated(ConditionExpr::identifier("isDisabled")), + "enabled-frag", + ), + // Compound AND condition + WebUIFragment::if_cond( + ConditionExpr::compound( + ConditionExpr::identifier("isLoggedIn"), + LogicalOperator::And, + ConditionExpr::identifier("hasPermission"), + ), + "auth-frag", + ), + // Compound OR condition + WebUIFragment::if_cond( + ConditionExpr::compound( + ConditionExpr::identifier("isOwner"), + LogicalOperator::Or, + ConditionExpr::identifier("isAdmin"), + ), + "access-frag", + ), + WebUIFragment::raw("
"), + ], + }, + ); + + for (id, content) in [ + ("admin-frag", "
Admin Mode
"), + ("status-frag", "Active"), + ("enabled-frag", ""), + ("auth-frag", ""), + ("access-frag", "
Access granted
"), + ] { + fragments.insert( + id.to_string(), + FragmentList { + fragments: vec![WebUIFragment::raw(content)], + }, + ); + } + + WebUIProtocol::new(fragments) +} + +fn build_condition_state() -> Value { + json!({ + "isAdmin": true, + "status": "active", + "isDisabled": false, + "isLoggedIn": true, + "hasPermission": true, + "isOwner": false, + }) +} + +fn handler_condition_variety_bench(c: &mut Criterion) { + let mut group = c.benchmark_group("handler_condition_variety"); + let protocol = build_condition_protocol(); + + let state_true = build_condition_state(); + let mut handler = WebUIHandler::new(); + let mut writer = BenchWriter::new(1024); + handler + .handle(&protocol, &state_true, &mut writer) + .unwrap_or_else(|error| panic!("condition warmup failed: {error}")); + group.throughput(Throughput::Bytes(writer.len() as u64)); + + group.bench_function("all_true", |b| { + let mut h = WebUIHandler::new(); + let mut w = BenchWriter::new(1024); + b.iter(|| { + w.clear(); + h.handle(black_box(&protocol), black_box(&state_true), &mut w) + .unwrap_or_else(|error| panic!("condition render failed: {error}")); + }); + }); + + let state_mixed = json!({ + "isAdmin": false, + "status": "inactive", + "isDisabled": true, + "isLoggedIn": true, + "hasPermission": false, + "isOwner": false, + }); + + group.bench_function("mixed", |b| { + let mut h = WebUIHandler::new(); + let mut w = BenchWriter::new(1024); + b.iter(|| { + w.clear(); + h.handle(black_box(&protocol), black_box(&state_mixed), &mut w) + .unwrap_or_else(|error| panic!("condition mixed render failed: {error}")); + }); + }); + + group.finish(); +} + +fn build_nested_component_protocol() -> WebUIProtocol { + let mut fragments = HashMap::new(); + + fragments.insert( + "index.html".to_string(), + FragmentList { + fragments: vec![ + WebUIFragment::raw(""), + WebUIFragment::component("app"), + WebUIFragment::raw(""), + ], + }, + ); + + fragments.insert( + "app".to_string(), + FragmentList { + fragments: vec![ + WebUIFragment::raw("

"), + WebUIFragment::signal("title", false), + WebUIFragment::raw("

"), + WebUIFragment::signal("items.length", false), + WebUIFragment::raw(" items
    "), + WebUIFragment::for_loop("item", "items", "item-component"), + WebUIFragment::raw("
"), + ], + }, + ); + + fragments.insert( + "item-component".to_string(), + FragmentList { + fragments: vec![ + WebUIFragment::raw(""), + WebUIFragment::signal("item.name", false), + WebUIFragment::raw(""), + WebUIFragment::if_cond(ConditionExpr::identifier("item.enabled"), "enabled-badge"), + WebUIFragment::raw(""), + ], + }, + ); + + fragments.insert( + "enabled-badge".to_string(), + FragmentList { + fragments: vec![WebUIFragment::raw("")], + }, + ); + + WebUIProtocol::new(fragments) +} + +fn handler_nested_components_bench(c: &mut Criterion) { + let mut group = c.benchmark_group("handler_nested_components"); + let protocol = build_nested_component_protocol(); + let state = build_state(50); + + let mut handler = WebUIHandler::new(); + let mut writer = BenchWriter::new(8 * 1024); + handler + .handle(&protocol, &state, &mut writer) + .unwrap_or_else(|error| panic!("nested components warmup failed: {error}")); + group.throughput(Throughput::Bytes(writer.len() as u64)); + + group.bench_function("three_levels_50_items", |b| { + let mut h = WebUIHandler::new(); + let mut w = BenchWriter::new(8 * 1024); + b.iter(|| { + w.clear(); + h.handle(black_box(&protocol), black_box(&state), &mut w) + .unwrap_or_else(|error| panic!("nested components render failed: {error}")); + }); + }); + + group.finish(); +} + +fn build_signal_protocol(signal_path: &str) -> WebUIProtocol { + let mut fragments = HashMap::new(); + fragments.insert( + "index.html".to_string(), + FragmentList { + fragments: vec![ + WebUIFragment::raw("
"), + WebUIFragment::signal(signal_path, false), + WebUIFragment::raw("
"), + ], + }, + ); + WebUIProtocol::new(fragments) +} + +fn handler_state_depth_bench(c: &mut Criterion) { + let mut group = c.benchmark_group("handler_state_depth"); + + let cases: Vec<(&str, &str, Value)> = vec![ + ("flat", "name", json!({"name": "Alice"})), + ("depth_2", "user.name", json!({"user": {"name": "Alice"}})), + ( + "depth_3", + "user.profile.name", + json!({"user": {"profile": {"name": "Alice"}}}), + ), + ( + "depth_5", + "a.b.c.d.name", + json!({"a": {"b": {"c": {"d": {"name": "Alice"}}}}}), + ), + ]; + + for (label, path, state) in &cases { + let protocol = build_signal_protocol(path); + + group.bench_function(*label, |b| { + let mut h = WebUIHandler::new(); + let mut w = BenchWriter::new(256); + b.iter(|| { + w.clear(); + h.handle(black_box(&protocol), black_box(state), &mut w) + .unwrap_or_else(|error| { + panic!("state depth render failed for {label}: {error}") + }); + }); + }); + } + + group.finish(); +} + +criterion_group!( + benches, + handler_plugin_fast_bench, + handler_loop_scaling_bench, + handler_condition_variety_bench, + handler_nested_components_bench, + handler_state_depth_bench +); +criterion_main!(benches); diff --git a/crates/webui-handler/src/lib.rs b/crates/webui-handler/src/lib.rs index 9c15b75c..61cb9554 100644 --- a/crates/webui-handler/src/lib.rs +++ b/crates/webui-handler/src/lib.rs @@ -1,4238 +1,4234 @@ -//! WebUI Handler implementation for Rust. -//! -//! This crate provides functionality to process and render WebUI protocols -//! into final HTML output based on provided data. - -pub mod plugin; - -use plugin::HandlerPlugin; -use serde_json::Value; -use std::collections::HashMap; -use thiserror::Error; -use webui_expressions::{evaluate, ExpressionError}; -use webui_protocol::{web_ui_fragment::Fragment, WebUIFragment, WebUIProtocol}; -use webui_state::find_value_by_dotted_path; - -/// Error types for the WebUI handler. -#[derive(Debug, Error)] -pub enum HandlerError { - #[error("Rendering error: {0}")] - Rendering(String), - - #[error("Missing fragment: {0}")] - MissingFragment(String), - - #[error("Missing data field: {0}")] - MissingData(String), - - #[error("Type error: {0}")] - TypeError(String), - - #[error("Protocol error: {0}")] - Protocol(#[from] webui_protocol::ProtocolError), - - #[error("Evaluation error: {0}")] - Evaluation(String), - - #[error("I/O error: {0}")] - Io(#[from] std::io::Error), - - #[error("Writer error: {0}")] - Writer(String), -} - -pub type Result = std::result::Result; - -/// Interface for writing rendered output -pub trait ResponseWriter { - /// Write content to the output - fn write(&mut self, content: &str) -> Result<()>; - - /// Finalize the output - fn end(&mut self) -> Result<()>; -} - -/// The main WebUI handler that processes protocols and renders them. -pub struct WebUIHandler { - plugin: Option>, -} - -/// Context object for processing WebUI fragments -struct WebUIProcessContext<'a> { - protocol: &'a WebUIProtocol, - state: &'a Value, - #[allow(dead_code)] - depth: usize, - writer: &'a mut dyn ResponseWriter, - // Add local variables map to store context-specific variables (like loop items) - local_vars: HashMap, - /// Accumulates component attribute values between attrStart and the component fragment. - component_attrs: HashMap, -} - -/// Convert hyphenated name to camelCase (e.g., "data-title" → "dataTitle"). -fn convert_hyphen_to_camel_case(name: &str) -> String { - let mut result = String::with_capacity(name.len()); - let mut capitalize_next = false; - for ch in name.chars() { - if ch == '-' { - capitalize_next = true; - } else if capitalize_next { - result.extend(ch.to_uppercase()); - capitalize_next = false; - } else { - result.push(ch); - } - } - result -} - -/// Get the component attribute name, stripping `:` prefix and converting to camelCase. -fn component_attr_name(name: &str) -> String { - let stripped = name.strip_prefix(':').unwrap_or(name); - if stripped.contains('-') { - convert_hyphen_to_camel_case(stripped) - } else { - stripped.to_string() - } -} - -impl WebUIHandler { - /// Create a new WebUI handler with no plugin. - pub fn new() -> Self { - Self { plugin: None } - } - - /// Create a new WebUI handler with a plugin. - pub fn with_plugin(plugin: Box) -> Self { - Self { - plugin: Some(plugin), - } - } - - /// Process a WebUI protocol with the provided state and write the output to the given writer. - /// - /// This method initializes an empty context map that will be used to track scoped variables - /// during rendering (such as loop variables that are only available within their loops). - pub fn handle( - &mut self, - protocol: &WebUIProtocol, - state: &Value, - writer: &mut dyn ResponseWriter, - ) -> Result<()> { - // Start with the main fragment (typically "index.html") - let main_fragment_id = "index.html"; - if !protocol.fragments.contains_key(main_fragment_id) { - return Err(HandlerError::MissingFragment(main_fragment_id.to_string())); - } - - // Process the main fragment with an empty initial context - let mut context = WebUIProcessContext { - protocol, - state, - depth: 0, - writer, - local_vars: HashMap::new(), - component_attrs: HashMap::new(), - }; - self.process_fragment_id(main_fragment_id, &mut context)?; - - // Finalize the output - writer.end()?; - - Ok(()) - } - - /// Process a fragment by its ID. - /// - /// The `context` parameter contains scope-local variables that are accessible during rendering, - /// such as loop iteration variables. This is separate from the global `state`. - fn process_fragment_id( - &mut self, - fragment_id: &str, - context: &mut WebUIProcessContext, - ) -> Result<()> { - if let Some(fragment_list) = context.protocol.fragments.get(fragment_id) { - self.process_fragment(&fragment_list.fragments, context) - } else { - Err(HandlerError::MissingFragment(fragment_id.to_string())) - } - } - - /// Process a vector of fragments. - /// - /// The `context` maintains scope-specific variables that can be accessed by fragments - /// during rendering, while `state` contains the global application state. - fn process_fragment( - &mut self, - fragments: &[WebUIFragment], - context: &mut WebUIProcessContext, - ) -> Result<()> { - for item in fragments { - match item.fragment.as_ref() { - Some(Fragment::Raw(raw)) => { - context.writer.write(&raw.value)?; - } - Some(Fragment::Component(component)) => { - self.process_component(component, context)?; - } - Some(Fragment::ForLoop(for_loop)) => { - self.process_for_loop(for_loop, context)?; - } - Some(Fragment::Signal(signal)) => { - self.process_signal(signal, context)?; - } - Some(Fragment::IfCond(if_cond)) => { - self.process_if(if_cond, context)?; - } - Some(Fragment::Attribute(attr)) => { - self.process_attribute(attr, context)?; - } - Some(Fragment::Plugin(plugin_frag)) => { - if let Some(p) = &mut self.plugin { - p.on_plugin_data(&plugin_frag.data, context.writer)?; - } - } - None => {} - } - } - Ok(()) - } - - /// Process a component fragment. - fn process_component( - &mut self, - component: &webui_protocol::WebUIFragmentComponent, - context: &mut WebUIProcessContext, - ) -> Result<()> { - // Save parent scope - let saved_local_vars = std::mem::take(&mut context.local_vars); - let saved_component_attrs = std::mem::take(&mut context.component_attrs); - - // Component gets accumulated attrs as its local vars - context.local_vars = saved_component_attrs; - - if let Some(p) = &mut self.plugin { - p.push_scope(); - } - - self.process_fragment_id(&component.fragment_id, context)?; - - if let Some(p) = &mut self.plugin { - p.pop_scope(); - } - - // Restore parent scope - context.local_vars = saved_local_vars; - context.component_attrs = HashMap::new(); - - Ok(()) - } - - /// Resolve a dotted path value, checking local variables first, then global state. - fn resolve_value(&self, path: &str, context: &WebUIProcessContext) -> Option { - // Check local vars first - if let Some(first_part) = path.split('.').next() { - if let Some(local_value) = context.local_vars.get(first_part) { - if !path.contains('.') { - return Some(local_value.clone()); - } - let remaining = &path[first_part.len() + 1..]; - if let Some(v) = find_value_by_dotted_path(remaining, local_value) { - return Some(v); - } - } - } - // Fall back to global state - find_value_by_dotted_path(path, context.state) - } - - /// Evaluate a condition expression, merging local variables into state. - /// Returns false if the condition references a missing value. - /// When local and global state share a key and both are objects, their properties - /// are deep-merged so that the local value takes precedence per-property while - /// global-only properties remain accessible (matching NodeJS behaviour). - fn evaluate_condition( - &self, - condition: &webui_protocol::ConditionExpr, - context: &WebUIProcessContext, - ) -> Result { - let merged_state = if context.local_vars.is_empty() { - context.state.clone() - } else { - let mut merged = context.state.clone(); - if let Value::Object(map) = &mut merged { - for (k, v) in &context.local_vars { - if let (Some(Value::Object(existing)), Value::Object(local_obj)) = - (map.get(k), v) - { - // Deep merge: start with global, overlay local - let mut merged_obj = existing.clone(); - for (lk, lv) in local_obj { - merged_obj.insert(lk.clone(), lv.clone()); - } - map.insert(k.clone(), Value::Object(merged_obj)); - } else { - map.insert(k.clone(), v.clone()); - } - } - } - merged - }; - match evaluate(condition, &merged_state) { - Ok(result) => Ok(result), - Err(ExpressionError::MissingValue(_)) => Ok(false), - Err(e) => Err(HandlerError::Evaluation(e.to_string())), - } - } - - /// Process a for loop fragment. - /// - /// Creates a new context for each iteration that includes the current loop item. - /// This allows nested templates to access both the loop variable and any parent context. - /// Example: `for item in items` makes "item" available in the loop body. - fn process_for_loop( - &mut self, - for_loop: &webui_protocol::WebUIFragmentFor, - context: &mut WebUIProcessContext, - ) -> Result<()> { - let collection_name = &for_loop.collection; - - // If the collection is missing, treat it as empty (0 iterations) — matches NodeJS behavior. - // Hydration comments are always emitted regardless of collection presence. - let items = match self.resolve_value(collection_name, context) { - Some(Value::Array(arr)) => arr, - Some(_) => { - return Err(HandlerError::TypeError(format!( - "Collection '{}' is not an array", - collection_name - ))) - } - None => Vec::new(), - }; - - if let Some(p) = &mut self.plugin { - p.on_binding_start(&for_loop.fragment_id, context.writer)?; - } - - let item_name = &for_loop.item; - for (i, item) in items.into_iter().enumerate() { - if let Some(p) = &mut self.plugin { - p.on_repeat_item_start(i, context.writer)?; - p.push_scope(); - } - - let saved_vars = context.local_vars.clone(); - context.local_vars.insert(item_name.clone(), item); - self.process_fragment_id(&for_loop.fragment_id, context)?; - context.local_vars = saved_vars; - - if let Some(p) = &mut self.plugin { - p.pop_scope(); - p.on_repeat_item_end(i, context.writer)?; - } - } - - if let Some(p) = &mut self.plugin { - p.on_binding_end(&for_loop.fragment_id, context.writer)?; - } - - Ok(()) - } - - /// Process a signal fragment. - /// - /// Looks up the value in the context first (for local variables), then in the global state. - /// This prioritization allows local variables (like loop items) to override global state. - /// If the value is not found in either scope, an empty string is returned. - fn process_signal( - &mut self, - signal: &webui_protocol::WebUIFragmentSignal, - context: &mut WebUIProcessContext, - ) -> Result<()> { - if let Some(p) = &mut self.plugin { - p.on_binding_start(&signal.value, context.writer)?; - } - - if let Some(value) = self.resolve_value(&signal.value, context) { - let content = self.format_signal_value(&value, signal.raw)?; - context.writer.write(&content)?; - } - - if let Some(p) = &mut self.plugin { - p.on_binding_end(&signal.value, context.writer)?; - } - Ok(()) - } - - /// Helper function to format a signal value based on the raw flag - fn format_signal_value(&self, value: &Value, raw: bool) -> Result { - let result = if raw { - // Raw HTML content - match value { - Value::String(s) => s.clone(), - _ => value.to_string(), - } - } else { - // Escaped HTML content - match value { - Value::String(s) => html_escape::encode_safe(s).to_string(), - _ => html_escape::encode_safe(&value.to_string()).to_string(), - } - }; - Ok(result) - } - - /// Process an if condition fragment. - fn process_if( - &mut self, - if_cond: &webui_protocol::WebUIFragmentIf, - context: &mut WebUIProcessContext, - ) -> Result<()> { - let condition = if_cond - .condition - .as_ref() - .ok_or_else(|| HandlerError::Rendering("If fragment missing condition".to_string()))?; - let condition_met = self.evaluate_condition(condition, context)?; - - if let Some(p) = &mut self.plugin { - p.on_binding_start(&if_cond.fragment_id, context.writer)?; - } - - if condition_met { - if let Some(p) = &mut self.plugin { - p.push_scope(); - } - - self.process_fragment_id(&if_cond.fragment_id, context)?; - - if let Some(p) = &mut self.plugin { - p.pop_scope(); - } - } - - if let Some(p) = &mut self.plugin { - p.on_binding_end(&if_cond.fragment_id, context.writer)?; - } - - Ok(()) - } - - /// Process an attribute fragment by rendering the attribute name/value pair. - fn process_attribute( - &mut self, - attr: &webui_protocol::WebUIFragmentAttribute, - context: &mut WebUIProcessContext, - ) -> Result<()> { - // Initialize component attribute accumulator on attrStart - if attr.attr_start { - context.component_attrs = HashMap::new(); - } - - // Boolean attribute with condition tree - if let Some(condition) = &attr.condition_tree { - let condition_met = self.evaluate_condition(condition, context)?; - - if !attr.attr_skip { - let name = component_attr_name(&attr.name); - context - .component_attrs - .insert(name, Value::Bool(condition_met)); - } - - if condition_met { - context.writer.write(&format!(" {}", attr.name))?; - } - return Ok(()); - } - - // Template attribute (mixed static + dynamic) - if !attr.template.is_empty() { - let raw_value = self.render_template_attr_value(&attr.template, context)?; - let escaped = html_escape::encode_safe(&raw_value); - context - .writer - .write(&format!(" {}=\"{}\"", attr.name, escaped))?; - - if !attr.attr_skip { - let name = component_attr_name(&attr.name); - context - .component_attrs - .insert(name, Value::String(raw_value)); - } - return Ok(()); - } - - // Simple attribute - if !attr.value.is_empty() { - if attr.raw_value { - // Static attribute — value is the literal string - context - .writer - .write(&format!(" {}=\"{}\"", attr.name, attr.value))?; - if !attr.attr_skip { - let name = component_attr_name(&attr.name); - context - .component_attrs - .insert(name, Value::String(attr.value.clone())); - } - } else if attr.complex { - // Complex attribute — resolve value, don't render to HTML, store as state - if let Some(value) = self.resolve_value(&attr.value, context) { - if !attr.attr_skip { - let stripped = attr.name.strip_prefix(':').unwrap_or(&attr.name); - let name = component_attr_name(stripped); - context.component_attrs.insert(name, value); - } - } - } else { - // Dynamic attribute — resolve and render - if let Some(value) = self.resolve_value(&attr.value, context) { - let formatted = match &value { - Value::String(s) => html_escape::encode_safe(s).to_string(), - Value::Number(n) => n.to_string(), - Value::Bool(b) => b.to_string(), - Value::Null => String::new(), - _ => value.to_string(), - }; - context - .writer - .write(&format!(" {}=\"{}\"", attr.name, formatted))?; - - if !attr.attr_skip { - let name = component_attr_name(&attr.name); - context.component_attrs.insert(name, value); - } - } - } - } - - Ok(()) - } - - /// Render a template attribute's fragments into a raw (unescaped) string. - fn render_template_attr_value( - &mut self, - template_id: &str, - context: &WebUIProcessContext, - ) -> Result { - let fragments = context - .protocol - .fragments - .get(template_id) - .ok_or_else(|| HandlerError::MissingFragment(template_id.to_string()))?; - let mut raw_value = String::new(); - for frag in &fragments.fragments { - match frag.fragment.as_ref() { - Some(Fragment::Raw(raw)) => raw_value.push_str(&raw.value), - Some(Fragment::Signal(signal)) => { - if let Some(value) = self.resolve_value(&signal.value, context) { - match &value { - Value::String(s) => raw_value.push_str(s), - _ => raw_value.push_str(&value.to_string()), - } - } - } - _ => {} - } - } - Ok(raw_value) - } - - /// Render the UI based on the protocol and state - pub fn render( - &mut self, - protocol: &WebUIProtocol, - state: &Value, - writer: &mut dyn ResponseWriter, - ) -> Result<()> { - let mut context = WebUIProcessContext { - protocol, - state, - depth: 0, - writer, - local_vars: HashMap::new(), - component_attrs: HashMap::new(), - }; - - self.process_fragment_id("index.html", &mut context) - } -} - -impl Default for WebUIHandler { - fn default() -> Self { - Self::new() - } -} - -/// Process a WebUI protocol with the provided state and write the output to the given writer. -/// This is the main entry point for the WebUI handler. -pub fn handle( - protocol: &WebUIProtocol, - state: &Value, - writer: &mut dyn ResponseWriter, -) -> Result<()> { - let mut handler = WebUIHandler::new(); - handler.handle(protocol, state, writer) -} - -#[cfg(test)] -mod tests { - use super::*; - use std::cell::RefCell; - use webui_protocol::{ - web_ui_fragment, ComparisonOperator, ConditionExpr, FragmentList, LogicalOperator, - WebUIFragmentAttribute, - }; - use webui_test_utils::test_json; - - // A simple test writer implementation - struct TestWriter { - content: RefCell, - ended: RefCell, - } - - impl TestWriter { - fn new() -> Self { - Self { - content: RefCell::new(String::new()), - ended: RefCell::new(false), - } - } - - fn get_content(&self) -> String { - self.content.borrow().clone() - } - - fn is_ended(&self) -> bool { - *self.ended.borrow() - } - } - - impl ResponseWriter for TestWriter { - fn write(&mut self, content: &str) -> Result<()> { - self.content.borrow_mut().push_str(content); - Ok(()) - } - - fn end(&mut self) -> Result<()> { - *self.ended.borrow_mut() = true; - Ok(()) - } - } - - #[test] - fn test_handle_raw() { - // Create a simple protocol - let mut fragments = HashMap::new(); - fragments.insert( - "index.html".to_string(), - FragmentList { - fragments: vec![WebUIFragment::raw("Hello, WebUI!")], - }, - ); - - let protocol = WebUIProtocol { fragments }; - let state = test_json!({}); - - // Create a test writer - let mut writer = TestWriter::new(); - - // Handle the protocol - assert!( - handle(&protocol, &state, &mut writer).is_ok(), - "Failed to handle raw protocol" - ); - - // Check the output - assert_eq!(writer.get_content(), "Hello, WebUI!"); - assert!(writer.is_ended()); - } - - #[test] - fn test_handle_signal() { - // Create a protocol with a signal - let mut fragments = HashMap::new(); - fragments.insert( - "index.html".to_string(), - FragmentList { - fragments: vec![ - WebUIFragment::raw("Hello, "), - WebUIFragment::signal("name", false), - WebUIFragment::raw("!"), - ], - }, - ); - - let protocol = WebUIProtocol { fragments }; - let state = test_json!({"name": "WebUI"}); - - // Create a test writer - let mut writer = TestWriter::new(); - - // Handle the protocol - assert!( - handle(&protocol, &state, &mut writer).is_ok(), - "Failed to handle signal protocol" - ); - - // Check the output - assert_eq!(writer.get_content(), "Hello, WebUI!"); - assert!(writer.is_ended()); - } - - #[test] - fn test_handle_for_loop() { - // Create a protocol with a for loop - let mut fragments = HashMap::new(); - fragments.insert( - "index.html".to_string(), - FragmentList { - fragments: vec![ - WebUIFragment::raw("People: "), - WebUIFragment::for_loop("person", "people", "person-item"), - ], - }, - ); - - fragments.insert( - "person-item".to_string(), - FragmentList { - fragments: vec![ - WebUIFragment::signal("person.name", false), - WebUIFragment::raw(", "), - ], - }, - ); - - let protocol = WebUIProtocol { fragments }; - let state = test_json!({ - "people": [ - {"name": "Alice"}, - {"name": "Bob"}, - {"name": "Charlie"} - ] - }); - - // Create a test writer - let mut writer = TestWriter::new(); - - // Handle the protocol - assert!( - handle(&protocol, &state, &mut writer).is_ok(), - "Failed to handle for loop protocol" - ); - - // Check the output - assert_eq!(writer.get_content(), "People: Alice, Bob, Charlie, "); - assert!(writer.is_ended()); - } - - #[test] - fn test_handle_if_condition() { - // Create a protocol with an if condition - let mut fragments = HashMap::new(); - fragments.insert( - "index.html".to_string(), - FragmentList { - fragments: vec![ - WebUIFragment::raw("Status: "), - WebUIFragment::if_cond( - webui_protocol::ConditionExpr::identifier("isActive"), - "active-content", - ), - WebUIFragment::raw("End"), - ], - }, - ); - - fragments.insert( - "active-content".to_string(), - FragmentList { - fragments: vec![WebUIFragment::raw("Active")], - }, - ); - - let protocol = WebUIProtocol { fragments }; - - // Test with isActive = true - let state_true = test_json!({"isActive": true}); - let mut writer_true = TestWriter::new(); - assert!( - handle(&protocol, &state_true, &mut writer_true).is_ok(), - "Failed to handle if condition (true case)" - ); - assert_eq!(writer_true.get_content(), "Status: ActiveEnd"); - assert!(writer_true.is_ended()); - - // Test with isActive = false - let state_false = test_json!({"isActive": false}); - let mut writer_false = TestWriter::new(); - assert!( - handle(&protocol, &state_false, &mut writer_false).is_ok(), - "Failed to handle if condition (false case)" - ); - assert_eq!(writer_false.get_content(), "Status: End"); - assert!(writer_false.is_ended()); - } - - #[test] - fn test_handle_component() { - // Create a protocol with a component - let mut fragments = HashMap::new(); - fragments.insert( - "index.html".to_string(), - FragmentList { - fragments: vec![ - WebUIFragment::raw("Component: "), - WebUIFragment::component("my-component"), - ], - }, - ); - - fragments.insert( - "my-component".to_string(), - FragmentList { - fragments: vec![WebUIFragment::raw("
Component Content
")], - }, - ); - - let protocol = WebUIProtocol { fragments }; - let state = test_json!({}); - - // Create a test writer - let mut writer = TestWriter::new(); - - // Handle the protocol - assert!( - handle(&protocol, &state, &mut writer).is_ok(), - "Failed to handle component protocol" - ); - - // Check the output - assert_eq!( - writer.get_content(), - "Component:
Component Content
" - ); - assert!(writer.is_ended()); - } - - #[test] - fn test_missing_fragment() { - // Create a protocol with a missing fragment reference - let mut fragments = HashMap::new(); - fragments.insert( - "index.html".to_string(), - FragmentList { - fragments: vec![WebUIFragment::component("missing-component")], - }, - ); - - let protocol = WebUIProtocol { fragments }; - let state = test_json!({}); - - // Create a test writer - let mut writer = TestWriter::new(); - - // Handle the protocol - let result = handle(&protocol, &state, &mut writer); - - // Expect an error - assert!(result.is_err()); - if let Err(HandlerError::MissingFragment(fragment_id)) = result { - assert_eq!(fragment_id, "missing-component"); - } else { - panic!("Expected MissingFragment error"); - } - } - - #[test] - fn test_missing_signal_renders_empty() { - // A signal referencing a field absent from state should render as empty - let mut fragments = HashMap::new(); - fragments.insert( - "index.html".to_string(), - FragmentList { - fragments: vec![ - WebUIFragment::raw("Hello, "), - WebUIFragment::signal("missing_field", false), - WebUIFragment::raw("!"), - ], - }, - ); - - let protocol = WebUIProtocol { fragments }; - let state = test_json!({}); - - let mut writer = TestWriter::new(); - - assert!( - handle(&protocol, &state, &mut writer).is_ok(), - "Missing signal should not produce an error" - ); - - assert_eq!(writer.get_content(), "Hello, !"); - assert!(writer.is_ended()); - } - - // ── Boolean attribute rendering tests ───────────────────────────── - - #[test] - fn test_boolean_attr_true() { - let mut fragments = HashMap::new(); - fragments.insert( - "index.html".to_string(), - FragmentList { - fragments: vec![ - WebUIFragment::raw("Click"), - ], - }, - ); - let protocol = WebUIProtocol { fragments }; - let state = test_json!({"isDisabled": true}); - let mut writer = TestWriter::new(); - handle(&protocol, &state, &mut writer).unwrap(); - assert_eq!(writer.get_content(), ""); - } - - #[test] - fn test_boolean_attr_false() { - let mut fragments = HashMap::new(); - fragments.insert( - "index.html".to_string(), - FragmentList { - fragments: vec![ - WebUIFragment::raw("Click"), - ], - }, - ); - let protocol = WebUIProtocol { fragments }; - let state = test_json!({"isDisabled": false}); - let mut writer = TestWriter::new(); - handle(&protocol, &state, &mut writer).unwrap(); - assert_eq!(writer.get_content(), ""); - } - - #[test] - fn test_boolean_attr_missing() { - let mut fragments = HashMap::new(); - fragments.insert( - "index.html".to_string(), - FragmentList { - fragments: vec![ - WebUIFragment::raw(""), - ], - }, - ); - let protocol = WebUIProtocol { fragments }; - let state = test_json!({}); - let mut writer = TestWriter::new(); - handle(&protocol, &state, &mut writer).unwrap(); - assert_eq!(writer.get_content(), ""); - } - - #[test] - fn test_boolean_attr_multiple() { - let mut fragments = HashMap::new(); - fragments.insert( - "index.html".to_string(), - FragmentList { - fragments: vec![ - WebUIFragment::raw(""), - ], - }, - ); - let protocol = WebUIProtocol { fragments }; - let state = test_json!({"checked": true, "disabled": false}); - let mut writer = TestWriter::new(); - handle(&protocol, &state, &mut writer).unwrap(); - assert_eq!(writer.get_content(), ""); - } - - // ── Simple attribute rendering tests ────────────────────────────── - - #[test] - fn test_attribute_with_value() { - let mut fragments = HashMap::new(); - fragments.insert( - "index.html".to_string(), - FragmentList { - fragments: vec![ - WebUIFragment::raw(""), - ], - }, - ); - let protocol = WebUIProtocol { fragments }; - let state = test_json!({"inputValue": "Hello"}); - let mut writer = TestWriter::new(); - handle(&protocol, &state, &mut writer).unwrap(); - assert_eq!(writer.get_content(), ""); - } - - #[test] - fn test_attribute_with_falsy_numeric() { - let mut fragments = HashMap::new(); - fragments.insert( - "index.html".to_string(), - FragmentList { - fragments: vec![ - WebUIFragment::raw("
"), - ], - }, - ); - let protocol = WebUIProtocol { fragments }; - let state = test_json!({"number": 0}); - let mut writer = TestWriter::new(); - handle(&protocol, &state, &mut writer).unwrap(); - assert_eq!( - writer.get_content(), - "
" - ); - } - - // ── Template attribute rendering tests ──────────────────────────── - - #[test] - fn test_mixed_attribute_template() { - let mut fragments = HashMap::new(); - fragments.insert( - "index.html".to_string(), - FragmentList { - fragments: vec![ - WebUIFragment::raw(""), - ], - }, - ); - fragments.insert( - "attr-1".to_string(), - FragmentList { - fragments: vec![ - WebUIFragment::raw("hello "), - WebUIFragment::signal("item", false), - ], - }, - ); - let protocol = WebUIProtocol { fragments }; - let state = test_json!({"item": "world"}); - let mut writer = TestWriter::new(); - handle(&protocol, &state, &mut writer).unwrap(); - assert_eq!(writer.get_content(), ""); - } - - // ── Raw signal rendering test ───────────────────────────────────── - - #[test] - fn test_raw_signal_not_escaped() { - let mut fragments = HashMap::new(); - fragments.insert( - "index.html".to_string(), - FragmentList { - fragments: vec![ - WebUIFragment::signal("html", false), - WebUIFragment::signal("html", true), - ], - }, - ); - let protocol = WebUIProtocol { fragments }; - let state = test_json!({"html": "hi"}); - let mut writer = TestWriter::new(); - handle(&protocol, &state, &mut writer).unwrap(); - assert_eq!( - writer.get_content(), - "<strong>hi</strong>hi" - ); - } - - // ── Nested for loop tests ───────────────────────────────────────── - - #[test] - fn test_nested_for_loop() { - let mut fragments = HashMap::new(); - fragments.insert( - "index.html".to_string(), - FragmentList { - fragments: vec![ - WebUIFragment::raw("
"), - WebUIFragment::for_loop("outerItem", "outerItems", "outer"), - WebUIFragment::raw("
"), - ], - }, - ); - fragments.insert( - "outer".to_string(), - FragmentList { - fragments: vec![ - WebUIFragment::raw("
"), - WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "inner"), - WebUIFragment::raw("
"), - ], - }, - ); - fragments.insert( - "inner".to_string(), - FragmentList { - fragments: vec![WebUIFragment::raw("Inner")], - }, - ); - let protocol = WebUIProtocol { fragments }; - let state = test_json!({ - "outerItems": [ - {"innerItems": [{"name": "A"}, {"name": "B"}]}, - {"innerItems": [{"name": "C"}]} - ] - }); - let mut writer = TestWriter::new(); - handle(&protocol, &state, &mut writer).unwrap(); - assert_eq!( - writer.get_content(), - "
InnerInner
Inner
" - ); - } - - #[test] - fn test_nested_for_with_signals() { - let mut fragments = HashMap::new(); - fragments.insert( - "index.html".to_string(), - FragmentList { - fragments: vec![ - WebUIFragment::raw("
"), - WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"), - WebUIFragment::raw("
"), - ], - }, - ); - fragments.insert( - "outerTemplate".to_string(), - FragmentList { - fragments: vec![ - WebUIFragment::raw("
"), - WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"), - WebUIFragment::raw("
"), - ], - }, - ); - fragments.insert( - "innerTemplate".to_string(), - FragmentList { - fragments: vec![ - WebUIFragment::raw(""), - WebUIFragment::signal("innerItem.name", false), - WebUIFragment::raw(""), - ], - }, - ); - let protocol = WebUIProtocol { fragments }; - let state = test_json!({ - "outerItems": [ - {"innerItems": [{"name": "Item1"}, {"name": "Item2"}]}, - {"innerItems": [{"name": "Item3"}, {"name": "Item4"}]} - ] - }); - let mut writer = TestWriter::new(); - handle(&protocol, &state, &mut writer).unwrap(); - assert_eq!( - writer.get_content(), - "
Item1Item2
Item3Item4
" - ); - } - - #[test] - fn test_nested_for_with_global_state() { - let mut fragments = HashMap::new(); - fragments.insert( - "index.html".to_string(), - FragmentList { - fragments: vec![ - WebUIFragment::raw("
"), - WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"), - WebUIFragment::raw("
"), - ], - }, - ); - fragments.insert( - "outerTemplate".to_string(), - FragmentList { - fragments: vec![ - WebUIFragment::raw("
"), - WebUIFragment::signal("globalOuter", false), - WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"), - WebUIFragment::raw("
"), - ], - }, - ); - fragments.insert( - "innerTemplate".to_string(), - FragmentList { - fragments: vec![ - WebUIFragment::raw(""), - WebUIFragment::signal("innerItem.name", false), - WebUIFragment::signal("globalInner", false), - WebUIFragment::raw(""), - ], - }, - ); - let protocol = WebUIProtocol { fragments }; - let state = test_json!({ - "globalOuter": "GO", - "globalInner": "GI", - "outerItems": [ - {"innerItems": [{"name": "Item1"}, {"name": "Item2"}]}, - {"innerItems": [{"name": "Item3"}, {"name": "Item4"}]} - ] - }); - let mut writer = TestWriter::new(); - handle(&protocol, &state, &mut writer).unwrap(); - assert_eq!( - writer.get_content(), - "
GOItem1GIItem2GI
GOItem3GIItem4GI
" - ); - } - - // ── For + If state scoping tests ────────────────────────────────── - - #[test] - fn test_if_in_for_uses_local_state() { - let mut fragments = HashMap::new(); - fragments.insert( - "index.html".to_string(), - FragmentList { - fragments: vec![WebUIFragment::for_loop("item", "items", "item-tpl")], - }, - ); - fragments.insert( - "item-tpl".to_string(), - FragmentList { - fragments: vec![WebUIFragment::if_cond( - ConditionExpr::identifier("item.visible"), - "visible-tpl", - )], - }, - ); - fragments.insert( - "visible-tpl".to_string(), - FragmentList { - fragments: vec![WebUIFragment::signal("item.name", false)], - }, - ); - let protocol = WebUIProtocol { fragments }; - let state = test_json!({"items": [{"name": "Show", "visible": true}, {"name": "Hide", "visible": false}]}); - let mut writer = TestWriter::new(); - handle(&protocol, &state, &mut writer).unwrap(); - assert_eq!(writer.get_content(), "Show"); - } - - #[test] - fn test_for_if_local_overrides_global() { - let mut fragments = HashMap::new(); - fragments.insert( - "index.html".to_string(), - FragmentList { - fragments: vec![WebUIFragment::for_loop("item", "items", "item-tpl")], - }, - ); - fragments.insert( - "item-tpl".to_string(), - FragmentList { - fragments: vec![WebUIFragment::if_cond( - ConditionExpr::identifier("item.flag"), - "show-tpl", - )], - }, - ); - fragments.insert( - "show-tpl".to_string(), - FragmentList { - fragments: vec![WebUIFragment::raw("yes")], - }, - ); - let protocol = WebUIProtocol { fragments }; - // Global flag is true, but local item.flag is false for second item - let state = test_json!({"flag": true, "items": [{"flag": true}, {"flag": false}]}); - let mut writer = TestWriter::new(); - handle(&protocol, &state, &mut writer).unwrap(); - assert_eq!(writer.get_content(), "yes"); - } - - // ── Component attribute state tests ─────────────────────────────── - - #[test] - fn test_component_attr_state_simple() { - let mut fragments = HashMap::new(); - fragments.insert( - "index.html".to_string(), - FragmentList { - fragments: vec![ - WebUIFragment::raw(""), - WebUIFragment::component("my-comp"), - WebUIFragment::raw(""), - ], - }, - ); - fragments.insert( - "my-comp".to_string(), - FragmentList { - fragments: vec![ - WebUIFragment::raw(""), - WebUIFragment::signal("title", false), - WebUIFragment::raw(""), - ], - }, - ); - let protocol = WebUIProtocol { fragments }; - let state = test_json!({"title": "Global Title"}); - let mut writer = TestWriter::new(); - handle(&protocol, &state, &mut writer).unwrap(); - assert_eq!( - writer.get_content(), - "Attribute Title" - ); - } - - #[test] - fn test_component_attr_state_template() { - let mut fragments = HashMap::new(); - fragments.insert( - "index.html".to_string(), - FragmentList { - fragments: vec![ - WebUIFragment::raw(""), - WebUIFragment::component("my-comp"), - WebUIFragment::raw(""), - ], - }, - ); - fragments.insert( - "title-attr".to_string(), - FragmentList { - fragments: vec![ - WebUIFragment::raw("hello "), - WebUIFragment::signal("item", false), - ], - }, - ); - fragments.insert( - "my-comp".to_string(), - FragmentList { - fragments: vec![ - WebUIFragment::raw(""), - WebUIFragment::signal("title", false), - WebUIFragment::raw(""), - ], - }, - ); - let protocol = WebUIProtocol { fragments }; - let state = test_json!({"item": ""}); - let mut writer = TestWriter::new(); - handle(&protocol, &state, &mut writer).unwrap(); - assert_eq!( - writer.get_content(), - "hello <world>" - ); - } - - #[test] - fn test_component_attr_camel_case() { - let mut fragments = HashMap::new(); - fragments.insert( - "index.html".to_string(), - FragmentList { - fragments: vec![ - WebUIFragment::raw(""), - WebUIFragment::component("my-comp"), - WebUIFragment::raw(""), - ], - }, - ); - fragments.insert( - "dt-attr".to_string(), - FragmentList { - fragments: vec![ - WebUIFragment::raw("prefix "), - WebUIFragment::signal("item", false), - ], - }, - ); - fragments.insert( - "my-comp".to_string(), - FragmentList { - fragments: vec![ - WebUIFragment::raw(""), - WebUIFragment::signal("dataTitle", false), - WebUIFragment::raw(""), - ], - }, - ); - let protocol = WebUIProtocol { fragments }; - let state = test_json!({"item": "a&b"}); - let mut writer = TestWriter::new(); - handle(&protocol, &state, &mut writer).unwrap(); - assert_eq!( - writer.get_content(), - "prefix a&b" - ); - } - - #[test] - fn test_component_complex_attr() { - let mut fragments = HashMap::new(); - fragments.insert( - "index.html".to_string(), - FragmentList { - fragments: vec![ - WebUIFragment::raw(""), - WebUIFragment::component("my-comp"), - WebUIFragment::raw(""), - ], - }, - ); - fragments.insert( - "my-comp".to_string(), - FragmentList { - fragments: vec![ - WebUIFragment::raw(""), - WebUIFragment::signal("item.foo", false), - WebUIFragment::raw("

"), - WebUIFragment::signal("item.bar", false), - WebUIFragment::raw("

"), - ], - }, - ); - let protocol = WebUIProtocol { fragments }; - let state = test_json!({"complexItem": {"foo": 1, "bar": "true"}}); - let mut writer = TestWriter::new(); - handle(&protocol, &state, &mut writer).unwrap(); - assert_eq!( - writer.get_content(), - "1

true

" - ); - } - - #[test] - fn test_component_no_parent_pollution() { - let mut fragments = HashMap::new(); - fragments.insert( - "index.html".to_string(), - FragmentList { - fragments: vec![ - WebUIFragment::raw(""), - WebUIFragment::component("parent"), - WebUIFragment::raw(""), - ], - }, - ); - fragments.insert( - "parent".to_string(), - FragmentList { - fragments: vec![ - WebUIFragment::raw("Before: "), - WebUIFragment::signal("var", false), - WebUIFragment::raw(""), - WebUIFragment::component("child"), - WebUIFragment::raw("LabelAfter: "), - WebUIFragment::signal("var", false), - ], - }, - ); - fragments.insert("child".to_string(), FragmentList { fragments: vec![] }); - let protocol = WebUIProtocol { fragments }; - let state = test_json!({"var": "original"}); - let mut writer = TestWriter::new(); - handle(&protocol, &state, &mut writer).unwrap(); - assert_eq!( - writer.get_content(), - "Before: originalLabelAfter: original" - ); - } - - #[test] - fn test_component_boolean_attr_state() { - let mut fragments = HashMap::new(); - fragments.insert( - "index.html".to_string(), - FragmentList { - fragments: vec![ - WebUIFragment::raw(""), - WebUIFragment::component("my-comp"), - WebUIFragment::raw(""), - ], - }, - ); - fragments.insert( - "my-comp".to_string(), - FragmentList { - fragments: vec![WebUIFragment::if_cond( - ConditionExpr::identifier("disabled"), - "show", - )], - }, - ); - fragments.insert( - "show".to_string(), - FragmentList { - fragments: vec![WebUIFragment::raw("disabled!")], - }, - ); - let protocol = WebUIProtocol { fragments }; - let state = test_json!({"isDisabled": true}); - let mut writer = TestWriter::new(); - handle(&protocol, &state, &mut writer).unwrap(); - assert_eq!( - writer.get_content(), - "disabled!" - ); - } - - // ===== HTML Escape Tests (ported from utils.test.js escapeHtml) ===== - - /// Helper: render a signal value through the handler and return the escaped output. - fn render_signal(value: &str) -> String { - let mut fragments = HashMap::new(); - fragments.insert( - "index.html".to_string(), - FragmentList { - fragments: vec![WebUIFragment::signal("v", false)], - }, - ); - let protocol = WebUIProtocol { fragments }; - let state = test_json!({"v": value}); - let mut writer = TestWriter::new(); - handle(&protocol, &state, &mut writer).unwrap(); - writer.get_content() - } - - #[test] - fn test_escape_ampersand() { - assert_eq!(render_signal("&"), "&"); - } - - #[test] - fn test_escape_less_than() { - assert_eq!(render_signal("<"), "<"); - } - - #[test] - fn test_escape_greater_than() { - assert_eq!(render_signal(">"), ">"); - } - - #[test] - fn test_escape_double_quote() { - assert_eq!(render_signal("\""), """); - } - - #[test] - fn test_escape_single_quote() { - // html_escape::encode_safe escapes ' as ' - let result = render_signal("'"); - assert!( - result == "'" || result == "'" || result == "'", - "Expected escaped single quote, got: {}", - result - ); - } - - #[test] - fn test_escape_multiple_special_chars() { - let result = render_signal(""); - assert!( - result.contains("<") && result.contains(">"), - "Expected escaped HTML, got: {}", - result - ); - assert!( - !result.contains(""); + assert!( + result.contains("<") && result.contains(">"), + "Expected escaped HTML, got: {}", + result + ); + assert!( + !result.contains(""}"#; - let result = render_to_string(&proto, state).expect("render should succeed"); - assert!(!result.contains(""}"#; + let result = render_to_string(&proto, state).expect("render should succeed"); + assert!(!result.contains(""), - ], - }, - ); - - // App component — header + list + footer - fragments.insert( - "app".to_string(), - FragmentList { - fragments: vec![ - WebUIFragment::raw("

"), - WebUIFragment::signal("title", false), - WebUIFragment::raw("

"), - WebUIFragment::signal("remainingCount", false), - WebUIFragment::raw(" remaining
    "), - WebUIFragment::for_loop("item", "items", "item-frag"), - WebUIFragment::raw("
"), - WebUIFragment::if_cond(ConditionExpr::identifier("showFooter"), "footer-frag"), - WebUIFragment::raw("
"), - ], - }, - ); - - // Item fragment — renders each todo item - fragments.insert( - "item-frag".to_string(), - FragmentList { - fragments: vec![ - WebUIFragment::raw(""), - WebUIFragment::signal("item.title", false), - WebUIFragment::if_cond( - ConditionExpr::predicate("item.state", ComparisonOperator::Equal, "'done'"), - "done-badge", - ), - WebUIFragment::raw( - "", - ), - ], - }, - ); - - // Item class template - fragments.insert( - "item-class-tmpl".to_string(), - FragmentList { - fragments: vec![ - WebUIFragment::raw("todo-item "), - WebUIFragment::signal("item.state", false), - ], - }, - ); - - // Done badge - fragments.insert( - "done-badge".to_string(), - FragmentList { - fragments: vec![WebUIFragment::raw("")], - }, - ); - - // Footer - fragments.insert( - "footer-frag".to_string(), - FragmentList { - fragments: vec![ - WebUIFragment::raw("

"), - WebUIFragment::signal("footerText", false), - WebUIFragment::raw("

Help
"), - ], - }, - ); - - WebUIProtocol { fragments } -} - -fn create_large_protocol(component_count: usize) -> WebUIProtocol { - let mut fragments = HashMap::new(); - - // Root: nav + main with all components - let mut root_frags = Vec::with_capacity(component_count * 2 + 4); - root_frags.push(WebUIFragment::raw("
")); - - for idx in 0..component_count { - let frag_id = format!("panel-{idx}"); - root_frags.push(WebUIFragment::component(&frag_id)); - } - - root_frags.push(WebUIFragment::raw("
")); - - fragments.insert( - "index.html".to_string(), - FragmentList { - fragments: root_frags, - }, - ); - - // Nav link fragment - fragments.insert( - "nav-link-frag".to_string(), - FragmentList { - fragments: vec![ - WebUIFragment::raw(""), - WebUIFragment::signal("link.label", false), - WebUIFragment::raw(""), - ], - }, - ); - - // Generate panel components - for idx in 0..component_count { - let panel_id = format!("panel-{idx}"); - let body_id = format!("panel-body-{idx}"); - let cond_id = format!("panel-detail-{idx}"); - - fragments.insert( - panel_id, - FragmentList { - fragments: vec![ - WebUIFragment::raw(&format!("
")), - WebUIFragment::raw("

"), - WebUIFragment::signal("title", false), - WebUIFragment::raw("

"), - WebUIFragment::component(&body_id), - WebUIFragment::if_cond(ConditionExpr::identifier("showDetails"), &cond_id), - WebUIFragment::raw("
"), - ], - }, - ); - - fragments.insert( - body_id, - FragmentList { - fragments: vec![ - WebUIFragment::raw("

"), - WebUIFragment::signal("description", false), - WebUIFragment::raw("

"), - WebUIFragment::signal("metric", false), - WebUIFragment::raw("
"), - ], - }, - ); - - fragments.insert( - cond_id, - FragmentList { - fragments: vec![ - WebUIFragment::raw("
More

"), - WebUIFragment::signal("details", false), - WebUIFragment::raw("

"), - ], - }, - ); - } - - WebUIProtocol { fragments } -} - -fn serialize_medium_benchmark(c: &mut Criterion) { - let protocol = create_medium_protocol(); - c.bench_function("serialize_medium_protobuf", |b| { - b.iter(|| black_box(&protocol).to_protobuf()) - }); -} - -fn deserialize_medium_benchmark(c: &mut Criterion) { - let protocol = create_medium_protocol(); - let bytes = protocol.to_protobuf().expect("encode failed"); - c.bench_function("deserialize_medium_protobuf", |b| { - b.iter(|| WebUIProtocol::from_protobuf(black_box(&bytes))) - }); -} - -fn protocol_size_sweep_bench(c: &mut Criterion) { - let mut group = c.benchmark_group("protocol_size_sweep"); - - for &count in &[5usize, 15, 30, 50] { - let protocol = create_large_protocol(count); - let bytes = protocol.to_protobuf().expect("encode failed"); - group.throughput(Throughput::Bytes(bytes.len() as u64)); - - group.bench_with_input( - BenchmarkId::new("serialize", count), - &protocol, - |b, proto| { - b.iter(|| black_box(proto).to_protobuf()); - }, - ); - - group.bench_with_input(BenchmarkId::new("deserialize", count), &bytes, |b, data| { - b.iter(|| WebUIProtocol::from_protobuf(black_box(data))); - }); - } - - group.finish(); -} - -criterion_group!( - benches, - serialize_protobuf_benchmark, - deserialize_protobuf_benchmark, - complex_condition_benchmark, - serialize_medium_benchmark, - deserialize_medium_benchmark, - protocol_size_sweep_bench -); -criterion_main!(benches); +use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion, Throughput}; +use std::collections::HashMap; +use std::hint::black_box; +use webui_protocol::{ + ComparisonOperator, ConditionExpr, FragmentList, LogicalOperator, WebUIFragment, WebUIProtocol, +}; + +#[allow(dead_code)] +fn create_test_protocol() -> WebUIProtocol { + let mut fragments = HashMap::new(); + + fragments.insert( + "index.html".to_string(), + FragmentList { + fragments: vec![ + WebUIFragment::raw("Hello, WebUI!\n"), + WebUIFragment::for_loop("person", "people", "for-1"), + WebUIFragment::signal("description", true), + WebUIFragment::if_cond(ConditionExpr::identifier("contact"), "if-1"), + ], + }, + ); + + fragments.insert( + "for-1".to_string(), + FragmentList { + fragments: vec![WebUIFragment::signal("person.name", false)], + }, + ); + + fragments.insert( + "if-1".to_string(), + FragmentList { + fragments: vec![WebUIFragment::component("contact-card")], + }, + ); + + fragments.insert( + "contact-card".to_string(), + FragmentList { + fragments: vec![ + WebUIFragment::raw("Hello, "), + WebUIFragment::signal("name", false), + ], + }, + ); + + WebUIProtocol::new(fragments) +} + +fn create_simple_protocol() -> WebUIProtocol { + let mut fragments = HashMap::new(); + + fragments.insert( + "index.html".to_string(), + FragmentList { + fragments: vec![ + WebUIFragment::raw("Hello, WebUI!\n"), + WebUIFragment::for_loop("person", "people", "for-1"), + ], + }, + ); + + fragments.insert( + "for-1".to_string(), + FragmentList { + fragments: vec![WebUIFragment::signal("person.name", false)], + }, + ); + + WebUIProtocol::new(fragments) +} + +fn serialize_protobuf_benchmark(c: &mut Criterion) { + let protocol = create_simple_protocol(); + + c.bench_function("serialize_protobuf", |b| { + b.iter(|| black_box(&protocol).to_protobuf()) + }); +} + +fn deserialize_protobuf_benchmark(c: &mut Criterion) { + let protocol = create_simple_protocol(); + let bytes = protocol.to_protobuf().expect("encode failed"); + + c.bench_function("deserialize_protobuf", |b| { + b.iter(|| WebUIProtocol::from_protobuf(black_box(&bytes))) + }); +} + +fn complex_condition_benchmark(c: &mut Criterion) { + let nested = ConditionExpr::compound( + ConditionExpr::predicate("user.role", ComparisonOperator::Equal, "admin"), + LogicalOperator::And, + ConditionExpr::negated(ConditionExpr::predicate( + "user.disabled", + ComparisonOperator::Equal, + "true", + )), + ); + + let mut fragments = HashMap::new(); + fragments.insert( + "main".to_string(), + FragmentList { + fragments: vec![WebUIFragment::if_cond(nested, "then")], + }, + ); + fragments.insert( + "then".to_string(), + FragmentList { + fragments: vec![WebUIFragment::raw("ok")], + }, + ); + let protocol = WebUIProtocol::new(fragments); + let bytes = protocol.to_protobuf().expect("encode failed"); + + c.bench_function("deserialize_complex_condition", |b| { + b.iter(|| WebUIProtocol::from_protobuf(black_box(&bytes))) + }); +} + +fn create_medium_protocol() -> WebUIProtocol { + let mut fragments = HashMap::new(); + + // Root page — head + body structure + fragments.insert( + "index.html".to_string(), + FragmentList { + fragments: vec![ + WebUIFragment::raw(""), + WebUIFragment::signal("title", false), + WebUIFragment::raw(""), + WebUIFragment::component("app"), + WebUIFragment::raw(""), + ], + }, + ); + + // App component — header + list + footer + fragments.insert( + "app".to_string(), + FragmentList { + fragments: vec![ + WebUIFragment::raw("

"), + WebUIFragment::signal("title", false), + WebUIFragment::raw("

"), + WebUIFragment::signal("remainingCount", false), + WebUIFragment::raw(" remaining
    "), + WebUIFragment::for_loop("item", "items", "item-frag"), + WebUIFragment::raw("
"), + WebUIFragment::if_cond(ConditionExpr::identifier("showFooter"), "footer-frag"), + WebUIFragment::raw("
"), + ], + }, + ); + + // Item fragment — renders each todo item + fragments.insert( + "item-frag".to_string(), + FragmentList { + fragments: vec![ + WebUIFragment::raw(""), + WebUIFragment::signal("item.title", false), + WebUIFragment::if_cond( + ConditionExpr::predicate("item.state", ComparisonOperator::Equal, "'done'"), + "done-badge", + ), + WebUIFragment::raw( + "", + ), + ], + }, + ); + + // Item class template + fragments.insert( + "item-class-tmpl".to_string(), + FragmentList { + fragments: vec![ + WebUIFragment::raw("todo-item "), + WebUIFragment::signal("item.state", false), + ], + }, + ); + + // Done badge + fragments.insert( + "done-badge".to_string(), + FragmentList { + fragments: vec![WebUIFragment::raw("")], + }, + ); + + // Footer + fragments.insert( + "footer-frag".to_string(), + FragmentList { + fragments: vec![ + WebUIFragment::raw("

"), + WebUIFragment::signal("footerText", false), + WebUIFragment::raw("

Help
"), + ], + }, + ); + + WebUIProtocol::new(fragments) +} + +fn create_large_protocol(component_count: usize) -> WebUIProtocol { + let mut fragments = HashMap::new(); + + // Root: nav + main with all components + let mut root_frags = Vec::with_capacity(component_count * 2 + 4); + root_frags.push(WebUIFragment::raw("
")); + + for idx in 0..component_count { + let frag_id = format!("panel-{idx}"); + root_frags.push(WebUIFragment::component(&frag_id)); + } + + root_frags.push(WebUIFragment::raw("
")); + + fragments.insert( + "index.html".to_string(), + FragmentList { + fragments: root_frags, + }, + ); + + // Nav link fragment + fragments.insert( + "nav-link-frag".to_string(), + FragmentList { + fragments: vec![ + WebUIFragment::raw(""), + WebUIFragment::signal("link.label", false), + WebUIFragment::raw(""), + ], + }, + ); + + // Generate panel components + for idx in 0..component_count { + let panel_id = format!("panel-{idx}"); + let body_id = format!("panel-body-{idx}"); + let cond_id = format!("panel-detail-{idx}"); + + fragments.insert( + panel_id, + FragmentList { + fragments: vec![ + WebUIFragment::raw(&format!("
")), + WebUIFragment::raw("

"), + WebUIFragment::signal("title", false), + WebUIFragment::raw("

"), + WebUIFragment::component(&body_id), + WebUIFragment::if_cond(ConditionExpr::identifier("showDetails"), &cond_id), + WebUIFragment::raw("
"), + ], + }, + ); + + fragments.insert( + body_id, + FragmentList { + fragments: vec![ + WebUIFragment::raw("

"), + WebUIFragment::signal("description", false), + WebUIFragment::raw("

"), + WebUIFragment::signal("metric", false), + WebUIFragment::raw("
"), + ], + }, + ); + + fragments.insert( + cond_id, + FragmentList { + fragments: vec![ + WebUIFragment::raw("
More

"), + WebUIFragment::signal("details", false), + WebUIFragment::raw("

"), + ], + }, + ); + } + + WebUIProtocol::new(fragments) +} + +fn serialize_medium_benchmark(c: &mut Criterion) { + let protocol = create_medium_protocol(); + c.bench_function("serialize_medium_protobuf", |b| { + b.iter(|| black_box(&protocol).to_protobuf()) + }); +} + +fn deserialize_medium_benchmark(c: &mut Criterion) { + let protocol = create_medium_protocol(); + let bytes = protocol.to_protobuf().expect("encode failed"); + c.bench_function("deserialize_medium_protobuf", |b| { + b.iter(|| WebUIProtocol::from_protobuf(black_box(&bytes))) + }); +} + +fn protocol_size_sweep_bench(c: &mut Criterion) { + let mut group = c.benchmark_group("protocol_size_sweep"); + + for &count in &[5usize, 15, 30, 50] { + let protocol = create_large_protocol(count); + let bytes = protocol.to_protobuf().expect("encode failed"); + group.throughput(Throughput::Bytes(bytes.len() as u64)); + + group.bench_with_input( + BenchmarkId::new("serialize", count), + &protocol, + |b, proto| { + b.iter(|| black_box(proto).to_protobuf()); + }, + ); + + group.bench_with_input(BenchmarkId::new("deserialize", count), &bytes, |b, data| { + b.iter(|| WebUIProtocol::from_protobuf(black_box(data))); + }); + } + + group.finish(); +} + +criterion_group!( + benches, + serialize_protobuf_benchmark, + deserialize_protobuf_benchmark, + complex_condition_benchmark, + serialize_medium_benchmark, + deserialize_medium_benchmark, + protocol_size_sweep_bench +); +criterion_main!(benches); diff --git a/crates/webui-protocol/proto/webui.proto b/crates/webui-protocol/proto/webui.proto index 359c6d5b..027b08e5 100644 --- a/crates/webui-protocol/proto/webui.proto +++ b/crates/webui-protocol/proto/webui.proto @@ -5,6 +5,10 @@ package webui; // Root protocol containing all fragment records. message WebUIProtocol { map fragments = 1; + // Sorted, deduplicated CSS custom property names used across all components + // and entry page styles (e.g., "colorBrandBackground", "spacingMedium"). + // Only usage via var(--name) is included; local definitions are excluded. + repeated string tokens = 2; } // A list of fragments (needed because protobuf maps cannot have repeated values directly). diff --git a/crates/webui-protocol/src/lib.rs b/crates/webui-protocol/src/lib.rs index a95375c5..1f1b60ab 100644 --- a/crates/webui-protocol/src/lib.rs +++ b/crates/webui-protocol/src/lib.rs @@ -1,663 +1,713 @@ -//! WebUI Protocol implementation. -//! -//! This crate defines the protocol used by the WebUI framework for cross-platform -//! representation of UI components and templates. Types are generated directly -//! from `proto/webui.proto` using prost for optimal runtime performance — -//! no conversion layer between domain types and protobuf types. - -use prost::Message; -use std::collections::HashMap; -use std::fmt; -use std::io; -use thiserror::Error; - -/// Generated protobuf types from `proto/webui.proto`. -pub mod proto { - include!(concat!(env!("OUT_DIR"), "/webui.rs")); -} - -// Re-export all generated types at the crate root. -pub use proto::*; - -// Type aliases preserving the `WebUI` naming convention. -// prost generates `WebUi*` from the proto `WebUI*` messages. -pub type WebUIProtocol = WebUiProtocol; -pub type WebUIFragment = WebUiFragment; -pub type WebUIFragmentRaw = WebUiFragmentRaw; -pub type WebUIFragmentComponent = WebUiFragmentComponent; -pub type WebUIFragmentFor = WebUiFragmentFor; -pub type WebUIFragmentSignal = WebUiFragmentSignal; -pub type WebUIFragmentIf = WebUiFragmentIf; -pub type WebUIFragmentAttribute = WebUiFragmentAttribute; -pub type WebUIFragmentPlugin = WebUiFragmentPlugin; - -/// A mapping of unique fragment identifiers to their corresponding fragment lists. -pub type WebUIFragmentRecords = HashMap; - -#[derive(Debug, Error)] -pub enum ProtocolError { - #[error("IO error: {0}")] - Io(#[from] io::Error), - - #[error("Protocol validation error: {0}")] - Validation(String), -} - -pub type Result = std::result::Result; - -// ── Display implementations ───────────────────────────────────────────── - -impl fmt::Display for ComparisonOperator { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - ComparisonOperator::GreaterThan => write!(f, ">"), - ComparisonOperator::LessThan => write!(f, "<"), - ComparisonOperator::Equal => write!(f, "=="), - ComparisonOperator::NotEqual => write!(f, "!="), - ComparisonOperator::GreaterThanOrEqual => write!(f, ">="), - ComparisonOperator::LessThanOrEqual => write!(f, "<="), - ComparisonOperator::Unspecified => write!(f, "?"), - } - } -} - -impl fmt::Display for LogicalOperator { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - LogicalOperator::And => write!(f, "&&"), - LogicalOperator::Or => write!(f, "||"), - LogicalOperator::Unspecified => write!(f, "?"), - } - } -} - -impl fmt::Display for ConditionExpr { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match &self.expr { - Some(condition_expr::Expr::Identifier(id)) => write!(f, "{}", id.value), - Some(condition_expr::Expr::Predicate(pred)) => { - let op = ComparisonOperator::try_from(pred.operator) - .unwrap_or(ComparisonOperator::Unspecified); - write!(f, "{} {} {}", pred.left, op, pred.right) - } - Some(condition_expr::Expr::Not(not)) => match ¬.condition { - Some(inner) => write!(f, "!({})", inner), - None => write!(f, "!(?)"), - }, - Some(condition_expr::Expr::Compound(compound)) => { - let op = - LogicalOperator::try_from(compound.op).unwrap_or(LogicalOperator::Unspecified); - let left_str = compound - .left - .as_ref() - .map(|l| l.to_string()) - .unwrap_or_else(|| "?".to_string()); - let right_str = compound - .right - .as_ref() - .map(|r| r.to_string()) - .unwrap_or_else(|| "?".to_string()); - write!(f, "({} {} {})", left_str, op, right_str) - } - None => write!(f, ""), - } - } -} - -// ── Convenience constructors ──────────────────────────────────────────── - -impl WebUiFragment { - /// Create a raw (static content) fragment. - pub fn raw(value: impl Into) -> Self { - Self { - fragment: Some(web_ui_fragment::Fragment::Raw(WebUiFragmentRaw { - value: value.into(), - })), - } - } - - /// Create a component fragment. - pub fn component(fragment_id: impl Into) -> Self { - Self { - fragment: Some(web_ui_fragment::Fragment::Component( - WebUiFragmentComponent { - fragment_id: fragment_id.into(), - }, - )), - } - } - - /// Create a for-loop fragment. - pub fn for_loop( - item: impl Into, - collection: impl Into, - fragment_id: impl Into, - ) -> Self { - Self { - fragment: Some(web_ui_fragment::Fragment::ForLoop(WebUiFragmentFor { - item: item.into(), - collection: collection.into(), - fragment_id: fragment_id.into(), - })), - } - } - - /// Create a signal fragment. - pub fn signal(value: impl Into, raw: bool) -> Self { - Self { - fragment: Some(web_ui_fragment::Fragment::Signal(WebUiFragmentSignal { - value: value.into(), - raw, - })), - } - } - - /// Create an if-condition fragment. - pub fn if_cond(condition: ConditionExpr, fragment_id: impl Into) -> Self { - Self { - fragment: Some(web_ui_fragment::Fragment::IfCond(WebUiFragmentIf { - condition: Some(condition), - fragment_id: fragment_id.into(), - })), - } - } - - /// Create a simple dynamic attribute fragment (value is a single signal name). - pub fn attribute(name: impl Into, value: impl Into) -> Self { - Self { - fragment: Some(web_ui_fragment::Fragment::Attribute( - WebUiFragmentAttribute { - name: name.into(), - value: value.into(), - ..Default::default() - }, - )), - } - } - - /// Create a template attribute fragment (mixed static + dynamic content). - pub fn attribute_template(name: impl Into, template: impl Into) -> Self { - Self { - fragment: Some(web_ui_fragment::Fragment::Attribute( - WebUiFragmentAttribute { - name: name.into(), - template: template.into(), - ..Default::default() - }, - )), - } - } - - /// Create a complex attribute fragment (:-prefixed). - pub fn attribute_complex(name: impl Into, value: impl Into) -> Self { - Self { - fragment: Some(web_ui_fragment::Fragment::Attribute( - WebUiFragmentAttribute { - name: name.into(), - value: value.into(), - complex: true, - ..Default::default() - }, - )), - } - } - - /// Create a boolean attribute fragment (?-prefixed) with a condition tree. - pub fn attribute_boolean(name: impl Into, condition_tree: ConditionExpr) -> Self { - Self { - fragment: Some(web_ui_fragment::Fragment::Attribute( - WebUiFragmentAttribute { - name: name.into(), - condition_tree: Some(condition_tree), - ..Default::default() - }, - )), - } - } - - /// Create a plugin data fragment with opaque bytes. - /// The data is passed through to the handler plugin without interpretation. - pub fn plugin(data: Vec) -> Self { - Self { - fragment: Some(web_ui_fragment::Fragment::Plugin(WebUiFragmentPlugin { - data, - })), - } - } -} - -impl ConditionExpr { - /// Create an identifier condition. - pub fn identifier(value: impl Into) -> Self { - Self { - expr: Some(condition_expr::Expr::Identifier(IdentifierCondition { - value: value.into(), - })), - } - } - - /// Create a predicate condition. - pub fn predicate( - left: impl Into, - operator: ComparisonOperator, - right: impl Into, - ) -> Self { - Self { - expr: Some(condition_expr::Expr::Predicate(Predicate { - left: left.into(), - operator: operator as i32, - right: right.into(), - })), - } - } - - /// Create a negation condition. - pub fn negated(inner: ConditionExpr) -> Self { - Self { - expr: Some(condition_expr::Expr::Not(Box::new(NotCondition { - condition: Some(Box::new(inner)), - }))), - } - } - - /// Create a compound condition. - pub fn compound(left: ConditionExpr, op: LogicalOperator, right: ConditionExpr) -> Self { - Self { - expr: Some(condition_expr::Expr::Compound(Box::new( - CompoundCondition { - left: Some(Box::new(left)), - op: op as i32, - right: Some(Box::new(right)), - }, - ))), - } - } -} - -// ── Serialization / deserialization / validation ──────────────────────── - -impl WebUiProtocol { - /// Validate that all fragment references point to existing fragment IDs. - fn validate_protocol(protocol: Self) -> Result { - let fragments = &protocol.fragments; - - let invalid_ref = fragments.iter().find_map(|(_, fragment_list)| { - fragment_list - .fragments - .iter() - .find_map(|frag| match frag.fragment.as_ref() { - Some(web_ui_fragment::Fragment::Component(comp)) - if !fragments.contains_key(&comp.fragment_id) => - { - Some(ProtocolError::Validation(format!( - "Component references non-existent fragment ID: {}", - comp.fragment_id - ))) - } - Some(web_ui_fragment::Fragment::ForLoop(fl)) - if !fragments.contains_key(&fl.fragment_id) => - { - Some(ProtocolError::Validation(format!( - "For loop references non-existent fragment ID: {}", - fl.fragment_id - ))) - } - Some(web_ui_fragment::Fragment::IfCond(ic)) - if !fragments.contains_key(&ic.fragment_id) => - { - Some(ProtocolError::Validation(format!( - "If condition references non-existent fragment ID: {}", - ic.fragment_id - ))) - } - Some(web_ui_fragment::Fragment::Attribute(attr)) - if !attr.template.is_empty() && !fragments.contains_key(&attr.template) => - { - Some(ProtocolError::Validation(format!( - "Attribute references non-existent template fragment ID: {}", - attr.template - ))) - } - _ => None, - }) - }); - - if let Some(err) = invalid_ref { - return Err(err); - } - - Ok(protocol) - } - - /// Serialize protocol to pretty JSON (for debug/inspect output only). - pub fn to_json_pretty(&self) -> std::result::Result { - serde_json::to_string_pretty(self) - } - - /// Serialize protocol to protobuf binary format. - pub fn to_protobuf(&self) -> Result> { - let len = self.encoded_len(); - let mut buf = Vec::with_capacity(len); - self.encode(&mut buf) - .map_err(|e| ProtocolError::Validation(format!("Protobuf encode error: {e}")))?; - Ok(buf) - } - - /// Deserialize protocol from protobuf binary bytes with validation. - pub fn from_protobuf(bytes: &[u8]) -> Result { - let protocol = Self::decode(bytes) - .map_err(|e| ProtocolError::Validation(format!("Protobuf decode error: {e}")))?; - Self::validate_protocol(protocol) - } - - /// Read and deserialize a protobuf file with validation. - pub fn from_protobuf_file>(path: P) -> Result { - let bytes = std::fs::read(path)?; - Self::from_protobuf(&bytes) - } - - /// Write protocol to a protobuf file. - pub fn to_protobuf_file>(&self, path: P) -> Result<()> { - let bytes = self.to_protobuf()?; - std::fs::write(path, bytes)?; - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn sample_protocol() -> WebUIProtocol { - let mut fragments = HashMap::new(); - fragments.insert( - "index.html".to_string(), - FragmentList { - fragments: vec![ - WebUIFragment::raw("Hello, WebUI!\n"), - WebUIFragment::for_loop("person", "people", "for-1"), - WebUIFragment::signal("description", true), - WebUIFragment::if_cond(ConditionExpr::identifier("contact"), "if-1"), - ], - }, - ); - fragments.insert( - "for-1".to_string(), - FragmentList { - fragments: vec![WebUIFragment::signal("person.name", false)], - }, - ); - fragments.insert( - "if-1".to_string(), - FragmentList { - fragments: vec![WebUIFragment::component("contact-card")], - }, - ); - fragments.insert( - "contact-card".to_string(), - FragmentList { - fragments: vec![ - WebUIFragment::raw("Hello, "), - WebUIFragment::signal("name", false), - ], - }, - ); - WebUIProtocol { fragments } - } - - #[test] - fn test_protobuf_roundtrip() { - let protocol = sample_protocol(); - let bytes = protocol.to_protobuf().expect("encode failed"); - let decoded = WebUIProtocol::from_protobuf(&bytes).expect("decode failed"); - assert_eq!(protocol, decoded); - } - - #[test] - fn test_protobuf_all_fragment_types() { - let mut fragments = HashMap::new(); - fragments.insert( - "main".to_string(), - FragmentList { - fragments: vec![ - WebUIFragment::raw("text"), - WebUIFragment::component("comp"), - WebUIFragment::for_loop("x", "xs", "loop"), - WebUIFragment::signal("sig", true), - WebUIFragment::if_cond( - ConditionExpr::predicate("a", ComparisonOperator::GreaterThan, "1"), - "cond", - ), - ], - }, - ); - fragments.insert( - "comp".to_string(), - FragmentList { - fragments: vec![WebUIFragment::raw("c")], - }, - ); - fragments.insert( - "loop".to_string(), - FragmentList { - fragments: vec![WebUIFragment::raw("l")], - }, - ); - fragments.insert( - "cond".to_string(), - FragmentList { - fragments: vec![WebUIFragment::raw("i")], - }, - ); - - let protocol = WebUIProtocol { fragments }; - let bytes = protocol.to_protobuf().unwrap(); - let decoded = WebUIProtocol::from_protobuf(&bytes).unwrap(); - assert_eq!(protocol, decoded); - } - - #[test] - fn test_protobuf_all_comparison_operators() { - let ops = [ - ComparisonOperator::GreaterThan, - ComparisonOperator::LessThan, - ComparisonOperator::Equal, - ComparisonOperator::NotEqual, - ComparisonOperator::GreaterThanOrEqual, - ComparisonOperator::LessThanOrEqual, - ]; - for op in &ops { - let mut fragments = HashMap::new(); - fragments.insert( - "main".to_string(), - FragmentList { - fragments: vec![WebUIFragment::if_cond( - ConditionExpr::predicate("a", *op, "b"), - "then", - )], - }, - ); - fragments.insert( - "then".to_string(), - FragmentList { - fragments: vec![WebUIFragment::raw("ok")], - }, - ); - let p = WebUIProtocol { fragments }; - let bytes = p.to_protobuf().unwrap(); - let decoded = WebUIProtocol::from_protobuf(&bytes).unwrap(); - assert_eq!(p, decoded); - } - } - - #[test] - fn test_protobuf_nested_conditions() { - let nested = ConditionExpr::compound( - ConditionExpr::predicate("user.role", ComparisonOperator::Equal, "admin"), - LogicalOperator::And, - ConditionExpr::negated(ConditionExpr::predicate( - "user.disabled", - ComparisonOperator::Equal, - "true", - )), - ); - - let mut fragments = HashMap::new(); - fragments.insert( - "main".to_string(), - FragmentList { - fragments: vec![WebUIFragment::if_cond(nested, "then")], - }, - ); - fragments.insert( - "then".to_string(), - FragmentList { - fragments: vec![WebUIFragment::raw("ok")], - }, - ); - let p = WebUIProtocol { fragments }; - let bytes = p.to_protobuf().unwrap(); - let decoded = WebUIProtocol::from_protobuf(&bytes).unwrap(); - assert_eq!(p, decoded); - } - - #[test] - fn test_protobuf_compound_or_condition() { - let compound = ConditionExpr::compound( - ConditionExpr::identifier("isAdmin"), - LogicalOperator::Or, - ConditionExpr::identifier("isEditor"), - ); - - let mut fragments = HashMap::new(); - fragments.insert( - "main".to_string(), - FragmentList { - fragments: vec![WebUIFragment::if_cond(compound, "body")], - }, - ); - fragments.insert( - "body".to_string(), - FragmentList { - fragments: vec![WebUIFragment::raw("yes")], - }, - ); - let p = WebUIProtocol { fragments }; - let bytes = p.to_protobuf().unwrap(); - let decoded = WebUIProtocol::from_protobuf(&bytes).unwrap(); - assert_eq!(p, decoded); - } - - #[test] - fn test_protobuf_invalid_bytes() { - let result = WebUIProtocol::from_protobuf(&[0xFF, 0xFF, 0xFF]); - assert!(result.is_err()); - } - - #[test] - fn test_protobuf_empty_bytes() { - let result = WebUIProtocol::from_protobuf(&[]); - assert!(result.is_ok()); - assert!(result.unwrap().fragments.is_empty()); - } - - #[test] - fn test_protobuf_file_roundtrip() { - let protocol = sample_protocol(); - let dir = std::env::temp_dir().join("webui-proto-test"); - std::fs::create_dir_all(&dir).unwrap(); - let path = dir.join("test.bin"); - - protocol.to_protobuf_file(&path).unwrap(); - let decoded = WebUIProtocol::from_protobuf_file(&path).unwrap(); - assert_eq!(protocol, decoded); - - std::fs::remove_dir_all(&dir).ok(); - } - - #[test] - fn test_protobuf_validation_catches_missing_reference() { - let mut fragments = HashMap::new(); - fragments.insert( - "main".to_string(), - FragmentList { - fragments: vec![WebUIFragment::component("does-not-exist")], - }, - ); - - let protocol = WebUIProtocol { fragments }; - let buf = protocol.to_protobuf().unwrap(); - - let result = WebUIProtocol::from_protobuf(&buf); - assert!(result.is_err()); - } - - #[test] - fn test_protobuf_validation_catches_missing_for_reference() { - let mut fragments = HashMap::new(); - fragments.insert( - "main".to_string(), - FragmentList { - fragments: vec![WebUIFragment::for_loop("item", "items", "missing-for")], - }, - ); - - let protocol = WebUIProtocol { fragments }; - let buf = protocol.to_protobuf().unwrap(); - - let result = WebUIProtocol::from_protobuf(&buf); - assert!(result.is_err()); - if let Err(ProtocolError::Validation(msg)) = result { - assert!(msg.contains("missing-for")); - } - } - - #[test] - fn test_protobuf_validation_catches_missing_if_reference() { - let mut fragments = HashMap::new(); - fragments.insert( - "main".to_string(), - FragmentList { - fragments: vec![WebUIFragment::if_cond( - ConditionExpr::identifier("flag"), - "missing-if", - )], - }, - ); - - let protocol = WebUIProtocol { fragments }; - let buf = protocol.to_protobuf().unwrap(); - - let result = WebUIProtocol::from_protobuf(&buf); - assert!(result.is_err()); - if let Err(ProtocolError::Validation(msg)) = result { - assert!(msg.contains("missing-if")); - } - } - - #[test] - fn test_protobuf_signal_default_raw_false() { - let mut fragments = HashMap::new(); - fragments.insert( - "main".to_string(), - FragmentList { - fragments: vec![WebUIFragment::signal("name", false)], - }, - ); - let p = WebUIProtocol { fragments }; - let bytes = p.to_protobuf().unwrap(); - let decoded = WebUIProtocol::from_protobuf(&bytes).unwrap(); - let frag = &decoded.fragments["main"].fragments[0]; - match frag.fragment.as_ref() { - Some(web_ui_fragment::Fragment::Signal(s)) => assert!(!s.raw), - _ => panic!("expected signal"), - } - } - - #[test] - fn test_protobuf_pre_allocated_buffer() { - let protocol = sample_protocol(); - let bytes = protocol.to_protobuf().unwrap(); - assert_eq!(bytes.len(), protocol.encoded_len()); - } -} +//! WebUI Protocol implementation. +//! +//! This crate defines the protocol used by the WebUI framework for cross-platform +//! representation of UI components and templates. Types are generated directly +//! from `proto/webui.proto` using prost for optimal runtime performance — +//! no conversion layer between domain types and protobuf types. + +use prost::Message; +use std::collections::HashMap; +use std::fmt; +use std::io; +use thiserror::Error; + +/// Generated protobuf types from `proto/webui.proto`. +pub mod proto { + include!(concat!(env!("OUT_DIR"), "/webui.rs")); +} + +// Re-export all generated types at the crate root. +pub use proto::*; + +// Type aliases preserving the `WebUI` naming convention. +// prost generates `WebUi*` from the proto `WebUI*` messages. +pub type WebUIProtocol = WebUiProtocol; +pub type WebUIFragment = WebUiFragment; +pub type WebUIFragmentRaw = WebUiFragmentRaw; +pub type WebUIFragmentComponent = WebUiFragmentComponent; +pub type WebUIFragmentFor = WebUiFragmentFor; +pub type WebUIFragmentSignal = WebUiFragmentSignal; +pub type WebUIFragmentIf = WebUiFragmentIf; +pub type WebUIFragmentAttribute = WebUiFragmentAttribute; +pub type WebUIFragmentPlugin = WebUiFragmentPlugin; + +/// A mapping of unique fragment identifiers to their corresponding fragment lists. +pub type WebUIFragmentRecords = HashMap; + +#[derive(Debug, Error)] +pub enum ProtocolError { + #[error("IO error: {0}")] + Io(#[from] io::Error), + + #[error("Protocol validation error: {0}")] + Validation(String), +} + +pub type Result = std::result::Result; + +// ── Display implementations ───────────────────────────────────────────── + +impl fmt::Display for ComparisonOperator { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ComparisonOperator::GreaterThan => write!(f, ">"), + ComparisonOperator::LessThan => write!(f, "<"), + ComparisonOperator::Equal => write!(f, "=="), + ComparisonOperator::NotEqual => write!(f, "!="), + ComparisonOperator::GreaterThanOrEqual => write!(f, ">="), + ComparisonOperator::LessThanOrEqual => write!(f, "<="), + ComparisonOperator::Unspecified => write!(f, "?"), + } + } +} + +impl fmt::Display for LogicalOperator { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + LogicalOperator::And => write!(f, "&&"), + LogicalOperator::Or => write!(f, "||"), + LogicalOperator::Unspecified => write!(f, "?"), + } + } +} + +impl fmt::Display for ConditionExpr { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match &self.expr { + Some(condition_expr::Expr::Identifier(id)) => write!(f, "{}", id.value), + Some(condition_expr::Expr::Predicate(pred)) => { + let op = ComparisonOperator::try_from(pred.operator) + .unwrap_or(ComparisonOperator::Unspecified); + write!(f, "{} {} {}", pred.left, op, pred.right) + } + Some(condition_expr::Expr::Not(not)) => match ¬.condition { + Some(inner) => write!(f, "!({})", inner), + None => write!(f, "!(?)"), + }, + Some(condition_expr::Expr::Compound(compound)) => { + let op = + LogicalOperator::try_from(compound.op).unwrap_or(LogicalOperator::Unspecified); + let left_str = compound + .left + .as_ref() + .map(|l| l.to_string()) + .unwrap_or_else(|| "?".to_string()); + let right_str = compound + .right + .as_ref() + .map(|r| r.to_string()) + .unwrap_or_else(|| "?".to_string()); + write!(f, "({} {} {})", left_str, op, right_str) + } + None => write!(f, ""), + } + } +} + +// ── Convenience constructors ──────────────────────────────────────────── + +impl WebUiFragment { + /// Create a raw (static content) fragment. + pub fn raw(value: impl Into) -> Self { + Self { + fragment: Some(web_ui_fragment::Fragment::Raw(WebUiFragmentRaw { + value: value.into(), + })), + } + } + + /// Create a component fragment. + pub fn component(fragment_id: impl Into) -> Self { + Self { + fragment: Some(web_ui_fragment::Fragment::Component( + WebUiFragmentComponent { + fragment_id: fragment_id.into(), + }, + )), + } + } + + /// Create a for-loop fragment. + pub fn for_loop( + item: impl Into, + collection: impl Into, + fragment_id: impl Into, + ) -> Self { + Self { + fragment: Some(web_ui_fragment::Fragment::ForLoop(WebUiFragmentFor { + item: item.into(), + collection: collection.into(), + fragment_id: fragment_id.into(), + })), + } + } + + /// Create a signal fragment. + pub fn signal(value: impl Into, raw: bool) -> Self { + Self { + fragment: Some(web_ui_fragment::Fragment::Signal(WebUiFragmentSignal { + value: value.into(), + raw, + })), + } + } + + /// Create an if-condition fragment. + pub fn if_cond(condition: ConditionExpr, fragment_id: impl Into) -> Self { + Self { + fragment: Some(web_ui_fragment::Fragment::IfCond(WebUiFragmentIf { + condition: Some(condition), + fragment_id: fragment_id.into(), + })), + } + } + + /// Create a simple dynamic attribute fragment (value is a single signal name). + pub fn attribute(name: impl Into, value: impl Into) -> Self { + Self { + fragment: Some(web_ui_fragment::Fragment::Attribute( + WebUiFragmentAttribute { + name: name.into(), + value: value.into(), + ..Default::default() + }, + )), + } + } + + /// Create a template attribute fragment (mixed static + dynamic content). + pub fn attribute_template(name: impl Into, template: impl Into) -> Self { + Self { + fragment: Some(web_ui_fragment::Fragment::Attribute( + WebUiFragmentAttribute { + name: name.into(), + template: template.into(), + ..Default::default() + }, + )), + } + } + + /// Create a complex attribute fragment (:-prefixed). + pub fn attribute_complex(name: impl Into, value: impl Into) -> Self { + Self { + fragment: Some(web_ui_fragment::Fragment::Attribute( + WebUiFragmentAttribute { + name: name.into(), + value: value.into(), + complex: true, + ..Default::default() + }, + )), + } + } + + /// Create a boolean attribute fragment (?-prefixed) with a condition tree. + pub fn attribute_boolean(name: impl Into, condition_tree: ConditionExpr) -> Self { + Self { + fragment: Some(web_ui_fragment::Fragment::Attribute( + WebUiFragmentAttribute { + name: name.into(), + condition_tree: Some(condition_tree), + ..Default::default() + }, + )), + } + } + + /// Create a plugin data fragment with opaque bytes. + /// The data is passed through to the handler plugin without interpretation. + pub fn plugin(data: Vec) -> Self { + Self { + fragment: Some(web_ui_fragment::Fragment::Plugin(WebUiFragmentPlugin { + data, + })), + } + } +} + +impl ConditionExpr { + /// Create an identifier condition. + pub fn identifier(value: impl Into) -> Self { + Self { + expr: Some(condition_expr::Expr::Identifier(IdentifierCondition { + value: value.into(), + })), + } + } + + /// Create a predicate condition. + pub fn predicate( + left: impl Into, + operator: ComparisonOperator, + right: impl Into, + ) -> Self { + Self { + expr: Some(condition_expr::Expr::Predicate(Predicate { + left: left.into(), + operator: operator as i32, + right: right.into(), + })), + } + } + + /// Create a negation condition. + pub fn negated(inner: ConditionExpr) -> Self { + Self { + expr: Some(condition_expr::Expr::Not(Box::new(NotCondition { + condition: Some(Box::new(inner)), + }))), + } + } + + /// Create a compound condition. + pub fn compound(left: ConditionExpr, op: LogicalOperator, right: ConditionExpr) -> Self { + Self { + expr: Some(condition_expr::Expr::Compound(Box::new( + CompoundCondition { + left: Some(Box::new(left)), + op: op as i32, + right: Some(Box::new(right)), + }, + ))), + } + } +} + +// ── Constructors ──────────────────────────────────────────────────────── + +impl WebUiProtocol { + /// Create a protocol from fragment records with no CSS tokens. + pub fn new(fragments: WebUIFragmentRecords) -> Self { + Self { + fragments, + tokens: Vec::new(), + } + } + + /// Create a protocol from fragment records with CSS tokens. + pub fn with_tokens(fragments: WebUIFragmentRecords, tokens: Vec) -> Self { + Self { fragments, tokens } + } +} + +// ── Serialization / deserialization / validation ──────────────────────── + +impl WebUiProtocol { + /// Validate that all fragment references point to existing fragment IDs. + fn validate_protocol(protocol: Self) -> Result { + let fragments = &protocol.fragments; + + let invalid_ref = fragments.iter().find_map(|(_, fragment_list)| { + fragment_list + .fragments + .iter() + .find_map(|frag| match frag.fragment.as_ref() { + Some(web_ui_fragment::Fragment::Component(comp)) + if !fragments.contains_key(&comp.fragment_id) => + { + Some(ProtocolError::Validation(format!( + "Component references non-existent fragment ID: {}", + comp.fragment_id + ))) + } + Some(web_ui_fragment::Fragment::ForLoop(fl)) + if !fragments.contains_key(&fl.fragment_id) => + { + Some(ProtocolError::Validation(format!( + "For loop references non-existent fragment ID: {}", + fl.fragment_id + ))) + } + Some(web_ui_fragment::Fragment::IfCond(ic)) + if !fragments.contains_key(&ic.fragment_id) => + { + Some(ProtocolError::Validation(format!( + "If condition references non-existent fragment ID: {}", + ic.fragment_id + ))) + } + Some(web_ui_fragment::Fragment::Attribute(attr)) + if !attr.template.is_empty() && !fragments.contains_key(&attr.template) => + { + Some(ProtocolError::Validation(format!( + "Attribute references non-existent template fragment ID: {}", + attr.template + ))) + } + _ => None, + }) + }); + + if let Some(err) = invalid_ref { + return Err(err); + } + + Ok(protocol) + } + + /// Serialize protocol to pretty JSON (for debug/inspect output only). + pub fn to_json_pretty(&self) -> std::result::Result { + serde_json::to_string_pretty(self) + } + + /// Serialize protocol to protobuf binary format. + pub fn to_protobuf(&self) -> Result> { + let len = self.encoded_len(); + let mut buf = Vec::with_capacity(len); + self.encode(&mut buf) + .map_err(|e| ProtocolError::Validation(format!("Protobuf encode error: {e}")))?; + Ok(buf) + } + + /// Deserialize protocol from protobuf binary bytes with validation. + pub fn from_protobuf(bytes: &[u8]) -> Result { + let protocol = Self::decode(bytes) + .map_err(|e| ProtocolError::Validation(format!("Protobuf decode error: {e}")))?; + Self::validate_protocol(protocol) + } + + /// Read and deserialize a protobuf file with validation. + pub fn from_protobuf_file>(path: P) -> Result { + let bytes = std::fs::read(path)?; + Self::from_protobuf(&bytes) + } + + /// Write protocol to a protobuf file. + pub fn to_protobuf_file>(&self, path: P) -> Result<()> { + let bytes = self.to_protobuf()?; + std::fs::write(path, bytes)?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_protocol() -> WebUIProtocol { + let mut fragments = HashMap::new(); + fragments.insert( + "index.html".to_string(), + FragmentList { + fragments: vec![ + WebUIFragment::raw("Hello, WebUI!\n"), + WebUIFragment::for_loop("person", "people", "for-1"), + WebUIFragment::signal("description", true), + WebUIFragment::if_cond(ConditionExpr::identifier("contact"), "if-1"), + ], + }, + ); + fragments.insert( + "for-1".to_string(), + FragmentList { + fragments: vec![WebUIFragment::signal("person.name", false)], + }, + ); + fragments.insert( + "if-1".to_string(), + FragmentList { + fragments: vec![WebUIFragment::component("contact-card")], + }, + ); + fragments.insert( + "contact-card".to_string(), + FragmentList { + fragments: vec![ + WebUIFragment::raw("Hello, "), + WebUIFragment::signal("name", false), + ], + }, + ); + WebUIProtocol::new(fragments) + } + + #[test] + fn test_protobuf_roundtrip() { + let protocol = sample_protocol(); + let bytes = protocol.to_protobuf().expect("encode failed"); + let decoded = WebUIProtocol::from_protobuf(&bytes).expect("decode failed"); + assert_eq!(protocol, decoded); + } + + #[test] + fn test_protobuf_all_fragment_types() { + let mut fragments = HashMap::new(); + fragments.insert( + "main".to_string(), + FragmentList { + fragments: vec![ + WebUIFragment::raw("text"), + WebUIFragment::component("comp"), + WebUIFragment::for_loop("x", "xs", "loop"), + WebUIFragment::signal("sig", true), + WebUIFragment::if_cond( + ConditionExpr::predicate("a", ComparisonOperator::GreaterThan, "1"), + "cond", + ), + ], + }, + ); + fragments.insert( + "comp".to_string(), + FragmentList { + fragments: vec![WebUIFragment::raw("c")], + }, + ); + fragments.insert( + "loop".to_string(), + FragmentList { + fragments: vec![WebUIFragment::raw("l")], + }, + ); + fragments.insert( + "cond".to_string(), + FragmentList { + fragments: vec![WebUIFragment::raw("i")], + }, + ); + + let protocol = WebUIProtocol::new(fragments); + let bytes = protocol.to_protobuf().unwrap(); + let decoded = WebUIProtocol::from_protobuf(&bytes).unwrap(); + assert_eq!(protocol, decoded); + } + + #[test] + fn test_protobuf_all_comparison_operators() { + let ops = [ + ComparisonOperator::GreaterThan, + ComparisonOperator::LessThan, + ComparisonOperator::Equal, + ComparisonOperator::NotEqual, + ComparisonOperator::GreaterThanOrEqual, + ComparisonOperator::LessThanOrEqual, + ]; + for op in &ops { + let mut fragments = HashMap::new(); + fragments.insert( + "main".to_string(), + FragmentList { + fragments: vec![WebUIFragment::if_cond( + ConditionExpr::predicate("a", *op, "b"), + "then", + )], + }, + ); + fragments.insert( + "then".to_string(), + FragmentList { + fragments: vec![WebUIFragment::raw("ok")], + }, + ); + let p = WebUIProtocol::new(fragments); + let bytes = p.to_protobuf().unwrap(); + let decoded = WebUIProtocol::from_protobuf(&bytes).unwrap(); + assert_eq!(p, decoded); + } + } + + #[test] + fn test_protobuf_nested_conditions() { + let nested = ConditionExpr::compound( + ConditionExpr::predicate("user.role", ComparisonOperator::Equal, "admin"), + LogicalOperator::And, + ConditionExpr::negated(ConditionExpr::predicate( + "user.disabled", + ComparisonOperator::Equal, + "true", + )), + ); + + let mut fragments = HashMap::new(); + fragments.insert( + "main".to_string(), + FragmentList { + fragments: vec![WebUIFragment::if_cond(nested, "then")], + }, + ); + fragments.insert( + "then".to_string(), + FragmentList { + fragments: vec![WebUIFragment::raw("ok")], + }, + ); + let p = WebUIProtocol::new(fragments); + let bytes = p.to_protobuf().unwrap(); + let decoded = WebUIProtocol::from_protobuf(&bytes).unwrap(); + assert_eq!(p, decoded); + } + + #[test] + fn test_protobuf_compound_or_condition() { + let compound = ConditionExpr::compound( + ConditionExpr::identifier("isAdmin"), + LogicalOperator::Or, + ConditionExpr::identifier("isEditor"), + ); + + let mut fragments = HashMap::new(); + fragments.insert( + "main".to_string(), + FragmentList { + fragments: vec![WebUIFragment::if_cond(compound, "body")], + }, + ); + fragments.insert( + "body".to_string(), + FragmentList { + fragments: vec![WebUIFragment::raw("yes")], + }, + ); + let p = WebUIProtocol::new(fragments); + let bytes = p.to_protobuf().unwrap(); + let decoded = WebUIProtocol::from_protobuf(&bytes).unwrap(); + assert_eq!(p, decoded); + } + + #[test] + fn test_protobuf_invalid_bytes() { + let result = WebUIProtocol::from_protobuf(&[0xFF, 0xFF, 0xFF]); + assert!(result.is_err()); + } + + #[test] + fn test_protobuf_empty_bytes() { + let result = WebUIProtocol::from_protobuf(&[]); + assert!(result.is_ok()); + assert!(result.unwrap().fragments.is_empty()); + } + + #[test] + fn test_protobuf_file_roundtrip() { + let protocol = sample_protocol(); + let dir = std::env::temp_dir().join("webui-proto-test"); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("test.bin"); + + protocol.to_protobuf_file(&path).unwrap(); + let decoded = WebUIProtocol::from_protobuf_file(&path).unwrap(); + assert_eq!(protocol, decoded); + + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn test_protobuf_validation_catches_missing_reference() { + let mut fragments = HashMap::new(); + fragments.insert( + "main".to_string(), + FragmentList { + fragments: vec![WebUIFragment::component("does-not-exist")], + }, + ); + + let protocol = WebUIProtocol::new(fragments); + let buf = protocol.to_protobuf().unwrap(); + + let result = WebUIProtocol::from_protobuf(&buf); + assert!(result.is_err()); + } + + #[test] + fn test_protobuf_validation_catches_missing_for_reference() { + let mut fragments = HashMap::new(); + fragments.insert( + "main".to_string(), + FragmentList { + fragments: vec![WebUIFragment::for_loop("item", "items", "missing-for")], + }, + ); + + let protocol = WebUIProtocol::new(fragments); + let buf = protocol.to_protobuf().unwrap(); + + let result = WebUIProtocol::from_protobuf(&buf); + assert!(result.is_err()); + if let Err(ProtocolError::Validation(msg)) = result { + assert!(msg.contains("missing-for")); + } + } + + #[test] + fn test_protobuf_validation_catches_missing_if_reference() { + let mut fragments = HashMap::new(); + fragments.insert( + "main".to_string(), + FragmentList { + fragments: vec![WebUIFragment::if_cond( + ConditionExpr::identifier("flag"), + "missing-if", + )], + }, + ); + + let protocol = WebUIProtocol::new(fragments); + let buf = protocol.to_protobuf().unwrap(); + + let result = WebUIProtocol::from_protobuf(&buf); + assert!(result.is_err()); + if let Err(ProtocolError::Validation(msg)) = result { + assert!(msg.contains("missing-if")); + } + } + + #[test] + fn test_protobuf_signal_default_raw_false() { + let mut fragments = HashMap::new(); + fragments.insert( + "main".to_string(), + FragmentList { + fragments: vec![WebUIFragment::signal("name", false)], + }, + ); + let p = WebUIProtocol::new(fragments); + let bytes = p.to_protobuf().unwrap(); + let decoded = WebUIProtocol::from_protobuf(&bytes).unwrap(); + let frag = &decoded.fragments["main"].fragments[0]; + match frag.fragment.as_ref() { + Some(web_ui_fragment::Fragment::Signal(s)) => assert!(!s.raw), + _ => panic!("expected signal"), + } + } + + #[test] + fn test_protobuf_pre_allocated_buffer() { + let protocol = sample_protocol(); + let bytes = protocol.to_protobuf().unwrap(); + assert_eq!(bytes.len(), protocol.encoded_len()); + } + + #[test] + fn test_protocol_new_has_empty_tokens() { + let protocol = WebUIProtocol::new(HashMap::new()); + assert!(protocol.tokens.is_empty()); + assert!(protocol.fragments.is_empty()); + } + + #[test] + fn test_protocol_with_tokens() { + let tokens = vec!["color-primary".to_string(), "spacing-m".to_string()]; + let protocol = WebUIProtocol::with_tokens(HashMap::new(), tokens.clone()); + assert_eq!(protocol.tokens, tokens); + } + + #[test] + fn test_protobuf_roundtrip_with_tokens() { + let mut fragments = HashMap::new(); + fragments.insert( + "index.html".to_string(), + FragmentList { + fragments: vec![WebUIFragment::raw("Hello")], + }, + ); + let tokens = vec!["border-radius-m".to_string(), "color-primary".to_string()]; + let protocol = WebUIProtocol::with_tokens(fragments, tokens.clone()); + + let bytes = protocol.to_protobuf().expect("encode failed"); + let decoded = WebUIProtocol::from_protobuf(&bytes).expect("decode failed"); + + assert_eq!(decoded.tokens, tokens); + assert!(decoded.fragments.contains_key("index.html")); + } +} diff --git a/crates/webui-wasm/src/lib.rs b/crates/webui-wasm/src/lib.rs index 9de9ff2b..a1c0f17b 100644 --- a/crates/webui-wasm/src/lib.rs +++ b/crates/webui-wasm/src/lib.rs @@ -1,363 +1,361 @@ -//! WebAssembly bindings for the WebUI framework. -//! -//! This crate exposes the WebUI rendering pipeline to JavaScript via `wasm-bindgen`, -//! powering the interactive playground in the documentation site. -//! -//! Two modes of operation: -//! - **`render`** — Takes a pre-built protocol (JSON) + state and renders HTML. -//! - **`build_and_render`** — Takes virtual files + state, parses and renders HTML -//! using the real `webui-parser` (same parser used by the CLI). - -use serde_json::Value; -use std::collections::HashMap; -use wasm_bindgen::prelude::*; -use webui_handler::plugin::FastHydrationPlugin; -use webui_handler::{ResponseWriter, WebUIHandler}; -use webui_parser::{CssStrategy, HtmlParser}; -use webui_protocol::WebUIProtocol; - -/// A simple string buffer for collecting rendered output. -struct StringWriter { - content: String, -} - -impl StringWriter { - fn with_capacity(cap: usize) -> Self { - Self { - content: String::with_capacity(cap), - } - } -} - -impl ResponseWriter for StringWriter { - fn write(&mut self, content: &str) -> webui_handler::Result<()> { - self.content.push_str(content); - Ok(()) - } - - fn end(&mut self) -> webui_handler::Result<()> { - Ok(()) - } -} - -/// Render a pre-built WebUI protocol with state data. -/// -/// # Arguments -/// -/// * `protocol_json` — JSON string of the serialized `WebUIProtocol`. -/// * `state_json` — JSON string of the state data. -/// * `plugin` — Optional plugin identifier (e.g., `"fast"`). -/// -/// # Returns -/// -/// The rendered HTML string, or throws a JS error on failure. -#[wasm_bindgen] -pub fn render( - protocol_json: &str, - state_json: &str, - plugin: Option, -) -> Result { - render_inner(protocol_json, state_json, plugin.as_deref()) - .map_err(|e| JsValue::from_str(&e.to_string())) -} - -/// Build and render a WebUI application from virtual files. -/// -/// Uses a lightweight pure-Rust parser suitable for the playground. -/// Handles signals, for-loops, if-conditions, components, and dynamic attributes. -/// -/// # Arguments -/// -/// * `files` — A JS object mapping filenames to their string content. -/// Example: `{ "index.html": "

{{title}}

", "my-card.html": "

" }` -/// * `state_json` — A JSON string of the state data to render with. -/// * `entry` — The entry HTML filename (e.g. `"index.html"`). -/// -/// # Returns -/// -/// The rendered HTML string, or throws a JS error on failure. -#[wasm_bindgen] -pub fn build_and_render(files: JsValue, state_json: &str, entry: &str) -> Result { - let files_map: HashMap = - serde_wasm_bindgen::from_value(files).map_err(|e| JsValue::from_str(&e.to_string()))?; - - build_and_render_inner(&files_map, state_json, entry) - .map_err(|e| JsValue::from_str(&e.to_string())) -} - -/// Build the protocol JSON from virtual files without rendering. -/// -/// Returns the serialized `WebUIProtocol` as a JSON string. -#[wasm_bindgen] -pub fn build_protocol(files: JsValue, entry: &str) -> Result { - let files_map: HashMap = - serde_wasm_bindgen::from_value(files).map_err(|e| JsValue::from_str(&e.to_string()))?; - - build_protocol_inner(&files_map, entry).map_err(|e| JsValue::from_str(&e.to_string())) -} - -fn build_protocol_inner( - files: &HashMap, - entry: &str, -) -> Result { - let protocol = parse_to_protocol(files, entry)?; - serde_json::to_string(&protocol).map_err(|e| BuildError::Protocol(e.to_string())) -} - -/// Create a handler with an optional plugin. -fn create_handler(plugin: Option<&str>) -> Result { - match plugin { - Some("fast") => Ok(WebUIHandler::with_plugin(Box::new( - FastHydrationPlugin::new(), - ))), - Some(unknown) => Err(BuildError::Render(format!("Unknown plugin: {unknown}"))), - None => Ok(WebUIHandler::new()), - } -} - -fn render_inner( - protocol_json: &str, - state_json: &str, - plugin: Option<&str>, -) -> Result { - let protocol: WebUIProtocol = - serde_json::from_str(protocol_json).map_err(|e| BuildError::Protocol(e.to_string()))?; - let state: Value = - serde_json::from_str(state_json).map_err(|e| BuildError::State(e.to_string()))?; - - let mut writer = StringWriter::with_capacity(1024); - let mut handler = create_handler(plugin)?; - handler - .render(&protocol, &state, &mut writer) - .map_err(|e| BuildError::Render(e.to_string()))?; - - Ok(writer.content) -} - -/// Core build-and-render implementation (testable without WASM). -pub(crate) fn build_and_render_inner( - files: &HashMap, - state_json: &str, - entry: &str, -) -> Result { - let protocol = parse_to_protocol(files, entry)?; - - let state: Value = - serde_json::from_str(state_json).map_err(|e| BuildError::State(e.to_string()))?; - - let mut writer = StringWriter::with_capacity(1024); - let mut handler = create_handler(None)?; - handler - .render(&protocol, &state, &mut writer) - .map_err(|e| BuildError::Render(e.to_string()))?; - - Ok(writer.content) -} - -/// Parse virtual files into a `WebUIProtocol` using the real `webui-parser`. -fn parse_to_protocol( - files: &HashMap, - entry: &str, -) -> Result { - let entry_html = files - .get(entry) - .ok_or_else(|| BuildError::MissingEntry(entry.to_string()))?; - - let mut parser = HtmlParser::new(); - parser.set_css_strategy(CssStrategy::Inline); - - // Register components from virtual files (no filesystem needed) - for (filename, content) in files { - if filename != entry && filename.ends_with(".html") { - let tag_name = filename.trim_end_matches(".html"); - if tag_name.contains('-') { - let css_key = format!("{tag_name}.css"); - let css = files.get(&css_key).map(|s| s.as_str()); - parser - .component_registry_mut() - .register_component(tag_name, content, css) - .map_err(|e| BuildError::Parse(e.to_string()))?; - } - } - } - - parser - .parse(entry, entry_html) - .map_err(|e| BuildError::Parse(e.to_string()))?; - - Ok(WebUIProtocol { - fragments: parser.into_fragment_records(), - }) -} - -/// Errors from the build-and-render pipeline. -#[derive(Debug, PartialEq)] -pub(crate) enum BuildError { - MissingEntry(String), - Parse(String), - Protocol(String), - State(String), - Render(String), -} - -impl std::fmt::Display for BuildError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - BuildError::MissingEntry(name) => write!(f, "Entry file '{name}' not found"), - BuildError::Parse(msg) => write!(f, "Parse error: {msg}"), - BuildError::Protocol(msg) => write!(f, "Protocol JSON error: {msg}"), - BuildError::State(msg) => write!(f, "State JSON error: {msg}"), - BuildError::Render(msg) => write!(f, "Render error: {msg}"), - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_simple_render() { - let mut files = HashMap::new(); - files.insert( - "index.html".to_string(), - "

Hello, {{name}}!

".to_string(), - ); - - let result = build_and_render_inner(&files, r#"{"name": "WebUI"}"#, "index.html"); - assert!(result.is_ok(), "Render failed: {:?}", result); - assert_eq!(result.as_deref(), Ok("

Hello, WebUI!

")); - } - - #[test] - fn test_missing_entry_file() { - let files = HashMap::new(); - let result = build_and_render_inner(&files, "{}", "index.html"); - assert!(result.is_err()); - let err = result.unwrap_err().to_string(); - assert!(err.contains("not found"), "Unexpected error: {}", err); - } - - #[test] - fn test_with_component() { - let mut files = HashMap::new(); - files.insert( - "index.html".to_string(), - "World".to_string(), - ); - files.insert( - "my-card.html".to_string(), - "
".to_string(), - ); - - let result = build_and_render_inner(&files, "{}", "index.html"); - assert!(result.is_ok(), "Render failed: {:?}", result); - let html = result.as_deref().unwrap_or(""); - assert!(html.contains("card"), "Expected card class in: {}", html); - } - - #[test] - fn test_with_for_loop() { - let mut files = HashMap::new(); - files.insert( - "index.html".to_string(), - "{{item.name}}, ".to_string(), - ); - - let state = r#"{"items": [{"name": "A"}, {"name": "B"}]}"#; - let result = build_and_render_inner(&files, state, "index.html"); - assert!(result.is_ok(), "Render failed: {:?}", result); - let html = result.as_deref().unwrap_or(""); - assert!(html.contains("A"), "Expected 'A' in: {}", html); - assert!(html.contains("B"), "Expected 'B' in: {}", html); - } - - #[test] - fn test_with_if_condition() { - let mut files = HashMap::new(); - files.insert( - "index.html".to_string(), - "Visible".to_string(), - ); - - let result_true = build_and_render_inner(&files, r#"{"show": true}"#, "index.html"); - assert_eq!(result_true.as_deref(), Ok("Visible")); - - let result_false = build_and_render_inner(&files, r#"{"show": false}"#, "index.html"); - assert_eq!(result_false.as_deref(), Ok("")); - } - - #[test] - fn test_invalid_state_json() { - let mut files = HashMap::new(); - files.insert("index.html".to_string(), "

Hi

".to_string()); - - let result = build_and_render_inner(&files, "not json", "index.html"); - assert!(result.is_err()); - let err = result.unwrap_err().to_string(); - assert!( - err.contains("State JSON error"), - "Unexpected error: {}", - err - ); - } - - #[test] - fn test_component_with_css() { - let mut files = HashMap::new(); - files.insert( - "index.html".to_string(), - "Content".to_string(), - ); - files.insert( - "my-card.html".to_string(), - "

".to_string(), - ); - files.insert("my-card.css".to_string(), "p { color: red; }".to_string()); - - let result = build_and_render_inner(&files, "{}", "index.html"); - assert!(result.is_ok(), "Render failed: {:?}", result); - let html = result.as_deref().unwrap_or(""); - // WASM uses CssStrategy::Inline, so CSS should be in "), - "Expected inline "), + "Expected inline +``` + +The `` comment is parsed as a **Signal fragment** with value `"tokens"`. At render time, the host can replace this signal with actual CSS variable declarations based on the hoisted token list and the active theme. + +### Binding Syntax + +| Syntax | Result | +|--------|--------| +| `` | Signal fragment (escaped) | +| `` | Signal fragment (raw/unescaped) | +| `` | Signal fragment with dotted path | +| `` | Preserved as raw content | + +This mechanism is general-purpose — any `{{identifier}}` inside an HTML comment becomes a signal fragment. + + + +## Protocol Output + +The hoisted tokens appear in the protocol's `tokens` field: + +```json +{ + "fragments": { ... }, + "tokens": [ + "bgColor", + "borderRadiusSmall", + "colorBrandBackground", + "colorPrimary", + "fontFamilyBase", + "lineHeightBase400", + "spacingHorizontalM" + ] +} +``` + +The list is always **sorted alphabetically** and **deduplicated** across all components and inline styles. + +## CLI Output + +When tokens are discovered, the build command reports the count: + +``` +✔ Registered 5 components +✔ Parsed index.html (23 fragments) +✔ Discovered 12 CSS tokens +✔ Build complete (3 files written) in 42ms +```