diff --git a/crates/rangular-aot/src/lower.rs b/crates/rangular-aot/src/lower.rs index 05b32f5..a1fc20a 100644 --- a/crates/rangular-aot/src/lower.rs +++ b/crates/rangular-aot/src/lower.rs @@ -138,7 +138,7 @@ impl HoistState { let closure = closure.clone(); self.closure_lets.push(quote! { let #id = leptos::prelude::StoredValue::new(std::sync::Arc::new({ - let host = __rangular_host.get_value().clone(); + let host = __rangular_host.get_value(); #closure })); }); @@ -191,7 +191,6 @@ pub fn emit_rust_tokens(template: &Template, fn_name: &str) -> EmitTokens { let has_default = has_default_projection(&template.nodes); let tokens = if selects.is_empty() && has_projection(&template.nodes) { quote! { - #[allow(clippy::needless_pass_by_value, clippy::redundant_clone)] pub fn #ident( host: rangular_aot::HostCell, children: Children, @@ -211,7 +210,6 @@ pub fn emit_rust_tokens(template: &Template, fn_name: &str) -> EmitTokens { params.push(quote! { children: Children }); } quote! { - #[allow(clippy::needless_pass_by_value, clippy::redundant_clone)] pub fn #ident( host: rangular_aot::HostCell, #(#params),* @@ -223,7 +221,6 @@ pub fn emit_rust_tokens(template: &Template, fn_name: &str) -> EmitTokens { } } else { quote! { - #[allow(clippy::needless_pass_by_value, clippy::redundant_clone)] pub fn #ident( host: rangular_aot::HostCell, ) -> impl IntoView { @@ -618,10 +615,9 @@ fn lower_for( let:row > { - let (__rangular_index, __rangular_count, #item_ident) = row.clone(); - let __rangular_loop_store = leptos::prelude::StoredValue::new(std::sync::Arc::new( - #item_ident.clone(), - )); + let (__rangular_index, __rangular_count, #item_ident) = row; + let __rangular_loop_store = + leptos::prelude::StoredValue::new(std::sync::Arc::new(#item_ident)); #body_prelude view! { #body } } diff --git a/crates/rangular-expr/src/pipe.rs b/crates/rangular-expr/src/pipe.rs index c5d5a76..153a71a 100644 --- a/crates/rangular-expr/src/pipe.rs +++ b/crates/rangular-expr/src/pipe.rs @@ -68,21 +68,6 @@ impl PipeRegistry { } } -fn display_plain(value: &Value) -> String { - match value { - Value::Str(s) => s.clone(), - Value::Num(n) => format_num(*n), - Value::Bool(b) => b.to_string(), - Value::List(items) => items - .iter() - .map(display_plain) - .collect::>() - .join(","), - Value::Event(payload) => format!("{payload:?}"), - Value::Unit => String::new(), - } -} - fn format_num(n: f64) -> String { if n.fract() == 0.0 && n.abs() < 1e15 { format!("{n:.0}") @@ -102,14 +87,29 @@ fn as_number(value: &Value) -> Result { } } -#[allow(clippy::unnecessary_wraps)] // PipeFn signature is always Result -fn pipe_uppercase(value: &Value, _: &[Value]) -> Result { - Ok(Value::Str(display_plain(value).to_uppercase())) +const fn require_str<'a>(value: &'a Value, pipe: &'static str) -> Result<&'a str, EvalError> { + match value { + Value::Str(s) => Ok(s.as_str()), + _ => Err(EvalError::TypeMismatch(pipe)), + } } -#[allow(clippy::unnecessary_wraps)] // PipeFn signature is always Result -fn pipe_lowercase(value: &Value, _: &[Value]) -> Result { - Ok(Value::Str(display_plain(value).to_lowercase())) +fn pipe_uppercase(value: &Value, args: &[Value]) -> Result { + if !args.is_empty() { + return Err(EvalError::TypeMismatch("uppercase pipe args")); + } + Ok(Value::Str( + require_str(value, "uppercase pipe")?.to_uppercase(), + )) +} + +fn pipe_lowercase(value: &Value, args: &[Value]) -> Result { + if !args.is_empty() { + return Err(EvalError::TypeMismatch("lowercase pipe args")); + } + Ok(Value::Str( + require_str(value, "lowercase pipe")?.to_lowercase(), + )) } fn pipe_number(value: &Value, args: &[Value]) -> Result { @@ -130,8 +130,10 @@ fn pipe_number(value: &Value, args: &[Value]) -> Result { Ok(Value::Str(format_num(n))) } -#[allow(clippy::unnecessary_wraps)] // PipeFn signature is always Result -fn pipe_json(value: &Value, _: &[Value]) -> Result { +fn pipe_json(value: &Value, args: &[Value]) -> Result { + if !args.is_empty() { + return Err(EvalError::TypeMismatch("json pipe args")); + } Ok(Value::Str(jsonish(value))) } @@ -190,6 +192,22 @@ mod tests { reg.apply("missing", &Value::Unit, &[]), Err(EvalError::UnknownPipe(_)) )); + assert!(matches!( + reg.apply("uppercase", &Value::Num(1.0), &[]), + Err(EvalError::TypeMismatch(_)) + )); + assert!(matches!( + reg.apply("lowercase", &Value::Unit, &[]), + Err(EvalError::TypeMismatch(_)) + )); + assert!(matches!( + reg.apply("uppercase", &Value::Str("x".into()), &[Value::Num(1.0)]), + Err(EvalError::TypeMismatch(_)) + )); + assert!(matches!( + reg.apply("json", &Value::Unit, &[Value::Str("x".into())]), + Err(EvalError::TypeMismatch(_)) + )); } #[test] @@ -223,21 +241,8 @@ mod tests { } #[test] - fn display_and_json_cover_value_variants() { + fn json_covers_value_variants() { let reg = PipeRegistry::with_builtins(); - assert_eq!( - reg.apply( - "uppercase", - &Value::List(vec![Value::Str("a".into()), Value::Bool(true)]), - &[] - ) - .unwrap(), - Value::Str("A,TRUE".into()) - ); - assert_eq!( - reg.apply("lowercase", &Value::Unit, &[]).unwrap(), - Value::Str(String::new()) - ); assert_eq!( reg.apply( "json", @@ -256,19 +261,6 @@ mod tests { .unwrap(), Value::Str("\"Load\"".into()) ); - assert_eq!( - reg.apply("uppercase", &Value::Num(1.5), &[]).unwrap(), - Value::Str("1.5".into()) - ); - assert_eq!( - reg.apply( - "uppercase", - &Value::from(rangular_host::EventPayload::Error), - &[] - ) - .unwrap(), - Value::Str("ERROR".into()) - ); assert_eq!( reg.apply("json", &Value::Str("a\nb\rc\td\\e\"f".into()), &[]) .unwrap(), diff --git a/crates/rangular-runtime/tests/parity.rs b/crates/rangular-runtime/tests/parity.rs index 3611b16..2c39c9d 100644 --- a/crates/rangular-runtime/tests/parity.rs +++ b/crates/rangular-runtime/tests/parity.rs @@ -527,11 +527,27 @@ fn pipes_runtime_snapshot() { use rangular_expr::{EvalError, PipeRegistry}; use rangular_host::Value as HostValue; - #[allow(clippy::unnecessary_wraps)] - fn pipe_crab(value: &HostValue, _: &[HostValue]) -> Result { + fn pipe_crab(value: &HostValue, args: &[HostValue]) -> Result { + if !args.is_empty() { + return Err(EvalError::TypeMismatch("crab pipe args")); + } let text = match value { HostValue::Str(s) => s.clone(), - _ => String::new(), + HostValue::Num(n) => n.to_string(), + HostValue::Bool(b) => b.to_string(), + HostValue::List(items) => { + let mut parts = Vec::with_capacity(items.len()); + for item in items { + match item { + HostValue::Str(s) => parts.push(s.as_str()), + _ => return Err(EvalError::TypeMismatch("crab pipe list")), + } + } + parts.join(", ") + } + HostValue::Event(_) | HostValue::Unit => { + return Err(EvalError::TypeMismatch("crab pipe")); + } }; Ok(HostValue::Str(format!("{text} 🦀"))) } diff --git a/demo-leptos/src/demo_pipes.rs b/demo-leptos/src/demo_pipes.rs index d32ef65..6737c9f 100644 --- a/demo-leptos/src/demo_pipes.rs +++ b/demo-leptos/src/demo_pipes.rs @@ -12,22 +12,27 @@ pub fn demo_pipes() -> Arc { })) } -#[allow(clippy::unnecessary_wraps)] -fn pipe_crab(value: &Value, _: &[Value]) -> Result { +fn pipe_crab(value: &Value, args: &[Value]) -> Result { + if !args.is_empty() { + return Err(EvalError::TypeMismatch("crab pipe args")); + } let text = match value { Value::Str(s) => s.clone(), Value::Num(n) => n.to_string(), Value::Bool(b) => b.to_string(), - Value::List(items) => items - .iter() - .filter_map(|item| match item { - Value::Str(s) => Some(s.as_str()), - _ => None, - }) - .collect::>() - .join(", "), - Value::Event(payload) => format!("{payload:?}"), - Value::Unit => String::new(), + Value::List(items) => { + let mut parts = Vec::with_capacity(items.len()); + for item in items { + match item { + Value::Str(s) => parts.push(s.as_str()), + _ => return Err(EvalError::TypeMismatch("crab pipe list")), + } + } + parts.join(", ") + } + Value::Event(_) | Value::Unit => { + return Err(EvalError::TypeMismatch("crab pipe")); + } }; Ok(Value::Str(format!("{text} 🦀"))) } @@ -57,4 +62,13 @@ mod tests { let out = eval_with_pipes(&expr, &mut host, &demo_pipes()).unwrap(); assert_eq!(out, Value::Str("Hello 🦀".into())); } + + #[test] + fn crab_pipe_rejects_unit() { + let pipes = demo_pipes(); + let err = pipes + .apply("crab", &Value::Unit, &[]) + .expect_err("unit"); + assert!(matches!(err, rangular_expr::EvalError::TypeMismatch(_))); + } } diff --git a/docs/SPEC.md b/docs/SPEC.md index c0c23f7..451bdc2 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -187,6 +187,17 @@ and property / attribute bindings. | `{{ a \| b \| c }}` | Left-associative chain | Builtins (pure transforms on eval): `uppercase`, `lowercase`, `number`, `json`. + +| Builtin | Input | Notes | +| --- | --- | --- | +| `uppercase` / `lowercase` | `Str` only | reject non-strings and colon args | +| `number` | `Num`, parseable `Str`, or `Bool` | optional integer digits arg `0..=20` | +| `json` | any value | reject colon args | + +Failed pipe eval becomes empty display in runtime / AOT (`Value::Unit`); callers that use +[`PipeRegistry::apply`](../crates/rangular-expr/src/pipe.rs) or `eval_with_pipes` still see +`EvalError`. + Apps register custom pipes on [`Registry::register_pipe`](../crates/rangular/src/registry.rs) (same map for AOT `HostCell::with_pipes` and runtime `interpret_with_pipes`).