Skip to content
68 changes: 68 additions & 0 deletions dsc/tests/dsc_lambda.tests.ps1
Original file line number Diff line number Diff line change
@@ -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.Count | Should -Be 0
}
}
23 changes: 23 additions & 0 deletions lib/dsc-lib/locales/en-us.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -656,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}"
Expand Down
4 changes: 4 additions & 0 deletions lib/dsc-lib/src/configure/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ pub struct Context {
pub dsc_version: Option<String>,
pub execution_type: ExecutionKind,
pub extensions: Vec<DscExtension>,
pub lambda_variables: HashMap<String, Value>,
pub lambdas: std::cell::RefCell<HashMap<String, crate::parser::functions::Lambda>>,
pub outputs: Map<String, Value>,
pub parameters: HashMap<String, (Value, DataType)>,
pub process_expressions: bool,
Expand All @@ -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,
Expand Down
38 changes: 38 additions & 0 deletions lib/dsc-lib/src/functions/lambda.rs
Original file line number Diff line number Diff line change
@@ -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<Value, DscError> {
// This should never be called - lambda() is handled specially in Function::invoke
Err(DscError::Parser(t!("functions.lambda.cannotInvokeDirectly").to_string()))
}
}
77 changes: 77 additions & 0 deletions lib/dsc-lib/src/functions/lambda_variables.rs
Original file line number Diff line number Diff line change
@@ -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<Value, DscError> {
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());
}
}
99 changes: 99 additions & 0 deletions lib/dsc-lib/src/functions/map.rs
Original file line number Diff line number Diff line change
@@ -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<Value, DscError> {
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());
}
}
Loading