diff --git a/dsc/src/subcommand.rs b/dsc/src/subcommand.rs index 3132dfedd..53a55eac7 100644 --- a/dsc/src/subcommand.rs +++ b/dsc/src/subcommand.rs @@ -672,6 +672,7 @@ fn list_functions(functions: &FunctionDispatcher, function_name: Option<&String> let returned_types= [ (FunctionArgKind::Array, "a"), (FunctionArgKind::Boolean, "b"), + (FunctionArgKind::Lambda, "l"), (FunctionArgKind::Number, "n"), (FunctionArgKind::String, "s"), (FunctionArgKind::Object, "o"), diff --git a/dsc/tests/dsc_lambda.tests.ps1 b/dsc/tests/dsc_lambda.tests.ps1 new file mode 100644 index 000000000..2bb781708 --- /dev/null +++ b/dsc/tests/dsc_lambda.tests.ps1 @@ -0,0 +1,134 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +Describe 'map() function with lambda tests' { + It 'map with simple lambda multiplies each element by 2' { + $config_yaml = @' +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +parameters: + numbers: + type: array + defaultValue: [1, 2, 3] +resources: +- name: Echo + type: Microsoft.DSC.Debug/Echo + properties: + output: "[map(parameters('numbers'), lambda('x', mul(lambdaVariables('x'), 2)))]" +'@ + $out = $config_yaml | dsc config get -f - | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $out.results[0].result.actualState.output | Should -Be @(2,4,6) + } + + It 'map with lambda using index parameter' { + $config_yaml = @' +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +parameters: + items: + type: array + defaultValue: [10, 20, 30] +resources: +- name: Echo + type: Microsoft.DSC.Debug/Echo + properties: + output: "[map(parameters('items'), lambda('val', 'i', add(lambdaVariables('val'), lambdaVariables('i'))))]" +'@ + $out = $config_yaml | dsc config get -f - | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $out.results[0].result.actualState.output | Should -Be @(10,21,32) + } + + It 'map with range generates array' { + $config_yaml = @' +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +resources: +- name: Echo + type: Microsoft.DSC.Debug/Echo + properties: + output: "[map(range(0, 3), lambda('x', mul(lambdaVariables('x'), 3)))]" +'@ + $out = $config_yaml | dsc config get -f - | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $out.results[0].result.actualState.output | Should -Be @(0,3,6) + } + + It 'map returns empty array for empty input' { + $config_yaml = @' +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +resources: +- name: Echo + type: Microsoft.DSC.Debug/Echo + properties: + output: "[map(createArray(), lambda('x', mul(lambdaVariables('x'), 2)))]" +'@ + $out = $config_yaml | dsc config get -f - | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $out.results[0].result.actualState.output.Count | Should -Be 0 + } +} + +Describe 'filter() function with lambda tests' { + It 'filter with simple lambda filters elements greater than 2' { + $config_yaml = @' +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +parameters: + numbers: + type: array + defaultValue: [1, 2, 3, 4, 5] +resources: +- name: Echo + type: Microsoft.DSC.Debug/Echo + properties: + output: "[filter(parameters('numbers'), lambda('x', greater(lambdaVariables('x'), 2)))]" +'@ + $out = $config_yaml | dsc config get -f - | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $out.results[0].result.actualState.output | Should -Be @(3,4,5) + } + + It 'filter with lambda using index parameter' { + $config_yaml = @' +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +parameters: + items: + type: array + defaultValue: [10, 20, 30, 40] +resources: +- name: Echo + type: Microsoft.DSC.Debug/Echo + properties: + output: "[filter(parameters('items'), lambda('val', 'i', less(lambdaVariables('i'), 2)))]" +'@ + $out = $config_yaml | dsc config get -f - | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $out.results[0].result.actualState.output | Should -Be @(10,20) + } + + It 'filter returns empty array when no elements match' { + $config_yaml = @' +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +resources: +- name: Echo + type: Microsoft.DSC.Debug/Echo + properties: + output: "[filter(createArray(1, 2, 3), lambda('x', greater(lambdaVariables('x'), 10)))]" +'@ + $out = $config_yaml | dsc config get -f - | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $out.results[0].result.actualState.output.Count | Should -Be 0 + } + + It 'filter returns all elements when all match' { + $config_yaml = @' +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +resources: +- name: Echo + type: Microsoft.DSC.Debug/Echo + properties: + output: "[filter(createArray(5, 6, 7), lambda('x', greater(lambdaVariables('x'), 2)))]" +'@ + $out = $config_yaml | dsc config get -f - | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $out.results[0].result.actualState.output | Should -Be @(5,6,7) + } +} diff --git a/lib/dsc-lib/locales/en-us.toml b/lib/dsc-lib/locales/en-us.toml index d900998e7..b970c2f06 100644 --- a/lib/dsc-lib/locales/en-us.toml +++ b/lib/dsc-lib/locales/en-us.toml @@ -247,9 +247,12 @@ argCountRequired = "Function '%{name}' requires between %{min_args} and %{max_ar noArrayArgs = "Function '%{name}' does not accept array arguments, accepted types are: %{accepted_args_string}" noBooleanArgs = "Function '%{name}' does not accept boolean arguments, accepted types are: %{accepted_args_string}" noNumberArgs = "Function '%{name}' does not accept number arguments, accepted types are: %{accepted_args_string}" +noLambdaArgs = "Function '%{name}' does not accept lambda arguments, accepted types are: %{accepted_args_string}" noNullArgs = "Function '%{name}' does not accept null arguments, accepted types are: %{accepted_args_string}" noObjectArgs = "Function '%{name}' does not accept object arguments, accepted types are: %{accepted_args_string}" noStringArgs = "Function '%{name}' does not accept string arguments, accepted types are: %{accepted_args_string}" +lambdaNotFound = "Function '%{name}' could not find lambda with ID '%{id}'" +lambdaTooManyParams = "Function '%{name}' requires lambda with 1 or 2 parameters (element and optional index)" [functions.add] description = "Adds two or more numbers together" @@ -344,6 +347,11 @@ description = "Evaluates if the two values are the same" description = "Returns the boolean value false" invoked = "false function" +[functions.filter] +description = "Filters an array with a custom filtering function" +invoked = "filter function" +lambdaMustReturnBool = "filter() lambda must return a boolean value" + [functions.first] description = "Returns the first element of an array or first character of a string" invoked = "first function" @@ -409,11 +417,27 @@ invalidObjectElement = "Array elements cannot be objects" description = "Converts a valid JSON string into a JSON data type" invalidJson = "Invalid JSON string" +[functions.lambda] +description = "Creates a lambda function with parameters and a body expression" +invoked = "lambda function" +requiresParamAndBody = "lambda() requires at least one parameter name and a body expression" +paramsMustBeStrings = "lambda() parameter names must be string literals" +bodyMustBeExpression = "lambda() body must be an expression" + +[functions.lambdaVariables] +description = "Retrieves the value of a lambda parameter" +invoked = "lambdaVariables function" +notFound = "Lambda parameter '%{name}' not found in current context" + [functions.lastIndexOf] description = "Returns the index of the last occurrence of an item in an array" invoked = "lastIndexOf function" invalidArrayArg = "First argument must be an array" +[functions.map] +description = "Transforms an array by applying a lambda function to each element" +invoked = "map function" + [functions.length] description = "Returns the length of a string, array, or object" invoked = "length function" @@ -683,6 +707,7 @@ functionName = "Function name: '%{name}'" argIsExpression = "Argument is an expression" argIsValue = "Argument is a value: '%{value}'" unknownArgType = "Unknown argument type '%{kind}'" +unexpectedLambda = "Lambda expressions cannot be used as function arguments directly. Use the lambda() function to create a lambda expression." [parser] parsingStatement = "Parsing statement: %{statement}" diff --git a/lib/dsc-lib/src/configure/context.rs b/lib/dsc-lib/src/configure/context.rs index 50641ee96..bc36c8d9f 100644 --- a/lib/dsc-lib/src/configure/context.rs +++ b/lib/dsc-lib/src/configure/context.rs @@ -12,6 +12,7 @@ use super::config_doc::{DataType, RestartRequired, SecurityContextKind}; #[derive(Debug, Clone, Eq, PartialEq)] pub enum ProcessMode { Copy, + Lambda, Normal, NoExpressionEvaluation, ParametersDefault, @@ -25,6 +26,9 @@ pub struct Context { pub dsc_version: Option, pub execution_type: ExecutionKind, pub extensions: Vec, + pub lambda_raw_args: std::cell::RefCell>>, + pub lambda_variables: HashMap, + pub lambdas: std::cell::RefCell>, pub outputs: Map, pub parameters: HashMap, pub process_expressions: bool, @@ -49,6 +53,9 @@ impl Context { dsc_version: None, execution_type: ExecutionKind::Actual, extensions: Vec::new(), + lambda_raw_args: std::cell::RefCell::new(None), + lambda_variables: HashMap::new(), + lambdas: std::cell::RefCell::new(HashMap::new()), outputs: Map::new(), parameters: HashMap::new(), process_expressions: true, diff --git a/lib/dsc-lib/src/functions/filter.rs b/lib/dsc-lib/src/functions/filter.rs new file mode 100644 index 000000000..8a33b9f59 --- /dev/null +++ b/lib/dsc-lib/src/functions/filter.rs @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use crate::DscError; +use crate::configure::context::Context; +use crate::functions::{FunctionArgKind, Function, FunctionCategory, FunctionMetadata}; +use crate::functions::lambda_helpers::{get_lambda, apply_lambda_to_array}; +use rust_i18n::t; +use serde_json::Value; +use tracing::debug; + +#[derive(Debug, Default)] +pub struct Filter {} + +impl Function for Filter { + fn get_metadata(&self) -> FunctionMetadata { + FunctionMetadata { + name: "filter".to_string(), + description: t!("functions.filter.description").to_string(), + category: vec![FunctionCategory::Array, FunctionCategory::Lambda], + min_args: 2, + max_args: 2, + accepted_arg_ordered_types: vec![ + vec![FunctionArgKind::Array], + vec![FunctionArgKind::Lambda], + ], + remaining_arg_accepted_types: None, + return_types: vec![FunctionArgKind::Array], + } + } + + fn invoke(&self, args: &[Value], context: &Context) -> Result { + debug!("{}", t!("functions.filter.invoked")); + + let array = args[0].as_array().unwrap(); + let lambda_id = args[1].as_str().unwrap(); + + let lambdas = get_lambda(context, lambda_id, "filter")?; + let lambda = lambdas.get(lambda_id).unwrap(); + + let result_array = apply_lambda_to_array(array, lambda, context, |result, element| { + let Some(include) = result.as_bool() else { + return Err(DscError::Parser(t!("functions.filter.lambdaMustReturnBool").to_string())); + }; + if include { + Ok(Some(element.clone())) + } else { + Ok(None) + } + })?; + + Ok(Value::Array(result_array)) + } +} diff --git a/lib/dsc-lib/src/functions/lambda.rs b/lib/dsc-lib/src/functions/lambda.rs new file mode 100644 index 000000000..ec2f85b53 --- /dev/null +++ b/lib/dsc-lib/src/functions/lambda.rs @@ -0,0 +1,60 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use crate::DscError; +use crate::configure::context::Context; +use crate::functions::{FunctionArgKind, Function, FunctionCategory, FunctionMetadata}; +use crate::parser::functions::{FunctionArg, Lambda}; +use rust_i18n::t; +use serde_json::Value; +use tracing::debug; +use uuid::Uuid; + +#[derive(Debug, Default)] +pub struct LambdaFn {} + +impl Function for LambdaFn { + fn get_metadata(&self) -> FunctionMetadata { + FunctionMetadata { + name: "lambda".to_string(), + description: t!("functions.lambda.description").to_string(), + category: vec![FunctionCategory::Lambda], + min_args: 0, // Args come through context.lambda_raw_args + max_args: 0, + accepted_arg_ordered_types: vec![], + remaining_arg_accepted_types: None, + return_types: vec![FunctionArgKind::Lambda], + } + } + + fn invoke(&self, _args: &[Value], context: &Context) -> Result { + debug!("{}", t!("functions.lambda.invoked")); + + let raw_args = context.lambda_raw_args.borrow(); + let args = raw_args.as_ref() + .filter(|a| a.len() >= 2) + .ok_or_else(|| DscError::Parser(t!("functions.lambda.requiresParamAndBody").to_string()))?; + + let (body_arg, param_args) = args.split_last().unwrap(); // safe: len >= 2 + + let parameters: Vec = param_args.iter() + .map(|arg| match arg { + FunctionArg::Value(Value::String(s)) => Ok(s.clone()), + _ => Err(DscError::Parser(t!("functions.lambda.paramsMustBeStrings").to_string())), + }) + .collect::>()?; + + // Extract body expression + let body = match body_arg { + FunctionArg::Expression(expr) => expr.clone(), + _ => return Err(DscError::Parser(t!("functions.lambda.bodyMustBeExpression").to_string())), + }; + + // Create Lambda and store in Context with unique ID + let lambda = Lambda { parameters, body }; + let lambda_id = format!("__lambda_{}", Uuid::new_v4()); + context.lambdas.borrow_mut().insert(lambda_id.clone(), lambda); + + Ok(Value::String(lambda_id)) + } +} diff --git a/lib/dsc-lib/src/functions/lambda_helpers.rs b/lib/dsc-lib/src/functions/lambda_helpers.rs new file mode 100644 index 000000000..ec3b6561e --- /dev/null +++ b/lib/dsc-lib/src/functions/lambda_helpers.rs @@ -0,0 +1,102 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Helper functions for lambda-consuming functions like `map()` and `filter()`. +//! +//! This module provides common utilities for retrieving lambdas from context, +//! validating lambda parameters, and iterating over arrays with lambda application. + +use crate::DscError; +use crate::configure::context::Context; +use crate::parser::functions::Lambda; +use crate::functions::FunctionDispatcher; +use rust_i18n::t; +use serde_json::Value; +use std::cell::Ref; + +/// Retrieves a lambda from the context and validates it has 1 or 2 parameters. +/// +/// # Arguments +/// +/// * `context` - The context containing the lambda registry +/// * `lambda_id` - The lambda ID string (e.g., `__lambda_`) +/// * `func_name` - The name of the calling function (for error messages) +/// +/// # Returns +/// +/// A reference to the borrowed lambdas HashMap. The caller must use the returned +/// `Ref` to access the lambda to keep the borrow active. +/// +/// # Errors +/// +/// Returns an error if the lambda is not found or has invalid parameter count. +pub fn get_lambda<'a>( + context: &'a Context, + lambda_id: &str, + func_name: &str, +) -> Result>, DscError> { + let lambdas = context.lambdas.borrow(); + + if !lambdas.contains_key(lambda_id) { + return Err(DscError::Parser(t!("functions.lambdaNotFound", name = func_name, id = lambda_id).to_string())); + } + + let lambda = lambdas.get(lambda_id).unwrap(); + if lambda.parameters.is_empty() || lambda.parameters.len() > 2 { + return Err(DscError::Parser(t!("functions.lambdaTooManyParams", name = func_name).to_string())); + } + + Ok(lambdas) +} + +/// Applies a lambda to each element of an array, yielding transformed values. +/// +/// This is the core iteration logic shared by `map()`, `filter()`, and similar +/// lambda-consuming functions. +/// +/// # Arguments +/// +/// * `array` - The input array to iterate over +/// * `lambda` - The lambda to apply to each element +/// * `context` - The base context (will be cloned for each iteration) +/// * `mut apply` - A closure that receives the lambda result and can transform/filter it +/// +/// # Returns +/// +/// A vector of values produced by the `apply` closure. +pub fn apply_lambda_to_array( + array: &[Value], + lambda: &Lambda, + context: &Context, + mut apply: F, +) -> Result, DscError> +where + F: FnMut(Value, &Value) -> Result, DscError>, +{ + let dispatcher = FunctionDispatcher::new(); + let mut results = Vec::new(); + + for (index, element) in array.iter().enumerate() { + let mut lambda_context = context.clone(); + + lambda_context.lambda_variables.insert( + lambda.parameters[0].clone(), + element.clone() + ); + + if lambda.parameters.len() == 2 { + lambda_context.lambda_variables.insert( + lambda.parameters[1].clone(), + Value::Number(serde_json::Number::from(index)) + ); + } + + let result = lambda.body.invoke(&dispatcher, &lambda_context)?; + + if let Some(value) = apply(result, element)? { + results.push(value); + } + } + + Ok(results) +} diff --git a/lib/dsc-lib/src/functions/lambda_variables.rs b/lib/dsc-lib/src/functions/lambda_variables.rs new file mode 100644 index 000000000..5a557278d --- /dev/null +++ b/lib/dsc-lib/src/functions/lambda_variables.rs @@ -0,0 +1,71 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use crate::DscError; +use crate::configure::context::Context; +use crate::functions::{FunctionArgKind, Function, FunctionCategory, FunctionMetadata}; +use rust_i18n::t; +use serde_json::Value; +use tracing::debug; + +#[derive(Debug, Default)] +pub struct LambdaVariables {} + +impl Function for LambdaVariables { + fn get_metadata(&self) -> FunctionMetadata { + FunctionMetadata { + name: "lambdaVariables".to_string(), + description: t!("functions.lambdaVariables.description").to_string(), + category: vec![FunctionCategory::Lambda], + min_args: 1, + max_args: 1, + accepted_arg_ordered_types: vec![vec![FunctionArgKind::String]], + remaining_arg_accepted_types: None, + return_types: vec![ + FunctionArgKind::String, + FunctionArgKind::Number, + FunctionArgKind::Boolean, + FunctionArgKind::Array, + FunctionArgKind::Object, + FunctionArgKind::Null, + ], + } + } + + fn invoke(&self, args: &[Value], context: &Context) -> Result { + debug!("{}", t!("functions.lambdaVariables.invoked")); + + let var_name = args[0].as_str().unwrap(); + + // Look up the variable in the lambda context + if let Some(value) = context.lambda_variables.get(var_name) { + Ok(value.clone()) + } else { + Err(DscError::Parser(t!("functions.lambdaVariables.notFound", name = var_name).to_string())) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn lookup_existing_variable() { + let mut context = Context::new(); + context.lambda_variables.insert("x".to_string(), json!(42)); + + let func = LambdaVariables {}; + let result = func.invoke(&[Value::String("x".to_string())], &context).unwrap(); + assert_eq!(result, json!(42)); + } + + #[test] + fn lookup_nonexistent_variable() { + let context = Context::new(); + let func = LambdaVariables {}; + let result = func.invoke(&[Value::String("x".to_string())], &context); + assert!(result.is_err()); + } +} diff --git a/lib/dsc-lib/src/functions/map.rs b/lib/dsc-lib/src/functions/map.rs new file mode 100644 index 000000000..1e57ebd4c --- /dev/null +++ b/lib/dsc-lib/src/functions/map.rs @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use crate::DscError; +use crate::configure::context::Context; +use crate::functions::{FunctionArgKind, Function, FunctionCategory, FunctionMetadata}; +use crate::functions::lambda_helpers::{get_lambda, apply_lambda_to_array}; +use rust_i18n::t; +use serde_json::Value; +use tracing::debug; + +#[derive(Debug, Default)] +pub struct Map {} + +impl Function for Map { + fn get_metadata(&self) -> FunctionMetadata { + FunctionMetadata { + name: "map".to_string(), + description: t!("functions.map.description").to_string(), + category: vec![FunctionCategory::Array, FunctionCategory::Lambda], + min_args: 2, + max_args: 2, + accepted_arg_ordered_types: vec![ + vec![FunctionArgKind::Array], + vec![FunctionArgKind::Lambda], + ], + remaining_arg_accepted_types: None, + return_types: vec![FunctionArgKind::Array], + } + } + + fn invoke(&self, args: &[Value], context: &Context) -> Result { + debug!("{}", t!("functions.map.invoked")); + + let array = args[0].as_array().unwrap(); + let lambda_id = args[1].as_str().unwrap(); + let lambdas = get_lambda(context, lambda_id, "map")?; + let lambda = lambdas.get(lambda_id).unwrap(); + let result_array = apply_lambda_to_array(array, lambda, context, |result, _element| { + Ok(Some(result)) + })?; + + Ok(Value::Array(result_array)) + } +} diff --git a/lib/dsc-lib/src/functions/mod.rs b/lib/dsc-lib/src/functions/mod.rs index 5c53bd3be..5881fcb9e 100644 --- a/lib/dsc-lib/src/functions/mod.rs +++ b/lib/dsc-lib/src/functions/mod.rs @@ -4,7 +4,7 @@ use std::collections::HashMap; use crate::DscError; -use crate::configure::context::Context; +use crate::configure::context::{Context, ProcessMode}; use crate::functions::user_function::invoke_user_function; use crate::schemas::dsc_repo::DscRepoSchema; use rust_i18n::t; @@ -33,6 +33,7 @@ pub mod empty; pub mod ends_with; pub mod envvar; pub mod equals; +pub mod filter; pub mod greater; pub mod greater_or_equals; pub mod r#if; @@ -49,7 +50,11 @@ pub mod intersection; pub mod items; pub mod join; pub mod json; +pub mod lambda; +pub mod lambda_helpers; +pub mod lambda_variables; pub mod last_index_of; +pub mod map; pub mod max; pub mod min; pub mod mod_function; @@ -96,6 +101,7 @@ pub mod try_which; pub enum FunctionArgKind { Array, Boolean, + Lambda, Null, Number, Object, @@ -107,6 +113,7 @@ impl Display for FunctionArgKind { match self { FunctionArgKind::Array => write!(f, "Array"), FunctionArgKind::Boolean => write!(f, "Boolean"), + FunctionArgKind::Lambda => write!(f, "Lambda"), FunctionArgKind::Null => write!(f, "Null"), FunctionArgKind::Number => write!(f, "Number"), FunctionArgKind::Object => write!(f, "Object"), @@ -188,7 +195,11 @@ impl FunctionDispatcher { Box::new(items::Items{}), Box::new(join::Join{}), Box::new(json::Json{}), + Box::new(filter::Filter{}), + Box::new(lambda::LambdaFn{}), + Box::new(lambda_variables::LambdaVariables{}), Box::new(last_index_of::LastIndexOf{}), + Box::new(map::Map{}), Box::new(max::Max{}), Box::new(min::Min{}), Box::new(mod_function::Mod{}), @@ -284,27 +295,40 @@ impl FunctionDispatcher { } // if we have remaining args, they must match one of the remaining_arg_types - if let Some(remaining_arg_types) = metadata.remaining_arg_accepted_types { + if let Some(ref remaining_arg_types) = metadata.remaining_arg_accepted_types { for value in args.iter().skip(metadata.accepted_arg_ordered_types.len()) { - Self::check_arg_against_expected_types(name, value, &remaining_arg_types)?; + Self::check_arg_against_expected_types(name, value, remaining_arg_types)?; } } - function.invoke(args, context) + let accepts_lambda = metadata.accepted_arg_ordered_types.iter().any(|types| types.contains(&FunctionArgKind::Lambda)) + || metadata.remaining_arg_accepted_types.as_ref().is_some_and(|types| types.contains(&FunctionArgKind::Lambda)); + + if accepts_lambda { + let mut lambda_context = context.clone(); + lambda_context.process_mode = ProcessMode::Lambda; + function.invoke(args, &lambda_context) + } else { + function.invoke(args, context) + } } fn check_arg_against_expected_types(name: &str, arg: &Value, expected_types: &[FunctionArgKind]) -> Result<(), DscError> { + let is_lambda = arg.as_str().is_some_and(|s| s.starts_with("__lambda_")); + if arg.is_array() && !expected_types.contains(&FunctionArgKind::Array) { return Err(DscError::Parser(t!("functions.noArrayArgs", name = name, accepted_args_string = expected_types.iter().map(std::string::ToString::to_string).collect::>().join(", ")).to_string())); } else if arg.is_boolean() && !expected_types.contains(&FunctionArgKind::Boolean) { return Err(DscError::Parser(t!("functions.noBooleanArgs", name = name, accepted_args_string = expected_types.iter().map(std::string::ToString::to_string).collect::>().join(", ")).to_string())); + } else if is_lambda && !expected_types.contains(&FunctionArgKind::Lambda) { + return Err(DscError::Parser(t!("functions.noLambdaArgs", name = name, accepted_args_string = expected_types.iter().map(std::string::ToString::to_string).collect::>().join(", ")).to_string())); } else if arg.is_null() && !expected_types.contains(&FunctionArgKind::Null) { return Err(DscError::Parser(t!("functions.noNullArgs", name = name, accepted_args_string = expected_types.iter().map(std::string::ToString::to_string).collect::>().join(", ")).to_string())); } else if arg.is_number() && !expected_types.contains(&FunctionArgKind::Number) { return Err(DscError::Parser(t!("functions.noNumberArgs", name = name, accepted_args_string = expected_types.iter().map(std::string::ToString::to_string).collect::>().join(", ")).to_string())); } else if arg.is_object() && !expected_types.contains(&FunctionArgKind::Object) { return Err(DscError::Parser(t!("functions.noObjectArgs", name = name, accepted_args_string = expected_types.iter().map(std::string::ToString::to_string).collect::>().join(", ")).to_string())); - } else if arg.is_string() && !expected_types.contains(&FunctionArgKind::String) { + } else if arg.is_string() && !is_lambda && !expected_types.contains(&FunctionArgKind::String) { return Err(DscError::Parser(t!("functions.noStringArgs", name = name, accepted_args_string = expected_types.iter().map(std::string::ToString::to_string).collect::>().join(", ")).to_string())); } Ok(()) diff --git a/lib/dsc-lib/src/parser/functions.rs b/lib/dsc-lib/src/parser/functions.rs index 70847ef14..6a507a582 100644 --- a/lib/dsc-lib/src/parser/functions.rs +++ b/lib/dsc-lib/src/parser/functions.rs @@ -23,6 +23,48 @@ pub struct Function { pub enum FunctionArg { Value(Value), Expression(Expression), + Lambda(Lambda), +} + +/// Represents a lambda expression for use in DSC function expressions. +/// +/// Lambda expressions are anonymous functions created using the `lambda()` function +/// and are primarily used with higher-order functions like `map()` to transform data. +/// Each lambda is stored in the context's lambda registry with a unique UUID identifier. +/// +/// # Structure +/// +/// A lambda consists of: +/// - **parameters**: A list of parameter names (e.g., `["item", "index"]`) that will be +/// bound to values when the lambda is invoked +/// - **body**: An expression tree that is evaluated with the bound parameters in scope +/// +/// # Usage in DSC +/// +/// Lambdas are created using the `lambda()` function syntax: +/// ```text +/// "[lambda(['item', 'index'], mul(variables('item'), 2))]" +/// ``` +/// +/// The lambda is stored in the context and referenced by UUID: +/// ```text +/// __lambda_ +/// ``` +/// +/// When used with `map()`, the lambda is invoked for each array element with bound parameters: +/// ```text +/// "[map(createArray(1, 2, 3), lambda(['item'], mul(variables('item'), 2)))]" +/// ``` +/// +/// # Lifetime +/// +/// Lambdas are stored for the duration of a single configuration evaluation and are +/// automatically cleaned up when the `Context` is dropped at the end of processing. +/// Each configuration evaluation starts with a fresh, empty lambda registry. +#[derive(Clone, Debug)] +pub struct Lambda { + pub parameters: Vec, + pub body: Expression, } impl Function { @@ -66,6 +108,16 @@ impl Function { /// /// This function will return an error if the function fails to execute. pub fn invoke(&self, function_dispatcher: &FunctionDispatcher, context: &Context) -> Result { + // Special handling for lambda() function - pass raw args through context + if self.name.to_lowercase() == "lambda" { + // Store raw args in context for lambda function to access + *context.lambda_raw_args.borrow_mut() = self.args.clone(); + let result = function_dispatcher.invoke("lambda", &[], context); + // Clear raw args + *context.lambda_raw_args.borrow_mut() = None; + return result; + } + // if any args are expressions, we need to invoke those first let mut resolved_args: Vec = vec![]; if let Some(args) = &self.args { @@ -79,6 +131,9 @@ impl Function { FunctionArg::Value(value) => { debug!("{}", t!("parser.functions.argIsValue", value = value : {:?})); resolved_args.push(value.clone()); + }, + FunctionArg::Lambda(_lambda) => { + return Err(DscError::Parser(t!("parser.functions.unexpectedLambda").to_string())); } } }