diff --git a/dsc/tests/dsc_lambda.tests.ps1 b/dsc/tests/dsc_lambda.tests.ps1 new file mode 100644 index 000000000..9199b90da --- /dev/null +++ b/dsc/tests/dsc_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..82d25877a 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" @@ -630,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}" 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..8e49dc198 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,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())); } } }