From dd9d3d34b8f413fe6087bd5bdb2a05d0717a13ad Mon Sep 17 00:00:00 2001 From: "G.Reijn" Date: Wed, 5 Nov 2025 02:05:26 +0100 Subject: [PATCH 01/16] Initial setup --- dsc/tests/dsc_map_lambda.tests.ps1 | 68 +++++++++++++ lib/dsc-lib/locales/en-us.toml | 22 +++++ lib/dsc-lib/src/configure/context.rs | 4 + lib/dsc-lib/src/functions/lambda.rs | 38 +++++++ lib/dsc-lib/src/functions/lambda_variables.rs | 77 +++++++++++++++ lib/dsc-lib/src/functions/map.rs | 99 +++++++++++++++++++ lib/dsc-lib/src/functions/mod.rs | 63 ++++++++++++ lib/dsc-lib/src/parser/functions.rs | 16 +++ 8 files changed, 387 insertions(+) create mode 100644 dsc/tests/dsc_map_lambda.tests.ps1 create mode 100644 lib/dsc-lib/src/functions/lambda.rs create mode 100644 lib/dsc-lib/src/functions/lambda_variables.rs create mode 100644 lib/dsc-lib/src/functions/map.rs diff --git a/dsc/tests/dsc_map_lambda.tests.ps1 b/dsc/tests/dsc_map_lambda.tests.ps1 new file mode 100644 index 000000000..9199b90da --- /dev/null +++ b/dsc/tests/dsc_map_lambda.tests.ps1 @@ -0,0 +1,68 @@ +# 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 | Should -Be $null + } +} diff --git a/lib/dsc-lib/locales/en-us.toml b/lib/dsc-lib/locales/en-us.toml index 40e21a47d..2ff83e162 100644 --- a/lib/dsc-lib/locales/en-us.toml +++ b/lib/dsc-lib/locales/en-us.toml @@ -401,11 +401,33 @@ 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" +cannotInvokeDirectly = "lambda() should not be invoked directly" +requiresArgs = "lambda() requires at least 2 arguments" +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" +paramNameMustBeString = "lambdaVariables() parameter name must be a string" +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" +firstArgMustBeArray = "map() first argument must be an array" +secondArgMustBeLambda = "map() second argument must be a lambda function" +lambdaNotFound = "Lambda function with ID '%{id}' not found" +lambdaMustHave1Or2Params = "map() lambda must have 1 or 2 parameters (element and optional index)" + [functions.length] description = "Returns the length of a string, array, or object" invoked = "length function" diff --git a/lib/dsc-lib/src/configure/context.rs b/lib/dsc-lib/src/configure/context.rs index abd52948d..b60ae9f81 100644 --- a/lib/dsc-lib/src/configure/context.rs +++ b/lib/dsc-lib/src/configure/context.rs @@ -25,6 +25,8 @@ pub struct Context { pub dsc_version: Option, pub execution_type: ExecutionKind, pub extensions: Vec, + pub lambda_variables: HashMap, + pub lambdas: std::cell::RefCell>, pub outputs: Map, pub parameters: HashMap, pub process_expressions: bool, @@ -48,6 +50,8 @@ impl Context { dsc_version: None, execution_type: ExecutionKind::Actual, extensions: Vec::new(), + 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/lambda.rs b/lib/dsc-lib/src/functions/lambda.rs new file mode 100644 index 000000000..7d7ae7cc8 --- /dev/null +++ b/lib/dsc-lib/src/functions/lambda.rs @@ -0,0 +1,38 @@ +// 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; + + +/// The lambda() function is special - it's not meant to be invoked directly +/// through the normal function dispatcher path. Instead, it's caught in the +/// Function::invoke method and handled specially via invoke_lambda(). +/// +/// This struct exists for metadata purposes and to signal errors if someone +/// tries to invoke lambda() as a regular function (which shouldn't happen). +#[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: 2, + max_args: 10, // Up to 9 parameters + 1 body + accepted_arg_ordered_types: vec![], + remaining_arg_accepted_types: None, + return_types: vec![FunctionArgKind::Object], // Lambda is represented as a special object + } + } + + fn invoke(&self, _args: &[Value], _context: &Context) -> Result { + // This should never be called - lambda() is handled specially in Function::invoke + Err(DscError::Parser(t!("functions.lambda.cannotInvokeDirectly").to_string())) + } +} 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..6d13d3e7c --- /dev/null +++ b/lib/dsc-lib/src/functions/lambda_variables.rs @@ -0,0 +1,77 @@ +// 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")); + + if args.len() != 1 { + return Err(DscError::Parser(t!("functions.invalidArgCount", name = "lambdaVariables", count = 1).to_string())); + } + + let Some(var_name) = args[0].as_str() else { + return Err(DscError::Parser(t!("functions.lambdaVariables.paramNameMustBeString").to_string())); + }; + + // 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..cdeaa1617 --- /dev/null +++ b/lib/dsc-lib/src/functions/map.rs @@ -0,0 +1,99 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use crate::DscError; +use crate::configure::context::Context; +use crate::functions::{FunctionArgKind, Function, FunctionCategory, FunctionMetadata, FunctionDispatcher}; +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::String], // Lambda ID as string + ], + remaining_arg_accepted_types: None, + return_types: vec![FunctionArgKind::Array], + } + } + + fn invoke(&self, args: &[Value], context: &Context) -> Result { + debug!("{}", t!("functions.map.invoked")); + + if args.len() != 2 { + return Err(DscError::Parser(t!("functions.invalidArgCount", name = "map", count = 2).to_string())); + } + + let Some(array) = args[0].as_array() else { + return Err(DscError::Parser(t!("functions.map.firstArgMustBeArray").to_string())); + }; + + let Some(lambda_id) = args[1].as_str() else { + return Err(DscError::Parser(t!("functions.map.secondArgMustBeLambda").to_string())); + }; + + // Retrieve the lambda from context + let lambdas = context.lambdas.borrow(); + let Some(lambda) = lambdas.get(lambda_id) else { + return Err(DscError::Parser(t!("functions.map.lambdaNotFound", id = lambda_id).to_string())); + }; + + // Validate parameter count (1 or 2 parameters) + if lambda.parameters.is_empty() || lambda.parameters.len() > 2 { + return Err(DscError::Parser(t!("functions.map.lambdaMustHave1Or2Params").to_string())); + } + + // Create function dispatcher for evaluating lambda body + let dispatcher = FunctionDispatcher::new(); + let mut result_array = Vec::new(); + + // Iterate through array and evaluate lambda for each element + for (index, element) in array.iter().enumerate() { + // Create a new context with lambda variables bound + let mut lambda_context = context.clone(); + + // Bind first parameter to array element + lambda_context.lambda_variables.insert( + lambda.parameters[0].clone(), + element.clone() + ); + + // Bind second parameter to index if provided + if lambda.parameters.len() == 2 { + lambda_context.lambda_variables.insert( + lambda.parameters[1].clone(), + Value::Number(serde_json::Number::from(index)) + ); + } + + // Evaluate lambda body with bound variables + let result = lambda.body.invoke(&dispatcher, &lambda_context)?; + result_array.push(result); + } + + Ok(Value::Array(result_array)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn requires_two_args() { + let func = Map {}; + let result = func.invoke(&[], &Context::new()); + assert!(result.is_err()); + } +} diff --git a/lib/dsc-lib/src/functions/mod.rs b/lib/dsc-lib/src/functions/mod.rs index 94d008202..f7e4907b1 100644 --- a/lib/dsc-lib/src/functions/mod.rs +++ b/lib/dsc-lib/src/functions/mod.rs @@ -46,7 +46,10 @@ pub mod intersection; pub mod items; pub mod join; pub mod json; +pub mod lambda; +pub mod lambda_variables; pub mod last_index_of; +pub mod map; pub mod max; pub mod min; pub mod mod_function; @@ -177,7 +180,10 @@ impl FunctionDispatcher { Box::new(items::Items{}), Box::new(join::Join{}), Box::new(json::Json{}), + 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{}), @@ -277,6 +283,63 @@ impl FunctionDispatcher { function.invoke(args, context) } + /// Special handler for lambda() function calls. + /// Creates a Lambda object and stores it in Context with a unique ID. + /// + /// # Arguments + /// + /// * `args` - Raw FunctionArg list (unevaluated) + /// * `context` - Context to store the lambda in + /// + /// # Errors + /// + /// This function will return an error if the lambda syntax is invalid. + pub fn invoke_lambda(&self, args: &Option>, context: &Context) -> Result { + use crate::parser::functions::{FunctionArg, Lambda}; + use uuid::Uuid; + + let Some(args) = args else { + return Err(DscError::Parser(t!("functions.lambda.requiresArgs").to_string())); + }; + + if args.len() < 2 { + return Err(DscError::Parser(t!("functions.lambda.requiresParamAndBody").to_string())); + } + + // All arguments except the last must be string values (parameter names) + let mut parameters = Vec::new(); + for arg in args.iter().take(args.len() - 1) { + match arg { + FunctionArg::Value(Value::String(s)) => { + parameters.push(s.clone()); + }, + _ => { + return Err(DscError::Parser(t!("functions.lambda.paramsMustBeStrings").to_string())); + } + } + } + + // Last argument is the body expression + let body_expr = match &args[args.len() - 1] { + 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: body_expr, + }; + + let lambda_id = format!("__lambda_{}", Uuid::new_v4()); + context.lambdas.borrow_mut().insert(lambda_id.clone(), lambda); + + // Return the ID as a string value + Ok(Value::String(lambda_id)) + } + fn check_arg_against_expected_types(name: &str, arg: &Value, expected_types: &[FunctionArgKind]) -> Result<(), DscError> { 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())); diff --git a/lib/dsc-lib/src/parser/functions.rs b/lib/dsc-lib/src/parser/functions.rs index a01d3e8aa..10ddb079e 100644 --- a/lib/dsc-lib/src/parser/functions.rs +++ b/lib/dsc-lib/src/parser/functions.rs @@ -23,6 +23,13 @@ pub struct Function { pub enum FunctionArg { Value(Value), Expression(Expression), + Lambda(Lambda), +} + +#[derive(Clone)] +pub struct Lambda { + pub parameters: Vec, + pub body: Expression, } impl Function { @@ -66,6 +73,11 @@ 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 - don't evaluate it, just pass args through + if self.name.to_lowercase() == "lambda" { + return function_dispatcher.invoke_lambda(&self.args, context); + } + // 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 +91,10 @@ impl Function { FunctionArg::Value(value) => { debug!("{}", t!("parser.functions.argIsValue", value = value : {:?})); resolved_args.push(value.clone()); + }, + FunctionArg::Lambda(_lambda) => { + // This shouldn't happen - lambdas should only be created by lambda() function + return Err(DscError::Parser(t!("parser.functions.unexpectedLambda").to_string())); } } } From fed30d407a2dade74ceaf067a092cf758100a9df Mon Sep 17 00:00:00 2001 From: "G.Reijn" Date: Wed, 5 Nov 2025 04:12:22 +0100 Subject: [PATCH 02/16] Fix comments --- dsc/tests/{dsc_map_lambda.tests.ps1 => dsc_lambda.tests.ps1} | 0 lib/dsc-lib/src/parser/functions.rs | 1 - 2 files changed, 1 deletion(-) rename dsc/tests/{dsc_map_lambda.tests.ps1 => dsc_lambda.tests.ps1} (100%) diff --git a/dsc/tests/dsc_map_lambda.tests.ps1 b/dsc/tests/dsc_lambda.tests.ps1 similarity index 100% rename from dsc/tests/dsc_map_lambda.tests.ps1 rename to dsc/tests/dsc_lambda.tests.ps1 diff --git a/lib/dsc-lib/src/parser/functions.rs b/lib/dsc-lib/src/parser/functions.rs index 10ddb079e..8e49dc198 100644 --- a/lib/dsc-lib/src/parser/functions.rs +++ b/lib/dsc-lib/src/parser/functions.rs @@ -93,7 +93,6 @@ impl Function { resolved_args.push(value.clone()); }, FunctionArg::Lambda(_lambda) => { - // This shouldn't happen - lambdas should only be created by lambda() function return Err(DscError::Parser(t!("parser.functions.unexpectedLambda").to_string())); } } From 9b057281de54754fa0109bee2c65c4471f7e2008 Mon Sep 17 00:00:00 2001 From: "G.Reijn" Date: Wed, 5 Nov 2025 04:30:48 +0100 Subject: [PATCH 03/16] Re-add localization --- lib/dsc-lib/locales/en-us.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/dsc-lib/locales/en-us.toml b/lib/dsc-lib/locales/en-us.toml index 2ff83e162..82d25877a 100644 --- a/lib/dsc-lib/locales/en-us.toml +++ b/lib/dsc-lib/locales/en-us.toml @@ -652,6 +652,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}" From acc8181bfbcda06f38a16f90e100a739ca47f13b Mon Sep 17 00:00:00 2001 From: "G.Reijn" Date: Fri, 14 Nov 2025 02:15:04 +0100 Subject: [PATCH 04/16] Remove dead code and add instructions --- dsc/tests/dsc_lambda.tests.ps1 | 2 +- lib/dsc-lib/src/parser/functions.rs | 39 ++++++++++++++++++++++++++--- 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/dsc/tests/dsc_lambda.tests.ps1 b/dsc/tests/dsc_lambda.tests.ps1 index 9199b90da..562ba66f4 100644 --- a/dsc/tests/dsc_lambda.tests.ps1 +++ b/dsc/tests/dsc_lambda.tests.ps1 @@ -63,6 +63,6 @@ resources: '@ $out = $config_yaml | dsc config get -f - | ConvertFrom-Json $LASTEXITCODE | Should -Be 0 - $out.results[0].result.actualState.output | Should -Be $null + $out.results[0].result.actualState.Count | Should -Be 0 } } diff --git a/lib/dsc-lib/src/parser/functions.rs b/lib/dsc-lib/src/parser/functions.rs index 8e49dc198..c04554083 100644 --- a/lib/dsc-lib/src/parser/functions.rs +++ b/lib/dsc-lib/src/parser/functions.rs @@ -23,9 +23,43 @@ 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)] pub struct Lambda { pub parameters: Vec, @@ -91,9 +125,6 @@ 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())); } } } From 3e019fe0fc80edfa4a8708683d6472181739cf81 Mon Sep 17 00:00:00 2001 From: "G.Reijn" Date: Wed, 5 Nov 2025 02:05:26 +0100 Subject: [PATCH 05/16] Initial setup --- dsc/tests/dsc_map_lambda.tests.ps1 | 68 +++++++++++++ lib/dsc-lib/locales/en-us.toml | 22 +++++ lib/dsc-lib/src/configure/context.rs | 4 + lib/dsc-lib/src/functions/lambda.rs | 38 +++++++ lib/dsc-lib/src/functions/lambda_variables.rs | 77 +++++++++++++++ lib/dsc-lib/src/functions/map.rs | 99 +++++++++++++++++++ lib/dsc-lib/src/functions/mod.rs | 63 ++++++++++++ lib/dsc-lib/src/parser/functions.rs | 16 +++ 8 files changed, 387 insertions(+) create mode 100644 dsc/tests/dsc_map_lambda.tests.ps1 create mode 100644 lib/dsc-lib/src/functions/lambda.rs create mode 100644 lib/dsc-lib/src/functions/lambda_variables.rs create mode 100644 lib/dsc-lib/src/functions/map.rs diff --git a/dsc/tests/dsc_map_lambda.tests.ps1 b/dsc/tests/dsc_map_lambda.tests.ps1 new file mode 100644 index 000000000..9199b90da --- /dev/null +++ b/dsc/tests/dsc_map_lambda.tests.ps1 @@ -0,0 +1,68 @@ +# 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 | Should -Be $null + } +} diff --git a/lib/dsc-lib/locales/en-us.toml b/lib/dsc-lib/locales/en-us.toml index 5061ef205..7ff4901fa 100644 --- a/lib/dsc-lib/locales/en-us.toml +++ b/lib/dsc-lib/locales/en-us.toml @@ -401,11 +401,33 @@ 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" +cannotInvokeDirectly = "lambda() should not be invoked directly" +requiresArgs = "lambda() requires at least 2 arguments" +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" +paramNameMustBeString = "lambdaVariables() parameter name must be a string" +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" +firstArgMustBeArray = "map() first argument must be an array" +secondArgMustBeLambda = "map() second argument must be a lambda function" +lambdaNotFound = "Lambda function with ID '%{id}' not found" +lambdaMustHave1Or2Params = "map() lambda must have 1 or 2 parameters (element and optional index)" + [functions.length] description = "Returns the length of a string, array, or object" invoked = "length function" diff --git a/lib/dsc-lib/src/configure/context.rs b/lib/dsc-lib/src/configure/context.rs index abd52948d..b60ae9f81 100644 --- a/lib/dsc-lib/src/configure/context.rs +++ b/lib/dsc-lib/src/configure/context.rs @@ -25,6 +25,8 @@ pub struct Context { pub dsc_version: Option, pub execution_type: ExecutionKind, pub extensions: Vec, + pub lambda_variables: HashMap, + pub lambdas: std::cell::RefCell>, pub outputs: Map, pub parameters: HashMap, pub process_expressions: bool, @@ -48,6 +50,8 @@ impl Context { dsc_version: None, execution_type: ExecutionKind::Actual, extensions: Vec::new(), + 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/lambda.rs b/lib/dsc-lib/src/functions/lambda.rs new file mode 100644 index 000000000..7d7ae7cc8 --- /dev/null +++ b/lib/dsc-lib/src/functions/lambda.rs @@ -0,0 +1,38 @@ +// 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; + + +/// The lambda() function is special - it's not meant to be invoked directly +/// through the normal function dispatcher path. Instead, it's caught in the +/// Function::invoke method and handled specially via invoke_lambda(). +/// +/// This struct exists for metadata purposes and to signal errors if someone +/// tries to invoke lambda() as a regular function (which shouldn't happen). +#[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: 2, + max_args: 10, // Up to 9 parameters + 1 body + accepted_arg_ordered_types: vec![], + remaining_arg_accepted_types: None, + return_types: vec![FunctionArgKind::Object], // Lambda is represented as a special object + } + } + + fn invoke(&self, _args: &[Value], _context: &Context) -> Result { + // This should never be called - lambda() is handled specially in Function::invoke + Err(DscError::Parser(t!("functions.lambda.cannotInvokeDirectly").to_string())) + } +} 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..6d13d3e7c --- /dev/null +++ b/lib/dsc-lib/src/functions/lambda_variables.rs @@ -0,0 +1,77 @@ +// 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")); + + if args.len() != 1 { + return Err(DscError::Parser(t!("functions.invalidArgCount", name = "lambdaVariables", count = 1).to_string())); + } + + let Some(var_name) = args[0].as_str() else { + return Err(DscError::Parser(t!("functions.lambdaVariables.paramNameMustBeString").to_string())); + }; + + // 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..cdeaa1617 --- /dev/null +++ b/lib/dsc-lib/src/functions/map.rs @@ -0,0 +1,99 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use crate::DscError; +use crate::configure::context::Context; +use crate::functions::{FunctionArgKind, Function, FunctionCategory, FunctionMetadata, FunctionDispatcher}; +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::String], // Lambda ID as string + ], + remaining_arg_accepted_types: None, + return_types: vec![FunctionArgKind::Array], + } + } + + fn invoke(&self, args: &[Value], context: &Context) -> Result { + debug!("{}", t!("functions.map.invoked")); + + if args.len() != 2 { + return Err(DscError::Parser(t!("functions.invalidArgCount", name = "map", count = 2).to_string())); + } + + let Some(array) = args[0].as_array() else { + return Err(DscError::Parser(t!("functions.map.firstArgMustBeArray").to_string())); + }; + + let Some(lambda_id) = args[1].as_str() else { + return Err(DscError::Parser(t!("functions.map.secondArgMustBeLambda").to_string())); + }; + + // Retrieve the lambda from context + let lambdas = context.lambdas.borrow(); + let Some(lambda) = lambdas.get(lambda_id) else { + return Err(DscError::Parser(t!("functions.map.lambdaNotFound", id = lambda_id).to_string())); + }; + + // Validate parameter count (1 or 2 parameters) + if lambda.parameters.is_empty() || lambda.parameters.len() > 2 { + return Err(DscError::Parser(t!("functions.map.lambdaMustHave1Or2Params").to_string())); + } + + // Create function dispatcher for evaluating lambda body + let dispatcher = FunctionDispatcher::new(); + let mut result_array = Vec::new(); + + // Iterate through array and evaluate lambda for each element + for (index, element) in array.iter().enumerate() { + // Create a new context with lambda variables bound + let mut lambda_context = context.clone(); + + // Bind first parameter to array element + lambda_context.lambda_variables.insert( + lambda.parameters[0].clone(), + element.clone() + ); + + // Bind second parameter to index if provided + if lambda.parameters.len() == 2 { + lambda_context.lambda_variables.insert( + lambda.parameters[1].clone(), + Value::Number(serde_json::Number::from(index)) + ); + } + + // Evaluate lambda body with bound variables + let result = lambda.body.invoke(&dispatcher, &lambda_context)?; + result_array.push(result); + } + + Ok(Value::Array(result_array)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn requires_two_args() { + let func = Map {}; + let result = func.invoke(&[], &Context::new()); + assert!(result.is_err()); + } +} diff --git a/lib/dsc-lib/src/functions/mod.rs b/lib/dsc-lib/src/functions/mod.rs index 6cd7810f7..d8de07ada 100644 --- a/lib/dsc-lib/src/functions/mod.rs +++ b/lib/dsc-lib/src/functions/mod.rs @@ -48,7 +48,10 @@ pub mod intersection; pub mod items; pub mod join; pub mod json; +pub mod lambda; +pub mod lambda_variables; pub mod last_index_of; +pub mod map; pub mod max; pub mod min; pub mod mod_function; @@ -182,7 +185,10 @@ impl FunctionDispatcher { Box::new(items::Items{}), Box::new(join::Join{}), Box::new(json::Json{}), + 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{}), @@ -283,6 +289,63 @@ impl FunctionDispatcher { function.invoke(args, context) } + /// Special handler for lambda() function calls. + /// Creates a Lambda object and stores it in Context with a unique ID. + /// + /// # Arguments + /// + /// * `args` - Raw FunctionArg list (unevaluated) + /// * `context` - Context to store the lambda in + /// + /// # Errors + /// + /// This function will return an error if the lambda syntax is invalid. + pub fn invoke_lambda(&self, args: &Option>, context: &Context) -> Result { + use crate::parser::functions::{FunctionArg, Lambda}; + use uuid::Uuid; + + let Some(args) = args else { + return Err(DscError::Parser(t!("functions.lambda.requiresArgs").to_string())); + }; + + if args.len() < 2 { + return Err(DscError::Parser(t!("functions.lambda.requiresParamAndBody").to_string())); + } + + // All arguments except the last must be string values (parameter names) + let mut parameters = Vec::new(); + for arg in args.iter().take(args.len() - 1) { + match arg { + FunctionArg::Value(Value::String(s)) => { + parameters.push(s.clone()); + }, + _ => { + return Err(DscError::Parser(t!("functions.lambda.paramsMustBeStrings").to_string())); + } + } + } + + // Last argument is the body expression + let body_expr = match &args[args.len() - 1] { + 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: body_expr, + }; + + let lambda_id = format!("__lambda_{}", Uuid::new_v4()); + context.lambdas.borrow_mut().insert(lambda_id.clone(), lambda); + + // Return the ID as a string value + Ok(Value::String(lambda_id)) + } + fn check_arg_against_expected_types(name: &str, arg: &Value, expected_types: &[FunctionArgKind]) -> Result<(), DscError> { 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())); diff --git a/lib/dsc-lib/src/parser/functions.rs b/lib/dsc-lib/src/parser/functions.rs index a01d3e8aa..10ddb079e 100644 --- a/lib/dsc-lib/src/parser/functions.rs +++ b/lib/dsc-lib/src/parser/functions.rs @@ -23,6 +23,13 @@ pub struct Function { pub enum FunctionArg { Value(Value), Expression(Expression), + Lambda(Lambda), +} + +#[derive(Clone)] +pub struct Lambda { + pub parameters: Vec, + pub body: Expression, } impl Function { @@ -66,6 +73,11 @@ 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 - don't evaluate it, just pass args through + if self.name.to_lowercase() == "lambda" { + return function_dispatcher.invoke_lambda(&self.args, context); + } + // 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 +91,10 @@ impl Function { FunctionArg::Value(value) => { debug!("{}", t!("parser.functions.argIsValue", value = value : {:?})); resolved_args.push(value.clone()); + }, + FunctionArg::Lambda(_lambda) => { + // This shouldn't happen - lambdas should only be created by lambda() function + return Err(DscError::Parser(t!("parser.functions.unexpectedLambda").to_string())); } } } From a81882c924471229f9fb91a5266ca5113af17573 Mon Sep 17 00:00:00 2001 From: "G.Reijn" Date: Wed, 5 Nov 2025 04:12:22 +0100 Subject: [PATCH 06/16] Fix comments --- dsc/tests/{dsc_map_lambda.tests.ps1 => dsc_lambda.tests.ps1} | 0 lib/dsc-lib/src/parser/functions.rs | 1 - 2 files changed, 1 deletion(-) rename dsc/tests/{dsc_map_lambda.tests.ps1 => dsc_lambda.tests.ps1} (100%) diff --git a/dsc/tests/dsc_map_lambda.tests.ps1 b/dsc/tests/dsc_lambda.tests.ps1 similarity index 100% rename from dsc/tests/dsc_map_lambda.tests.ps1 rename to dsc/tests/dsc_lambda.tests.ps1 diff --git a/lib/dsc-lib/src/parser/functions.rs b/lib/dsc-lib/src/parser/functions.rs index 10ddb079e..8e49dc198 100644 --- a/lib/dsc-lib/src/parser/functions.rs +++ b/lib/dsc-lib/src/parser/functions.rs @@ -93,7 +93,6 @@ impl Function { resolved_args.push(value.clone()); }, FunctionArg::Lambda(_lambda) => { - // This shouldn't happen - lambdas should only be created by lambda() function return Err(DscError::Parser(t!("parser.functions.unexpectedLambda").to_string())); } } From b18098646c06446f1cd0384b4e4e0e9360ec411b Mon Sep 17 00:00:00 2001 From: "G.Reijn" Date: Wed, 5 Nov 2025 04:30:48 +0100 Subject: [PATCH 07/16] Re-add localization --- lib/dsc-lib/locales/en-us.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/dsc-lib/locales/en-us.toml b/lib/dsc-lib/locales/en-us.toml index 7ff4901fa..7b36e71ca 100644 --- a/lib/dsc-lib/locales/en-us.toml +++ b/lib/dsc-lib/locales/en-us.toml @@ -678,6 +678,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}" From 3176d5bd1e05e2708e0ed6ffdc1146f5e01dc2ef Mon Sep 17 00:00:00 2001 From: "G.Reijn" Date: Sat, 15 Nov 2025 07:55:59 +0100 Subject: [PATCH 08/16] Remove conflict --- lib/dsc-lib/src/parser/functions.rs | 6 ------ 1 file changed, 6 deletions(-) diff --git a/lib/dsc-lib/src/parser/functions.rs b/lib/dsc-lib/src/parser/functions.rs index 84687898e..4af4e965b 100644 --- a/lib/dsc-lib/src/parser/functions.rs +++ b/lib/dsc-lib/src/parser/functions.rs @@ -26,12 +26,6 @@ pub enum FunctionArg { Lambda(Lambda), } -#[derive(Clone)] -pub struct Lambda { - pub parameters: Vec, - pub body: Expression, -} - /// Represents a lambda expression for use in DSC function expressions. /// /// Lambda expressions are anonymous functions created using the `lambda()` function From a93f9e24c486b390ced800e9ffb0e613f42b65f4 Mon Sep 17 00:00:00 2001 From: "G.Reijn" Date: Sat, 15 Nov 2025 08:44:11 +0100 Subject: [PATCH 09/16] Fix test to look at output --- dsc/tests/dsc_lambda.tests.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dsc/tests/dsc_lambda.tests.ps1 b/dsc/tests/dsc_lambda.tests.ps1 index 562ba66f4..d8329e8a0 100644 --- a/dsc/tests/dsc_lambda.tests.ps1 +++ b/dsc/tests/dsc_lambda.tests.ps1 @@ -63,6 +63,6 @@ resources: '@ $out = $config_yaml | dsc config get -f - | ConvertFrom-Json $LASTEXITCODE | Should -Be 0 - $out.results[0].result.actualState.Count | Should -Be 0 + $out.results[0].result.actualState.output.Count | Should -Be 0 } } From 0869cf07d6e21976c1fa609ab1948295dee16547 Mon Sep 17 00:00:00 2001 From: "G.Reijn" Date: Tue, 2 Dec 2025 04:14:50 +0100 Subject: [PATCH 10/16] Add lambda expression support for DSC function expressions --- lib/dsc-lib/src/parser/functions.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/dsc-lib/src/parser/functions.rs b/lib/dsc-lib/src/parser/functions.rs index 165b030d1..6dfe356e4 100644 --- a/lib/dsc-lib/src/parser/functions.rs +++ b/lib/dsc-lib/src/parser/functions.rs @@ -61,7 +61,7 @@ pub enum FunctionArg { /// 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)] +#[derive(Clone, Debug)] pub struct Lambda { pub parameters: Vec, pub body: Expression, From fb353f40b589e8957bfee46436a4b991ef26306f Mon Sep 17 00:00:00 2001 From: "G.Reijn" Date: Fri, 12 Dec 2025 03:47:04 +0100 Subject: [PATCH 11/16] Add support for Lambda function arguments in the function dispatcher --- dsc/src/subcommand.rs | 1 + lib/dsc-lib/locales/en-us.toml | 1 + lib/dsc-lib/src/functions/map.rs | 2 +- lib/dsc-lib/src/functions/mod.rs | 8 +++++++- 4 files changed, 10 insertions(+), 2 deletions(-) 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/lib/dsc-lib/locales/en-us.toml b/lib/dsc-lib/locales/en-us.toml index 4240b9967..1c53c1df5 100644 --- a/lib/dsc-lib/locales/en-us.toml +++ b/lib/dsc-lib/locales/en-us.toml @@ -247,6 +247,7 @@ 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}" diff --git a/lib/dsc-lib/src/functions/map.rs b/lib/dsc-lib/src/functions/map.rs index cdeaa1617..67258cfb9 100644 --- a/lib/dsc-lib/src/functions/map.rs +++ b/lib/dsc-lib/src/functions/map.rs @@ -21,7 +21,7 @@ impl Function for Map { max_args: 2, accepted_arg_ordered_types: vec![ vec![FunctionArgKind::Array], - vec![FunctionArgKind::String], // Lambda ID as string + vec![FunctionArgKind::Lambda], ], remaining_arg_accepted_types: None, return_types: vec![FunctionArgKind::Array], diff --git a/lib/dsc-lib/src/functions/mod.rs b/lib/dsc-lib/src/functions/mod.rs index 82889d286..3cd6a8983 100644 --- a/lib/dsc-lib/src/functions/mod.rs +++ b/lib/dsc-lib/src/functions/mod.rs @@ -96,6 +96,7 @@ pub mod try_which; pub enum FunctionArgKind { Array, Boolean, + Lambda, Null, Number, Object, @@ -107,6 +108,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"), @@ -353,17 +355,21 @@ impl FunctionDispatcher { } 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(()) From af25cc29ae0a0a929a6d2fa78aac44b2bd0e32fb Mon Sep 17 00:00:00 2001 From: "G.Reijn" Date: Sat, 13 Dec 2025 04:27:45 +0100 Subject: [PATCH 12/16] Refactor argument handling --- lib/dsc-lib/src/functions/lambda_variables.rs | 8 +------- lib/dsc-lib/src/functions/map.rs | 15 +++------------ 2 files changed, 4 insertions(+), 19 deletions(-) diff --git a/lib/dsc-lib/src/functions/lambda_variables.rs b/lib/dsc-lib/src/functions/lambda_variables.rs index 6d13d3e7c..5a557278d 100644 --- a/lib/dsc-lib/src/functions/lambda_variables.rs +++ b/lib/dsc-lib/src/functions/lambda_variables.rs @@ -34,14 +34,8 @@ impl Function for LambdaVariables { fn invoke(&self, args: &[Value], context: &Context) -> Result { debug!("{}", t!("functions.lambdaVariables.invoked")); - - if args.len() != 1 { - return Err(DscError::Parser(t!("functions.invalidArgCount", name = "lambdaVariables", count = 1).to_string())); - } - let Some(var_name) = args[0].as_str() else { - return Err(DscError::Parser(t!("functions.lambdaVariables.paramNameMustBeString").to_string())); - }; + 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) { diff --git a/lib/dsc-lib/src/functions/map.rs b/lib/dsc-lib/src/functions/map.rs index 67258cfb9..6c6445fe8 100644 --- a/lib/dsc-lib/src/functions/map.rs +++ b/lib/dsc-lib/src/functions/map.rs @@ -30,18 +30,9 @@ impl Function for Map { fn invoke(&self, args: &[Value], context: &Context) -> Result { debug!("{}", t!("functions.map.invoked")); - - if args.len() != 2 { - return Err(DscError::Parser(t!("functions.invalidArgCount", name = "map", count = 2).to_string())); - } - let Some(array) = args[0].as_array() else { - return Err(DscError::Parser(t!("functions.map.firstArgMustBeArray").to_string())); - }; - - let Some(lambda_id) = args[1].as_str() else { - return Err(DscError::Parser(t!("functions.map.secondArgMustBeLambda").to_string())); - }; + let array = args[0].as_array().unwrap(); + let lambda_id = args[1].as_str().unwrap(); // Retrieve the lambda from context let lambdas = context.lambdas.borrow(); @@ -96,4 +87,4 @@ mod tests { let result = func.invoke(&[], &Context::new()); assert!(result.is_err()); } -} +} \ No newline at end of file From 7a1de83400973e7021fc6a8f4c1b36338a668280 Mon Sep 17 00:00:00 2001 From: "G.Reijn" Date: Sat, 13 Dec 2025 04:32:37 +0100 Subject: [PATCH 13/16] Remove test --- lib/dsc-lib/src/functions/map.rs | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/lib/dsc-lib/src/functions/map.rs b/lib/dsc-lib/src/functions/map.rs index 6c6445fe8..cbff1d850 100644 --- a/lib/dsc-lib/src/functions/map.rs +++ b/lib/dsc-lib/src/functions/map.rs @@ -76,15 +76,3 @@ impl Function for Map { Ok(Value::Array(result_array)) } } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn requires_two_args() { - let func = Map {}; - let result = func.invoke(&[], &Context::new()); - assert!(result.is_err()); - } -} \ No newline at end of file From 9057146e71165631d643cedd64d073d65fbc25c7 Mon Sep 17 00:00:00 2001 From: "G.Reijn" Date: Sat, 13 Dec 2025 05:40:14 +0100 Subject: [PATCH 14/16] Remove unused localization strings --- lib/dsc-lib/locales/en-us.toml | 3 --- 1 file changed, 3 deletions(-) diff --git a/lib/dsc-lib/locales/en-us.toml b/lib/dsc-lib/locales/en-us.toml index da97e3ce4..98eb24090 100644 --- a/lib/dsc-lib/locales/en-us.toml +++ b/lib/dsc-lib/locales/en-us.toml @@ -421,7 +421,6 @@ bodyMustBeExpression = "lambda() body must be an expression" [functions.lambdaVariables] description = "Retrieves the value of a lambda parameter" invoked = "lambdaVariables function" -paramNameMustBeString = "lambdaVariables() parameter name must be a string" notFound = "Lambda parameter '%{name}' not found in current context" [functions.lastIndexOf] @@ -432,8 +431,6 @@ invalidArrayArg = "First argument must be an array" [functions.map] description = "Transforms an array by applying a lambda function to each element" invoked = "map function" -firstArgMustBeArray = "map() first argument must be an array" -secondArgMustBeLambda = "map() second argument must be a lambda function" lambdaNotFound = "Lambda function with ID '%{id}' not found" lambdaMustHave1Or2Params = "map() lambda must have 1 or 2 parameters (element and optional index)" From 7c3c6b664d94bb240d751017ed0f39ab99e7f7d4 Mon Sep 17 00:00:00 2001 From: "G.Reijn" Date: Tue, 16 Dec 2025 00:44:47 +0100 Subject: [PATCH 15/16] Add Lambda process mode and update function invocation logic --- lib/dsc-lib/src/configure/context.rs | 1 + lib/dsc-lib/src/functions/mod.rs | 17 +++++++++++++---- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/lib/dsc-lib/src/configure/context.rs b/lib/dsc-lib/src/configure/context.rs index fd6e2ed15..cb3bd2eb8 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, diff --git a/lib/dsc-lib/src/functions/mod.rs b/lib/dsc-lib/src/functions/mod.rs index 70ce5bb0b..c5cd05cc0 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 rust_i18n::t; use schemars::JsonSchema; @@ -290,13 +290,22 @@ 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) + } } /// Special handler for lambda() function calls. From c3d6d7710aa6ab5c15ef9f6f5b84454094e757fd Mon Sep 17 00:00:00 2001 From: "G.Reijn" Date: Wed, 17 Dec 2025 03:43:07 +0100 Subject: [PATCH 16/16] Implement filter function with lambda support and update related metadata --- dsc/tests/dsc_lambda.tests.ps1 | 66 +++++++++++++ lib/dsc-lib/locales/en-us.toml | 12 ++- lib/dsc-lib/src/configure/context.rs | 2 + lib/dsc-lib/src/functions/filter.rs | 54 +++++++++++ lib/dsc-lib/src/functions/lambda.rs | 48 ++++++--- lib/dsc-lib/src/functions/lambda_helpers.rs | 102 ++++++++++++++++++++ lib/dsc-lib/src/functions/map.rs | 47 ++------- lib/dsc-lib/src/functions/mod.rs | 60 +----------- lib/dsc-lib/src/parser/functions.rs | 9 +- 9 files changed, 284 insertions(+), 116 deletions(-) create mode 100644 lib/dsc-lib/src/functions/filter.rs create mode 100644 lib/dsc-lib/src/functions/lambda_helpers.rs diff --git a/dsc/tests/dsc_lambda.tests.ps1 b/dsc/tests/dsc_lambda.tests.ps1 index d8329e8a0..2bb781708 100644 --- a/dsc/tests/dsc_lambda.tests.ps1 +++ b/dsc/tests/dsc_lambda.tests.ps1 @@ -66,3 +66,69 @@ resources: $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 98eb24090..b970c2f06 100644 --- a/lib/dsc-lib/locales/en-us.toml +++ b/lib/dsc-lib/locales/en-us.toml @@ -251,6 +251,8 @@ noLambdaArgs = "Function '%{name}' does not accept lambda arguments, accepted ty 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" @@ -345,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" @@ -412,8 +419,7 @@ invalidJson = "Invalid JSON string" [functions.lambda] description = "Creates a lambda function with parameters and a body expression" -cannotInvokeDirectly = "lambda() should not be invoked directly" -requiresArgs = "lambda() requires at least 2 arguments" +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" @@ -431,8 +437,6 @@ invalidArrayArg = "First argument must be an array" [functions.map] description = "Transforms an array by applying a lambda function to each element" invoked = "map function" -lambdaNotFound = "Lambda function with ID '%{id}' not found" -lambdaMustHave1Or2Params = "map() lambda must have 1 or 2 parameters (element and optional index)" [functions.length] description = "Returns the length of a string, array, or object" diff --git a/lib/dsc-lib/src/configure/context.rs b/lib/dsc-lib/src/configure/context.rs index cb3bd2eb8..bc36c8d9f 100644 --- a/lib/dsc-lib/src/configure/context.rs +++ b/lib/dsc-lib/src/configure/context.rs @@ -26,6 +26,7 @@ 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, @@ -52,6 +53,7 @@ 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(), 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 index 7d7ae7cc8..ec2f85b53 100644 --- a/lib/dsc-lib/src/functions/lambda.rs +++ b/lib/dsc-lib/src/functions/lambda.rs @@ -4,16 +4,12 @@ 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; - -/// The lambda() function is special - it's not meant to be invoked directly -/// through the normal function dispatcher path. Instead, it's caught in the -/// Function::invoke method and handled specially via invoke_lambda(). -/// -/// This struct exists for metadata purposes and to signal errors if someone -/// tries to invoke lambda() as a regular function (which shouldn't happen). #[derive(Debug, Default)] pub struct LambdaFn {} @@ -23,16 +19,42 @@ impl Function for LambdaFn { name: "lambda".to_string(), description: t!("functions.lambda.description").to_string(), category: vec![FunctionCategory::Lambda], - min_args: 2, - max_args: 10, // Up to 9 parameters + 1 body + 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::Object], // Lambda is represented as a special object + return_types: vec![FunctionArgKind::Lambda], } } - fn invoke(&self, _args: &[Value], _context: &Context) -> Result { - // This should never be called - lambda() is handled specially in Function::invoke - Err(DscError::Parser(t!("functions.lambda.cannotInvokeDirectly").to_string())) + 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/map.rs b/lib/dsc-lib/src/functions/map.rs index cbff1d850..1e57ebd4c 100644 --- a/lib/dsc-lib/src/functions/map.rs +++ b/lib/dsc-lib/src/functions/map.rs @@ -3,7 +3,8 @@ use crate::DscError; use crate::configure::context::Context; -use crate::functions::{FunctionArgKind, Function, FunctionCategory, FunctionMetadata, FunctionDispatcher}; +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; @@ -33,45 +34,11 @@ impl Function for Map { let array = args[0].as_array().unwrap(); let lambda_id = args[1].as_str().unwrap(); - - // Retrieve the lambda from context - let lambdas = context.lambdas.borrow(); - let Some(lambda) = lambdas.get(lambda_id) else { - return Err(DscError::Parser(t!("functions.map.lambdaNotFound", id = lambda_id).to_string())); - }; - - // Validate parameter count (1 or 2 parameters) - if lambda.parameters.is_empty() || lambda.parameters.len() > 2 { - return Err(DscError::Parser(t!("functions.map.lambdaMustHave1Or2Params").to_string())); - } - - // Create function dispatcher for evaluating lambda body - let dispatcher = FunctionDispatcher::new(); - let mut result_array = Vec::new(); - - // Iterate through array and evaluate lambda for each element - for (index, element) in array.iter().enumerate() { - // Create a new context with lambda variables bound - let mut lambda_context = context.clone(); - - // Bind first parameter to array element - lambda_context.lambda_variables.insert( - lambda.parameters[0].clone(), - element.clone() - ); - - // Bind second parameter to index if provided - if lambda.parameters.len() == 2 { - lambda_context.lambda_variables.insert( - lambda.parameters[1].clone(), - Value::Number(serde_json::Number::from(index)) - ); - } - - // Evaluate lambda body with bound variables - let result = lambda.body.invoke(&dispatcher, &lambda_context)?; - result_array.push(result); - } + 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 4e1b1605d..5881fcb9e 100644 --- a/lib/dsc-lib/src/functions/mod.rs +++ b/lib/dsc-lib/src/functions/mod.rs @@ -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; @@ -50,6 +51,7 @@ 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; @@ -193,6 +195,7 @@ 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{}), @@ -310,63 +313,6 @@ impl FunctionDispatcher { } } - /// Special handler for lambda() function calls. - /// Creates a Lambda object and stores it in Context with a unique ID. - /// - /// # Arguments - /// - /// * `args` - Raw FunctionArg list (unevaluated) - /// * `context` - Context to store the lambda in - /// - /// # Errors - /// - /// This function will return an error if the lambda syntax is invalid. - pub fn invoke_lambda(&self, args: &Option>, context: &Context) -> Result { - use crate::parser::functions::{FunctionArg, Lambda}; - use uuid::Uuid; - - let Some(args) = args else { - return Err(DscError::Parser(t!("functions.lambda.requiresArgs").to_string())); - }; - - if args.len() < 2 { - return Err(DscError::Parser(t!("functions.lambda.requiresParamAndBody").to_string())); - } - - // All arguments except the last must be string values (parameter names) - let mut parameters = Vec::new(); - for arg in args.iter().take(args.len() - 1) { - match arg { - FunctionArg::Value(Value::String(s)) => { - parameters.push(s.clone()); - }, - _ => { - return Err(DscError::Parser(t!("functions.lambda.paramsMustBeStrings").to_string())); - } - } - } - - // Last argument is the body expression - let body_expr = match &args[args.len() - 1] { - 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: body_expr, - }; - - let lambda_id = format!("__lambda_{}", Uuid::new_v4()); - context.lambdas.borrow_mut().insert(lambda_id.clone(), lambda); - - // Return the ID as a string value - Ok(Value::String(lambda_id)) - } - 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_")); diff --git a/lib/dsc-lib/src/parser/functions.rs b/lib/dsc-lib/src/parser/functions.rs index 6dfe356e4..6a507a582 100644 --- a/lib/dsc-lib/src/parser/functions.rs +++ b/lib/dsc-lib/src/parser/functions.rs @@ -108,9 +108,14 @@ 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 - don't evaluate it, just pass args through + // Special handling for lambda() function - pass raw args through context if self.name.to_lowercase() == "lambda" { - return function_dispatcher.invoke_lambda(&self.args, context); + // 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