diff --git a/api/cpp/include/private/slint_models.h b/api/cpp/include/private/slint_models.h index ef1f25fa61a..6bcc54b6ee5 100644 --- a/api/cpp/include/private/slint_models.h +++ b/api/cpp/include/private/slint_models.h @@ -48,6 +48,36 @@ long int model_length(const std::shared_ptr &model) } } +template +bool model_any(const std::shared_ptr &model, P predicate) +{ + long int count = model_length(model); + + for (long int i = 0; i < count; ++i) { + auto data = access_array_index(model, i); + if (predicate(data)) { + return true; + } + } + + return false; +} + +template +bool model_all(const std::shared_ptr &model, P predicate) +{ + long int count = model_length(model); + + for (long int i = 0; i < count; ++i) { + auto data = access_array_index(model, i); + if (!predicate(data)) { + return false; + } + } + + return true; +} + } // namespace private_api /// A Model is providing Data for Slint Models or ListView elements of the diff --git a/api/node/rust/interpreter/value.rs b/api/node/rust/interpreter/value.rs index 9cb5722129d..22d6cfb6a3c 100644 --- a/api/node/rust/interpreter/value.rs +++ b/api/node/rust/interpreter/value.rs @@ -386,7 +386,8 @@ pub fn to_value( | Type::PathData | Type::LayoutCache | Type::ArrayOfU16 - | Type::ElementReference => Err(napi::Error::from_reason("reason")), + | Type::ElementReference + | Type::Closure => Err(napi::Error::from_reason("reason")), Type::StyledText => { let obj = unknown.coerce_to_object()?; let styled_instance: ClassInstance = diff --git a/docs/astro/src/content/docs/guide/language/coding/repetition-and-data-models.mdx b/docs/astro/src/content/docs/guide/language/coding/repetition-and-data-models.mdx index 1fd046914b6..b840d781889 100644 --- a/docs/astro/src/content/docs/guide/language/coding/repetition-and-data-models.mdx +++ b/docs/astro/src/content/docs/guide/language/coding/repetition-and-data-models.mdx @@ -65,8 +65,13 @@ Arrays define the following operations: - **`array.length`**: One can query the length of an array and model using the builtin `.length` property. - **`array[index]`**: The index operator retrieves individual elements of an array. +- **`array.any(name => condition)`**: Returns true if the boolean predicate is true for at least one element. +- **`array.all(name => condition)`**: Returns true if the boolean predicate is true for every element. Out of bound access into an array will return default-constructed values. +The argument name of an `any` or `all` predicate is available only in the predicate expression, +and its type is inferred from the array element type. `any` returns false for an empty array, +while `all` returns true for an empty array. ```slint export component Example { @@ -74,6 +79,7 @@ export component Example { out property list-len: list-of-int.length; out property first-int: list-of-int[0]; + out property contains-two: list-of-int.any(value => value == 2); + out property all-positive: list-of-int.all(value => value > 0); } ``` - diff --git a/editors/tree-sitter-slint/grammar.js b/editors/tree-sitter-slint/grammar.js index cf4e1bfe0f2..ee797c79c15 100644 --- a/editors/tree-sitter-slint/grammar.js +++ b/editors/tree-sitter-slint/grammar.js @@ -337,6 +337,7 @@ module.exports = grammar({ $.simple_identifier, $.function_call, $.member_access, + $.closure_expression, $.unary_expression, $.binary_expression, $.ternary_expression, @@ -345,6 +346,16 @@ module.exports = grammar({ parens_op: ($) => seq("(", field("left", $.expression), ")"), + closure_expression: ($) => + prec.right( + 2, + seq( + field("argument", $.simple_identifier), + "=>", + field("body", $.expression), + ), + ), + index_op: ($) => prec( 18, diff --git a/internal/compiler/builtin_macros.rs b/internal/compiler/builtin_macros.rs index b6c437713d3..3e93d9aa861 100644 --- a/internal/compiler/builtin_macros.rs +++ b/internal/compiler/builtin_macros.rs @@ -397,7 +397,8 @@ fn to_debug_string( | Type::LayoutCache | Type::ArrayOfU16 | Type::Model - | Type::PathData => { + | Type::PathData + | Type::Closure => { diag.push_error("Cannot debug this expression".into(), node); Expression::Invalid } diff --git a/internal/compiler/expression_tree.rs b/internal/compiler/expression_tree.rs index ece5fbe63ea..219eca72c09 100644 --- a/internal/compiler/expression_tree.rs +++ b/internal/compiler/expression_tree.rs @@ -84,6 +84,8 @@ pub enum BuiltinFunction { ColorWithAlpha, ImageSize, ArrayLength, + ArrayAny, + ArrayAll, Rgb, Hsv, Oklch, @@ -273,6 +275,8 @@ declare_builtin_function_types!( name: crate::langtype::BuiltinStruct::Size.into(), })), ArrayLength: (Type::Model) -> Type::Int32, + ArrayAny: (Type::Model, Type::Closure) -> Type::Bool, + ArrayAll: (Type::Model, Type::Closure) -> Type::Bool, Rgb: (Type::Int32, Type::Int32, Type::Int32, Type::Float32) -> Type::Color, Hsv: (Type::Float32, Type::Float32, Type::Float32, Type::Float32) -> Type::Color, Oklch: (Type::Float32, Type::Float32, Type::Float32, Type::Float32) -> Type::Color, @@ -424,6 +428,7 @@ impl BuiltinFunction { BuiltinFunction::ColorToStyledText => true, BuiltinFunction::OpenUrl => false, BuiltinFunction::MacosBringAllWindowsToFront => false, + BuiltinFunction::ArrayAny | BuiltinFunction::ArrayAll => true, } } @@ -512,6 +517,7 @@ impl BuiltinFunction { BuiltinFunction::ColorToStyledText => true, BuiltinFunction::OpenUrl => false, BuiltinFunction::MacosBringAllWindowsToFront => false, + BuiltinFunction::ArrayAny | BuiltinFunction::ArrayAll => true, } } } @@ -907,6 +913,11 @@ pub enum Expression { }, EmptyComponentFactory, + + Closure { + arg_name: SmolStr, + expression: Box, + }, } impl Expression { @@ -1033,6 +1044,7 @@ impl Expression { Expression::MinMax { ty, .. } => ty.clone(), Expression::EmptyComponentFactory => Type::ComponentFactory, Expression::DebugHook { expression, .. } => expression.ty(), + Expression::Closure { .. } => Type::Closure, } } @@ -1167,6 +1179,7 @@ impl Expression { } Expression::EmptyComponentFactory => {} Expression::DebugHook { expression, .. } => visitor(expression), + Expression::Closure { expression, .. } => visitor(expression), } } @@ -1303,6 +1316,7 @@ impl Expression { } Expression::EmptyComponentFactory => {} Expression::DebugHook { expression, .. } => visitor(expression), + Expression::Closure { expression, .. } => visitor(expression), } } @@ -1407,6 +1421,7 @@ impl Expression { Expression::MinMax { lhs, rhs, .. } => lhs.is_constant(ga) && rhs.is_constant(ga), Expression::EmptyComponentFactory => true, Expression::DebugHook { .. } => false, + Expression::Closure { expression, .. } => expression.is_constant(ga), } } @@ -1658,6 +1673,7 @@ impl Expression { arguments: vec![Self::default_value_for_type(&Type::String)], source_location: None, }, + Type::Closure => Expression::Invalid, } } @@ -2192,5 +2208,10 @@ pub fn pretty_print(f: &mut dyn std::fmt::Write, expression: &Expression) -> std pretty_print(f, expression)?; write!(f, "\"{id}\")") } + Expression::Closure { arg_name, expression } => { + let display_name = arg_name.strip_prefix("local_").unwrap_or(arg_name); + write!(f, "{display_name} => ")?; + pretty_print(f, expression) + } } } diff --git a/internal/compiler/generator/cpp.rs b/internal/compiler/generator/cpp.rs index 7e25100e605..a6139c5e094 100644 --- a/internal/compiler/generator/cpp.rs +++ b/internal/compiler/generator/cpp.rs @@ -4451,6 +4451,12 @@ fn compile_expression(expr: &llr::Expression, ctx: &EvaluationContext) -> String ), } } + Expression::Closure { arg_name, expression } => { + let arg = ident(arg_name); + let expr = compile_expression(expression, ctx); + + format!("[&](auto const &{arg}) -> bool {{ return {expr}; }}") + } } } @@ -5070,6 +5076,12 @@ fn compile_builtin_function_call( let color = a.next().unwrap(); format!("slint::private_api::color_to_styled_text({})", color) } + BuiltinFunction::ArrayAny => { + format!("slint::private_api::model_any({}, {})", a.next().unwrap(), a.next().unwrap()) + }, + BuiltinFunction::ArrayAll => { + format!("slint::private_api::model_all({}, {})", a.next().unwrap(), a.next().unwrap()) + }, } } diff --git a/internal/compiler/generator/rust.rs b/internal/compiler/generator/rust.rs index 5f3f8c65160..8aa98b5c26b 100644 --- a/internal/compiler/generator/rust.rs +++ b/internal/compiler/generator/rust.rs @@ -2942,8 +2942,12 @@ fn access_item_rc(pr: &llr::MemberReference, ctx: &EvaluationContext) -> TokenSt /// Compile `expr` to a Rust expression returning an owned value. fn compile_expression_to_value(expr: &Expression, ctx: &EvaluationContext) -> TokenStream { let compiled_expr = compile_expression(expr, ctx); - - quote!((#compiled_expr).clone()) + // Closures compile to closures, which aren't `Clone` and don't need to be cloned. + if matches!(expr, Expression::Closure { .. }) { + compiled_expr + } else { + quote!((#compiled_expr).clone()) + } } /// Compile `expr` to a Rust expression which may potentially return a reference. @@ -3563,6 +3567,13 @@ fn compile_expression(expr: &Expression, ctx: &EvaluationContext) -> TokenStream } } } + Expression::Closure { arg_name, expression } => { + let arg_name = ident(arg_name); + let expression = compile_expression(expression, ctx); + quote! { + |#arg_name| {#expression} + } + } } } @@ -4322,6 +4333,30 @@ fn compile_builtin_function_call( let color = a.next().unwrap(); quote!(sp::color_to_styled_text(#color)) } + BuiltinFunction::ArrayAny => { + let arr_expression = compile_expression_to_value(&arguments[0], ctx); + let Expression::Closure { arg_name, expression } = &arguments[1] else { + panic!("internal error: ArrayAny expects a closure as second argument") + }; + let arg_name = ident(arg_name); + let closure_expression = compile_expression(expression, ctx); + quote!({ + let arr = #arr_expression; + sp::model_any(&arr, |#arg_name| -> bool { #closure_expression }) + }) + } + BuiltinFunction::ArrayAll => { + let arr_expression = compile_expression_to_value(&arguments[0], ctx); + let Expression::Closure { arg_name, expression } = &arguments[1] else { + panic!("internal error: ArrayAll expects a closure as second argument") + }; + let arg_name = ident(arg_name); + let closure_expression = compile_expression(expression, ctx); + quote!({ + let arr = #arr_expression; + sp::model_all(&arr, |#arg_name| -> bool { #closure_expression }) + }) + } } } diff --git a/internal/compiler/langtype.rs b/internal/compiler/langtype.rs index 0dcf4589446..1e3480cce72 100644 --- a/internal/compiler/langtype.rs +++ b/internal/compiler/langtype.rs @@ -72,6 +72,7 @@ pub enum Type { ArrayOfU16, StyledText, + Closure, } impl core::cmp::PartialEq for Type { @@ -116,6 +117,7 @@ impl core::cmp::PartialEq for Type { Type::ArrayOfU16 => matches!(other, Type::ArrayOfU16), Type::StyledText => matches!(other, Type::StyledText), Type::DataTransfer => matches!(other, Type::DataTransfer), + Type::Closure => matches!(other, Type::Closure), } } } @@ -194,6 +196,7 @@ impl Display for Type { Type::LayoutCache => write!(f, "layout cache"), Type::ArrayOfU16 => write!(f, "[u16]"), Type::StyledText => write!(f, "styled-text"), + Type::Closure => write!(f, "closure"), } } } @@ -337,6 +340,7 @@ impl Type { Type::LayoutCache => None, Type::ArrayOfU16 => None, Type::StyledText => None, + Type::Closure => None, } } diff --git a/internal/compiler/llr/expression.rs b/internal/compiler/llr/expression.rs index 175b06b2414..a355cbf9c52 100644 --- a/internal/compiler/llr/expression.rs +++ b/internal/compiler/llr/expression.rs @@ -274,6 +274,11 @@ pub enum Expression { /// The `n` value to use for the plural form if it is a plural form plural: Option>, }, + + Closure { + arg_name: SmolStr, + expression: Box, + }, } impl Expression { @@ -287,7 +292,8 @@ impl Expression { | Type::InferredCallback | Type::ElementReference | Type::LayoutCache - | Type::ArrayOfU16 => return None, + | Type::ArrayOfU16 + | Type::Closure => return None, Type::Float32 | Type::Duration | Type::Int32 @@ -400,6 +406,7 @@ impl Expression { Self::EmptyComponentFactory => Type::ComponentFactory, Self::EmptyDataTransfer => Type::DataTransfer, Self::TranslationReference { .. } => Type::String, + Self::Closure { .. } => Type::Closure, } } } @@ -531,6 +538,9 @@ macro_rules! visit_impl { $visitor(plural); } } + Expression::Closure { expression, .. } => { + $visitor(expression); + } } }; } diff --git a/internal/compiler/llr/lower_expression.rs b/internal/compiler/llr/lower_expression.rs index 001635dfb68..9dbc2117510 100644 --- a/internal/compiler/llr/lower_expression.rs +++ b/internal/compiler/llr/lower_expression.rs @@ -329,6 +329,10 @@ pub fn lower_expression( tree_Expression::EmptyComponentFactory => llr_Expression::EmptyComponentFactory, tree_Expression::EmptyDataTransfer => llr_Expression::EmptyDataTransfer, tree_Expression::DebugHook { expression, .. } => lower_expression(expression, ctx), + tree_Expression::Closure { arg_name, expression } => llr_Expression::Closure { + arg_name: arg_name.clone(), + expression: Box::new(lower_expression(expression, ctx)), + }, } } diff --git a/internal/compiler/llr/optim_passes/inline_expressions.rs b/internal/compiler/llr/optim_passes/inline_expressions.rs index c2cfbedddd4..69d7e577d38 100644 --- a/internal/compiler/llr/optim_passes/inline_expressions.rs +++ b/internal/compiler/llr/optim_passes/inline_expressions.rs @@ -76,6 +76,9 @@ fn expression_cost(exp: &Expression, ctx: &EvaluationContext) -> isize { Expression::EmptyComponentFactory => 10, Expression::EmptyDataTransfer => 10, Expression::TranslationReference { .. } => PROPERTY_ACCESS_COST + 2 * ALLOC_COST, + // The body cost is added by the visit() walk below; returning the body + // cost here would double-count it. + Expression::Closure { .. } => 0, }; exp.visit(|e| cost = cost.saturating_add(expression_cost(e, ctx))); @@ -170,6 +173,8 @@ fn builtin_function_cost(function: &BuiltinFunction) -> isize { BuiltinFunction::ColorToStyledText => ALLOC_COST, BuiltinFunction::OpenUrl => isize::MAX, BuiltinFunction::MacosBringAllWindowsToFront => isize::MAX, + // Iterating the model and running the closure is unbounded; never inline. + BuiltinFunction::ArrayAny | BuiltinFunction::ArrayAll => isize::MAX, } } diff --git a/internal/compiler/llr/pretty_print.rs b/internal/compiler/llr/pretty_print.rs index af9dd88ff3d..d6088aa6956 100644 --- a/internal/compiler/llr/pretty_print.rs +++ b/internal/compiler/llr/pretty_print.rs @@ -495,6 +495,10 @@ impl<'a, T> Display for DisplayExpression<'a, T> { ), } } + Expression::Closure { arg_name, expression } => { + let display_name = arg_name.strip_prefix("local_").unwrap_or(arg_name); + write!(f, "{} => {}", display_name, e(expression)) + } } } } diff --git a/internal/compiler/lookup.rs b/internal/compiler/lookup.rs index 573eb1e9a47..a8249e7b846 100644 --- a/internal/compiler/lookup.rs +++ b/internal/compiler/lookup.rs @@ -108,7 +108,10 @@ pub enum LookupResultCallable { MemberFunction { /// This becomes the first argument of the function call base: Expression, - base_node: Option, + /// Syntax node used as the diagnostic source span for `base`. In practice this is + /// often the node that originated the member-function lookup (e.g. the `.focus` + /// token), not the node of `base` itself. + source_node: Option, member: Box, }, } @@ -239,8 +242,8 @@ impl LookupObject for LocalVariableLookup { ctx: &LookupCtx, f: &mut impl FnMut(&SmolStr, LookupResult) -> Option, ) -> Option { - for scope in ctx.local_variables.iter() { - for (name, ty) in scope { + for scope in ctx.local_variables.iter().rev() { + for (name, ty) in scope.iter().rev() { if let Some(r) = f( // we need to strip the "local_" prefix because a lookup call will not include it &name.strip_prefix("local_").unwrap_or(name).into(), @@ -544,7 +547,7 @@ fn expression_from_reference( if matches!(function.args.first(), Some(Type::ElementReference)) { LookupResult::Callable(LookupResultCallable::MemberFunction { base: Expression::ElementReference(base_expr), - base_node: None, + source_node: None, member: Box::new(LookupResultCallable::Callable(callable)), }) } else { @@ -1059,7 +1062,7 @@ impl LookupObject for ColorExpression<'_> { }; LookupResult::Callable(LookupResultCallable::MemberFunction { base, - base_node: ctx.current_token.clone(), // Note that this is not the base_node, but the function's node + source_node: ctx.current_token.clone(), member: Box::new(LookupResultCallable::Callable(Callable::Builtin(f))), }) }; @@ -1125,15 +1128,25 @@ impl LookupObject for ArrayExpression<'_> { f: &mut impl FnMut(&SmolStr, LookupResult) -> Option, ) -> Option { let member_function = |f: BuiltinFunction| { + LookupResult::Callable(LookupResultCallable::MemberFunction { + base: self.0.clone(), + source_node: ctx.current_token.clone(), + member: LookupResultCallable::Callable(Callable::Builtin(f)).into(), + }) + }; + let function_call = |f: BuiltinFunction| { LookupResult::from(Expression::FunctionCall { function: Callable::Builtin(f), source_location: ctx.current_token.as_ref().map(|t| t.to_source_location()), arguments: vec![self.0.clone()], }) }; + None.or_else(|| { - f(&SmolStr::new_static("length"), member_function(BuiltinFunction::ArrayLength)) + f(&SmolStr::new_static("length"), function_call(BuiltinFunction::ArrayLength)) }) + .or_else(|| f(&SmolStr::new_static("any"), member_function(BuiltinFunction::ArrayAny))) + .or_else(|| f(&SmolStr::new_static("all"), member_function(BuiltinFunction::ArrayAll))) } } @@ -1174,7 +1187,7 @@ fn builtin_member_function_generator<'a>( move |func: BuiltinFunction| { LookupResult::Callable(LookupResultCallable::MemberFunction { base: base.clone(), - base_node: ctx.current_token.clone(), + source_node: ctx.current_token.clone(), member: Box::new(LookupResultCallable::Callable(Callable::Builtin(func))), }) } @@ -1182,12 +1195,12 @@ fn builtin_member_function_generator<'a>( fn member_macro_generator( base: Expression, - base_node: Option, + source_node: Option, ) -> impl FnMut(BuiltinMacroFunction) -> LookupResult { move |func: BuiltinMacroFunction| { LookupResult::Callable(LookupResultCallable::MemberFunction { base: base.clone(), - base_node: base_node.clone(), + source_node: source_node.clone(), member: Box::new(LookupResultCallable::Macro(func)), }) } diff --git a/internal/compiler/parser.rs b/internal/compiler/parser.rs index 2e047427d1e..edfecadf509 100644 --- a/internal/compiler/parser.rs +++ b/internal/compiler/parser.rs @@ -376,7 +376,7 @@ declare_syntax! { Expression-> [ ?Expression, ?FunctionCallExpression, ?IndexExpression, ?SelfAssignment, ?ConditionalExpression, ?QualifiedName, ?BinaryExpression, ?Array, ?ObjectLiteral, ?UnaryOpExpression, ?CodeBlock, ?StringTemplate, ?AtImageUrl, ?AtGradient, ?AtTr, - ?MemberAccess, ?AtKeys ], + ?MemberAccess, ?AtKeys, ?Closure ], /// Concatenate the children Expressions and StringLiteral to make a string StringTemplate -> [*Expression], /// `@image-url("foo.png")` @@ -460,6 +460,8 @@ declare_syntax! { UsesIdentifier -> [QualifiedName, DeclaredIdentifier], /// `implements Interface.Foo` ImplementsSpecifier -> [ QualifiedName ], + /// `x => x > 0` + Closure -> [DeclaredIdentifier, Expression], } } diff --git a/internal/compiler/parser/expressions.rs b/internal/compiler/parser/expressions.rs index f414a9e9d62..fc20045aa42 100644 --- a/internal/compiler/parser/expressions.rs +++ b/internal/compiler/parser/expressions.rs @@ -30,6 +30,7 @@ use super::prelude::*; /// array[index] /// {object:42} /// "foo".bar.something().something.xx({a: 1.foo}.a) +/// x => x > 0 /// ``` pub fn parse_expression(p: &mut impl Parser) -> bool { p.peek(); // consume the whitespace so they aren't part of the Expression node @@ -58,7 +59,11 @@ fn parse_expression_helper(p: &mut impl Parser, precedence: OperatorPrecedence) let mut possible_range = false; match p.nth(0).kind() { SyntaxKind::Identifier => { - parse_qualified_name(&mut *p); + if p.nth(1).kind() == SyntaxKind::FatArrow { + parse_closure(&mut *p); + } else { + parse_qualified_name(&mut *p); + } } SyntaxKind::StringLiteral => { if p.nth(0).as_str().ends_with('{') { @@ -230,6 +235,25 @@ fn parse_expression_helper(p: &mut impl Parser, precedence: OperatorPrecedence) true } +#[cfg_attr(test, parser_test)] +/// ```test +/// x => x > 0 +/// y => y == 42 +/// z => true +/// ``` +fn parse_closure(p: &mut impl Parser) { + let mut p = p.start_node(SyntaxKind::Closure); + + { + let mut p = p.start_node(SyntaxKind::DeclaredIdentifier); + p.expect(SyntaxKind::Identifier); + } + + p.expect(SyntaxKind::FatArrow); + + parse_expression(&mut *p); +} + #[cfg_attr(test, parser_test)] /// ```test /// @image-url("/foo/bar.png") diff --git a/internal/compiler/passes/resolving.rs b/internal/compiler/passes/resolving.rs index 4876ff9835e..170d1794810 100644 --- a/internal/compiler/passes/resolving.rs +++ b/internal/compiler/passes/resolving.rs @@ -458,6 +458,7 @@ impl Expression { ctx.diag.slint_sc_error("String interpolation expressions are", &node); Some(Self::from_string_template_node(node.into(), ctx)) } + SyntaxKind::Closure => Some(Self::from_closure_node(node.into(), ctx, None)), _ => None, }, NodeOrToken::Token(token) => match token.kind() { @@ -1449,22 +1450,54 @@ impl Expression { } return Self::Invalid; }; - let sub_expr = sub_expr.map(|n| { - (Self::from_expression_node(n.clone(), ctx), Some(NodeOrToken::from((*n).clone()))) - }); - let Some(function) = function else { - // Check sub expressions anyway - sub_expr.count(); - assert!(ctx.diag.has_errors()); - return Self::Invalid; + + // For `.any(predicate)` / `.all(predicate)` the closure's argument type is + // structurally derived from the base array's element type. Compute it here + // so we can hand it to the closure when resolving that specific argument. + let expected_closure_arg_type = match &function { + Some(LookupResult::Callable(LookupResultCallable::MemberFunction { + base, + member, + .. + })) if matches!( + **member, + LookupResultCallable::Callable(Callable::Builtin( + BuiltinFunction::ArrayAny | BuiltinFunction::ArrayAll + )) + ) => + { + let Type::Array(elem_ty) = base.ty() else { unreachable!() }; + Some((*elem_ty).clone()) + } + _ => None, }; - let LookupResult::Callable(function) = function else { - // Check sub expressions anyway - sub_expr.count(); - ctx.diag.push_error("The expression is not a function".into(), &node); - return Self::Invalid; + + let function = match function { + Some(LookupResult::Callable(function)) => function, + Some(_) => { + // Check sub expressions anyway + sub_expr.for_each(|n| { + Self::from_expression_node(n.clone(), ctx); + }); + ctx.diag.push_error("The expression is not a function".into(), &node); + return Self::Invalid; + } + None => { + // Check sub expressions anyway + sub_expr.for_each(|n| { + Self::from_expression_node(n.clone(), ctx); + }); + assert!(ctx.diag.has_errors()); + return Self::Invalid; + } }; + let sub_expr = sub_expr.map(|n| { + let expression = + Self::from_argument_expression_node(n.clone(), ctx, &expected_closure_arg_type); + (expression, Some(NodeOrToken::from((*n).clone()))) + }); + let mut adjust_arg_count = 0; let function = match function { LookupResultCallable::Callable(c) => c, @@ -1477,8 +1510,8 @@ impl Expression { ctx.diag, ); } - LookupResultCallable::MemberFunction { member, base, base_node } => { - arguments.push((base, base_node)); + LookupResultCallable::MemberFunction { member, base, source_node } => { + arguments.push((base, source_node)); adjust_arg_count = 1; match *member { LookupResultCallable::Callable(c) => c, @@ -1827,6 +1860,72 @@ impl Expression { Expression::Array { element_ty, values } } + /// Resolve a closure expression. `arg_type` is `Some` only when the closure appears in a + /// position whose callee constrains the argument's type (currently `.any` / `.all`); in + /// that case the body is also required to evaluate to `bool`. When `arg_type` is `None` + /// the closure is still a valid expression of type [`Type::Closure`], but its body cannot + /// be meaningfully typed and any later type-conversion error will be reported at the + /// position that consumes it. + fn from_closure_node( + node: syntax_nodes::Closure, + ctx: &mut LookupCtx, + arg_type: Option, + ) -> Expression { + let has_expected_arg_type = arg_type.is_some(); + let ty = arg_type.unwrap_or(Type::Invalid); + let arg_name = node.DeclaredIdentifier().to_smolstr(); + let internal_arg_name: SmolStr = format!("local_{arg_name}").into(); + + ctx.local_variables.push(vec![(internal_arg_name.clone(), ty)]); + let expression = Expression::from_expression_node(node.Expression(), ctx); + ctx.local_variables.pop(); + + let body_ty = expression.ty(); + if has_expected_arg_type && body_ty != Type::Bool && body_ty != Type::Invalid { + ctx.diag.push_error( + format!("Closure body must be of type bool, but is {body_ty}"), + &node.Expression(), + ); + return Expression::Invalid; + } + + Expression::Closure { arg_name: internal_arg_name, expression: Box::new(expression) } + } + + /// Resolve a function call argument. If the argument is a closure expression (possibly + /// nested in zero or more parenthesizing `Expression` wrappers), dispatch directly to + /// `from_closure_node` with the expected argument type. Otherwise fall back to the + /// generic expression resolver, in which case any closure encountered inside has no + /// expected argument type. + fn from_argument_expression_node( + node: syntax_nodes::Expression, + ctx: &mut LookupCtx, + expected_closure_arg_type: &Option, + ) -> Expression { + if expected_closure_arg_type.is_some() { + let mut current = node.clone(); + loop { + let first_meaningful_child = current + .children() + .find(|n| matches!(n.kind(), SyntaxKind::Expression | SyntaxKind::Closure)); + match first_meaningful_child { + Some(child) if child.kind() == SyntaxKind::Closure => { + return Self::from_closure_node( + child.into(), + ctx, + expected_closure_arg_type.clone(), + ); + } + Some(child) if child.kind() == SyntaxKind::Expression => { + current = child.into(); + } + _ => break, + } + } + } + Self::from_expression_node(node, ctx) + } + fn from_string_template_node( node: syntax_nodes::StringTemplate, ctx: &mut LookupCtx, @@ -2187,7 +2286,7 @@ fn continue_lookup_within_element( if matches!(fun.args.first(), Some(Type::ElementReference)) { LookupResult::Callable(LookupResultCallable::MemberFunction { base: Expression::ElementReference(Rc::downgrade(elem)), - base_node: Some(NodeOrToken::Node(node.into())), + source_node: Some(NodeOrToken::Node(node.into())), member: Box::new(LookupResultCallable::Callable(callable)), }) .into() diff --git a/internal/compiler/passes/resolving/remove_noop.rs b/internal/compiler/passes/resolving/remove_noop.rs index c9ec95c1d69..12b9c199c06 100644 --- a/internal/compiler/passes/resolving/remove_noop.rs +++ b/internal/compiler/passes/resolving/remove_noop.rs @@ -126,5 +126,6 @@ fn without_side_effects(expression: &Expression) -> bool { Expression::DebugHook { .. } => false, Expression::EmptyComponentFactory => false, Expression::EmptyDataTransfer => false, + Expression::Closure { expression, .. } => without_side_effects(expression), } } diff --git a/internal/compiler/tests/syntax/expressions/predicates.slint b/internal/compiler/tests/syntax/expressions/predicates.slint new file mode 100644 index 00000000000..564a32ae72f --- /dev/null +++ b/internal/compiler/tests/syntax/expressions/predicates.slint @@ -0,0 +1,62 @@ +// Copyright © SixtyFPS GmbH +// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0 + +export component Test inherits Window { + property <[int]> ints: [1, 2, 3, 4, 5]; + function takes-int(value: int) -> int { return value; } + function takes-bool(value: bool) -> bool { return value; } + + if x => x > 0 : Rectangle {} +// > { + ints.any(1); +// ^error{Cannot convert float to closure} + ints.all(1); +// ^error{Cannot convert float to closure} + + ints.any(x => 1); +// ^error{Closure body must be of type bool, but is float} + ints.all(x => 1); +// ^error{Closure body must be of type bool, but is float} + + ints.any(x => x); +// ^error{Closure body must be of type bool, but is int} + ints.all(x => x); +// ^error{Closure body must be of type bool, but is int} + debug(x); +// ^error{Unknown unqualified identifier 'x'. Did you mean 'self.x'?} + + takes-int(x => true); +// > x > 0); +// > y > 0; +// > takes-bool(y => y > 0)); +// > x + 1; + // Closure as a regular sub-expression: same story, body is unconstrained. + let still-useless = (y => y); + } + + // Closure as the right-hand side of a non-array property still produces a single + // conversion error at the property's binding, not a cascade inside the closure body. + property rejected: x => x + 1; +// > x => x > 0; + } +} diff --git a/internal/core/model.rs b/internal/core/model.rs index 9587f418bc0..438bea3da76 100644 --- a/internal/core/model.rs +++ b/internal/core/model.rs @@ -294,6 +294,28 @@ pub trait ModelExt: Model { impl ModelExt for T {} +pub fn model_any( + model: &dyn Model, + mut predicate: impl FnMut(T) -> bool, +) -> bool { + model.model_tracker().track_row_count_changes(); + (0..model.row_count()).any(|index| { + model.model_tracker().track_row_data_changes(index); + predicate(model.row_data(index).unwrap_or_default()) + }) +} + +pub fn model_all( + model: &dyn Model, + mut predicate: impl FnMut(T) -> bool, +) -> bool { + model.model_tracker().track_row_count_changes(); + (0..model.row_count()).all(|index| { + model.model_tracker().track_row_data_changes(index); + predicate(model.row_data(index).unwrap_or_default()) + }) +} + /// An iterator over the elements of a model. /// This struct is created by the [`Model::iter()`] trait function. pub struct ModelIterator<'a, T> { diff --git a/internal/interpreter/dynamic_item_tree.rs b/internal/interpreter/dynamic_item_tree.rs index bbdb834b932..e0830392b72 100644 --- a/internal/interpreter/dynamic_item_tree.rs +++ b/internal/interpreter/dynamic_item_tree.rs @@ -1305,7 +1305,8 @@ pub(crate) fn generate_item_tree<'id>( | Type::Model | Type::PathData | Type::UnitProduct(_) - | Type::ElementReference => panic!("bad type {ty:?} for property {name}"), + | Type::ElementReference + | Type::Closure => panic!("bad type {ty:?} for property {name}"), }) } diff --git a/internal/interpreter/eval.rs b/internal/interpreter/eval.rs index a6ee751baa4..f06384a074c 100644 --- a/internal/interpreter/eval.rs +++ b/internal/interpreter/eval.rs @@ -712,6 +712,9 @@ pub fn eval_expression(expression: &Expression, local_context: &mut EvalLocalCon Expression::EmptyComponentFactory => Value::ComponentFactory(Default::default()), Expression::EmptyDataTransfer => Value::DataTransfer(Default::default()), Expression::DebugHook { expression, .. } => eval_expression(expression, local_context), + Expression::Closure { .. } => unreachable!( + "closures are dispatched by their consuming builtin and should not go through eval_expression" + ), } } @@ -1814,6 +1817,33 @@ fn call_builtin_function( eval_expression(&arguments[0], local_context).try_into().unwrap(); Value::StyledText(corelib::styled_text::color_to_styled_text(color)) } + BuiltinFunction::ArrayAny | BuiltinFunction::ArrayAll => { + let is_all = matches!(f, BuiltinFunction::ArrayAll); + let model: ModelRc = + eval_expression(&arguments[0], local_context).try_into().unwrap(); + let Expression::Closure { arg_name, expression } = &arguments[1] else { + panic!("internal error: Array.any/all expects a closure as second argument") + }; + model.model_tracker().track_row_count_changes(); + for row in 0..model.row_count() { + let x = model.row_data_tracked(row).unwrap_or_default(); + let previous = local_context.local_variables.insert(arg_name.clone(), x); + let result: bool = eval_expression(expression, local_context).try_into().unwrap(); + match previous { + Some(prev) => { + local_context.local_variables.insert(arg_name.clone(), prev); + } + None => { + local_context.local_variables.remove(arg_name); + } + } + // `all` short-circuits on false, `any` short-circuits on true. + if result != is_all { + return Value::Bool(!is_all); + } + } + Value::Bool(is_all) + } } } @@ -2110,7 +2140,8 @@ fn check_value_type(value: &mut Value, ty: &Type) -> bool { | Type::InferredCallback | Type::Callback { .. } | Type::Function { .. } - | Type::ElementReference => panic!("not valid property type"), + | Type::ElementReference + | Type::Closure => panic!("not valid property type"), Type::Float32 => matches!(value, Value::Number(_)), Type::Int32 => matches!(value, Value::Number(_)), Type::String => matches!(value, Value::String(_)), @@ -2480,7 +2511,8 @@ pub fn default_value_for_type(ty: &Type) -> Value { Type::InferredProperty | Type::InferredCallback | Type::ElementReference - | Type::Function { .. } => { + | Type::Function { .. } + | Type::Closure => { panic!("There can't be such property") } Type::StyledText => Value::StyledText(Default::default()), diff --git a/tests/cases/models/array_predicates.slint b/tests/cases/models/array_predicates.slint new file mode 100644 index 00000000000..b20d65fad1f --- /dev/null +++ b/tests/cases/models/array_predicates.slint @@ -0,0 +1,243 @@ +// Copyright © SixtyFPS GmbH +// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0 + +struct Person { + name: string, + age: int, +} + +export component TestCase { + in-out property <[int]> ints: [1, 2, 3, 4, 5]; + in-out property threshold: 3; + property <[int]> empty-ints: []; + property <[string]> strings: ["hello", "world", "foo", "bar"]; + property <[Person]> people: [ + { name: "Alice", age: 30 }, + { name: "Bob", age: 25 }, + { name: "Charlie", age: 35 }, + { name: "Diana", age: 28 } + ]; + property <[[Person]]> groups: [ + [{ name: "Alice", age: 30 }, { name: "Bob", age: 25 }], + [{ name: "Charlie", age: 35 }, { name: "Diana", age: 28 }], + [{ name: "Evan", age: 22 }, { name: "Fiona", age: 27 }] + ]; + + out property has-zero: ints.any(x => x == 0); + out property all-positive: ints.all(x => x > 0); + out property any-above-threshold: ints.any(x => x > threshold); + out property all-above-threshold: ints.all(x => x > threshold); + + out property ints-all-positive: ints.all(x => x > 0); + out property ints-not-all-gt-one: !ints.all(x => x > 1); + out property ints-no-zero: !ints.any(x => x == 0); + out property ints-any-five: ints.any(x => x == 5); + out property ints-any-under-five: ints.any(x => x < 5); + + out property strings-all-nonempty: strings.all(s => s.character-count > 0); + out property strings-not-all-empty: !strings.all(s => s.is-empty); + out property strings-any-hello: strings.any(s => s == "hello"); + out property strings-no-test: !strings.any(s => s == "test"); + out property strings-no-empty: !strings.any(s => s.is-empty); + + out property people-all-adults: people.all(p => p.age > 20); + out property people-not-all-under-thirty: !people.all(p => p.age < 30); + out property people-any-alice: people.any(p => p.name == "Alice"); + out property people-no-evan: !people.any(p => p.name == "Evan"); + out property people-any-under-thirty: people.any(p => p.age < 30); + + out property groups-all-over-twenty: groups.all(group => group.all(person => person.age > 20)); + out property groups-not-all-under-thirty: !groups.all(group => group.all(person => person.age < 30)); + out property groups-all-have-adult: groups.all(group => group.any(person => person.age >= 25)); + out property groups-no-all-alice: !groups.any(group => group.all(person => person.name == "Alice")); + out property groups-any-all-long-name: groups.any(group => group.all(person => person.name.character-count > 4)); + out property groups-any-seven-letter-name: groups.any(group => group.any(person => person.name.character-count == 7)); + + out property nested-finds-alice: groups.any(group => group.length == 2 && group.any(person => person.name == "Alice")); + out property nested-all-have-age-twenty-five: groups.all(group => group.any(person => person.age >= 25)); + out property nested-no-all-alice: !groups.any(group => group.all(person => person.name == "Alice")); + + out property precedence-and: ints.any(x => x > 0 && x < 2); + out property precedence-or: !ints.any(x => x > 10 || x < 0); + out property precedence-all: ints.all(x => x > 0 && x < 10); + + out property empty-any-false: !empty-ints.any(x => x == 0); + out property empty-all-true: empty-ints.all(x => x == 0); + + out property closure-arg-shadows-local: { + let x = "hello world"; + self.ints.any(x => x == 1) && x == "hello world" + } + + // Same-name, same-type shadowing: the outer `x: int` must be hidden by the closure's + // `x: int` for the duration of the predicate body, and re-exposed after. + out property closure-arg-shadows-same-type-local: { + let x = 5; + self.ints.any(x => x == 2) && x == 5 + } + + out property nested-closure-arg-shadows: { + let person = "outer"; + self.groups.any(person => person.any(person => person.name == "Alice")) && person == "outer" + } + + out property test: + ints-all-positive && ints-not-all-gt-one && ints-no-zero && ints-any-five + && ints-any-under-five && strings-all-nonempty && strings-not-all-empty + && strings-any-hello && strings-no-test && strings-no-empty && people-all-adults + && people-not-all-under-thirty && people-any-alice && people-no-evan + && people-any-under-thirty && groups-all-over-twenty && groups-not-all-under-thirty + && groups-all-have-adult && groups-no-all-alice && groups-any-all-long-name + && groups-any-seven-letter-name && nested-finds-alice && nested-all-have-age-twenty-five + && nested-no-all-alice && precedence-and && precedence-or && precedence-all + && empty-any-false && empty-all-true && closure-arg-shadows-local + && closure-arg-shadows-same-type-local + && nested-closure-arg-shadows; +} + +/* +```cpp +auto handle = TestCase::create(); +const TestCase &instance = *handle; + +assert(instance.get_ints_all_positive()); +assert(instance.get_ints_not_all_gt_one()); +assert(instance.get_ints_no_zero()); +assert(instance.get_ints_any_five()); +assert(instance.get_ints_any_under_five()); +assert(instance.get_strings_all_nonempty()); +assert(instance.get_strings_not_all_empty()); +assert(instance.get_strings_any_hello()); +assert(instance.get_strings_no_test()); +assert(instance.get_strings_no_empty()); +assert(instance.get_people_all_adults()); +assert(instance.get_people_not_all_under_thirty()); +assert(instance.get_people_any_alice()); +assert(instance.get_people_no_evan()); +assert(instance.get_people_any_under_thirty()); +assert(instance.get_groups_all_over_twenty()); +assert(instance.get_groups_not_all_under_thirty()); +assert(instance.get_groups_all_have_adult()); +assert(instance.get_groups_no_all_alice()); +assert(instance.get_groups_any_all_long_name()); +assert(instance.get_groups_any_seven_letter_name()); +assert(instance.get_nested_finds_alice()); +assert(instance.get_nested_all_have_age_twenty_five()); +assert(instance.get_nested_no_all_alice()); +assert(instance.get_precedence_and()); +assert(instance.get_precedence_or()); +assert(instance.get_precedence_all()); +assert(instance.get_empty_any_false()); +assert(instance.get_empty_all_true()); +assert(instance.get_closure_arg_shadows_local()); +assert(instance.get_closure_arg_shadows_same_type_local()); +assert(instance.get_nested_closure_arg_shadows()); +``` + +```rust +use slint::Model; + +let instance = TestCase::new().unwrap(); + +assert!(instance.get_ints_all_positive()); +assert!(instance.get_ints_not_all_gt_one()); +assert!(instance.get_ints_no_zero()); +assert!(instance.get_ints_any_five()); +assert!(instance.get_ints_any_under_five()); +assert!(instance.get_strings_all_nonempty()); +assert!(instance.get_strings_not_all_empty()); +assert!(instance.get_strings_any_hello()); +assert!(instance.get_strings_no_test()); +assert!(instance.get_strings_no_empty()); +assert!(instance.get_people_all_adults()); +assert!(instance.get_people_not_all_under_thirty()); +assert!(instance.get_people_any_alice()); +assert!(instance.get_people_no_evan()); +assert!(instance.get_people_any_under_thirty()); +assert!(instance.get_groups_all_over_twenty()); +assert!(instance.get_groups_not_all_under_thirty()); +assert!(instance.get_groups_all_have_adult()); +assert!(instance.get_groups_no_all_alice()); +assert!(instance.get_groups_any_all_long_name()); +assert!(instance.get_groups_any_seven_letter_name()); +assert!(instance.get_nested_finds_alice()); +assert!(instance.get_nested_all_have_age_twenty_five()); +assert!(instance.get_nested_no_all_alice()); +assert!(instance.get_precedence_and()); +assert!(instance.get_precedence_or()); +assert!(instance.get_precedence_all()); +assert!(instance.get_empty_any_false()); +assert!(instance.get_empty_all_true()); +assert!(instance.get_closure_arg_shadows_local()); +assert!(instance.get_closure_arg_shadows_same_type_local()); +assert!(instance.get_nested_closure_arg_shadows()); + +let model: std::rc::Rc> = std::rc::Rc::new(vec![1, 2, 3].into()); +instance.set_ints(slint::ModelRc::from(model.clone())); +assert!(!instance.get_has_zero()); +assert!(instance.get_all_positive()); +assert!(!instance.get_any_above_threshold()); +assert!(!instance.get_all_above_threshold()); + +instance.set_threshold(1); +assert!(instance.get_any_above_threshold()); +assert!(!instance.get_all_above_threshold()); + +model.set_row_data(0, 3); +assert!(instance.get_all_above_threshold()); + +model.set_row_data(1, 0); +assert!(instance.get_has_zero()); +assert!(!instance.get_all_positive()); +assert!(instance.get_any_above_threshold()); +assert!(!instance.get_all_above_threshold()); + +model.set_row_data(1, 4); +assert!(!instance.get_has_zero()); +assert!(instance.get_all_positive()); +assert!(instance.get_any_above_threshold()); +assert!(instance.get_all_above_threshold()); + +model.push(0); +assert!(instance.get_has_zero()); +assert!(!instance.get_all_positive()); +assert!(!instance.get_all_above_threshold()); +``` + +```js +var instance = new slint.TestCase(); + +assert(instance.ints_all_positive); +assert(instance.ints_not_all_gt_one); +assert(instance.ints_no_zero); +assert(instance.ints_any_five); +assert(instance.ints_any_under_five); +assert(instance.strings_all_nonempty); +assert(instance.strings_not_all_empty); +assert(instance.strings_any_hello); +assert(instance.strings_no_test); +assert(instance.strings_no_empty); +assert(instance.people_all_adults); +assert(instance.people_not_all_under_thirty); +assert(instance.people_any_alice); +assert(instance.people_no_evan); +assert(instance.people_any_under_thirty); +assert(instance.groups_all_over_twenty); +assert(instance.groups_not_all_under_thirty); +assert(instance.groups_all_have_adult); +assert(instance.groups_no_all_alice); +assert(instance.groups_any_all_long_name); +assert(instance.groups_any_seven_letter_name); +assert(instance.nested_finds_alice); +assert(instance.nested_all_have_age_twenty_five); +assert(instance.nested_no_all_alice); +assert(instance.precedence_and); +assert(instance.precedence_or); +assert(instance.precedence_all); +assert(instance.empty_any_false); +assert(instance.empty_all_true); +assert(instance.closure_arg_shadows_local); +assert(instance.closure_arg_shadows_same_type_local); +assert(instance.nested_closure_arg_shadows); +``` +*/ diff --git a/tools/lsp/fmt/fmt.rs b/tools/lsp/fmt/fmt.rs index e4485db1b4f..87201636d7e 100644 --- a/tools/lsp/fmt/fmt.rs +++ b/tools/lsp/fmt/fmt.rs @@ -203,6 +203,9 @@ fn format_node( SyntaxKind::ImplementsSpecifier => { return format_implements_specifier(node, writer, state); } + SyntaxKind::Closure => { + return format_closure(node, writer, state); + } _ => (), } @@ -773,24 +776,6 @@ fn format_qualified_name( state.skip_all_whitespace = true; fold(n, writer, state)?; } - /*if !node - .last_token() - .and_then(|x| x.next_token()) - .map(|x| { - matches!( - x.kind(), - SyntaxKind::LParent - | SyntaxKind::RParent - | SyntaxKind::Semicolon - | SyntaxKind::Comma - ) - }) - .unwrap_or(false) - { - state.insert_whitespace(" "); - } else { - state.skip_all_whitespace = true; - }*/ Ok(()) } @@ -2082,6 +2067,26 @@ fn format_import_specifier( Ok(()) } +fn format_closure( + node: &SyntaxNode, + writer: &mut impl TokenWriter, + state: &mut FormatState, +) -> Result<(), std::io::Error> { + for s in node.children_with_tokens() { + state.skip_all_whitespace = true; + match s.kind() { + SyntaxKind::FatArrow => { + state.insert_whitespace(" "); + fold(s, writer, state)?; + state.insert_whitespace(" "); + } + _ => fold(s, writer, state)?, + } + } + + Ok(()) +} + /// Format import list /// /// If the line is too long, it formats the import identifier in a new line, unless @@ -3470,6 +3475,20 @@ export component MainWindow2 inherits Rectangle { ); } + #[test] + fn closure() { + assert_formatting( + "component X { property <[int]> arr: [1, 2, 3, 4, 5]; function foo() { arr.any(x\n => x == 1 ); } }", + r#"component X { + property <[int]> arr: [1, 2, 3, 4, 5]; + function foo() { + arr.any(x => x == 1); + } +} +"#, + ); + } + // cspell:disable #[test] fn import_line_too_long() { @@ -3684,4 +3703,25 @@ export component MainWindow2 inherits Rectangle { } from "./here.slint";"#, ); } + + #[test] + fn nested_closure() { + assert_formatting( + "component X { property <[[int]]> arr: [[1, 2, 3, 4, 5]]; function foo() { arr.any(x => x.all(y => y == 7 ) ); } }", + r#"component X { + property <[[int]]> arr: [[1, 2, 3, 4, 5]]; + function foo() { arr.any(x => x.all(y => y == 7)); } +} +"#, + ); + + assert_formatting( + "component X { property <[[{age: int}]]> groups: []; function foo() { groups.any(group => group.any(person => person.age > 20 ) && group.length == 2 ); } }", + r#"component X { + property <[[{age: int}]]> groups: []; + function foo() { groups.any(group => group.any(person => person.age > 20) && group.length == 2); } +} +"#, + ); + } }