Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 | Should -Be $null
Copy link

Copilot AI Nov 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test expects $null for an empty array output, but this may not be the correct assertion. An empty array @() is different from $null in PowerShell. Verify that DSC actually returns $null for empty arrays, or update the assertion to check for an empty array: $out.results[0].result.actualState.output.Count | Should -Be 0.

Suggested change
$out.results[0].result.actualState.output | Should -Be $null
$out.results[0].result.actualState.output.Count | Should -Be 0

Copilot uses AI. Check for mistakes.
}
}
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 @@ -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}"
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>,
Comment on lines 27 to +28
Copy link

Copilot AI Nov 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new fields lambda_variables and lambdas lack documentation comments explaining their purpose and usage. Add doc comments to clarify:

  • lambda_variables: Storage for lambda parameter bindings during lambda body evaluation
  • lambdas: Registry of lambda functions by their unique ID
Suggested change
pub extensions: Vec<DscExtension>,
pub lambda_variables: HashMap<String, Value>,
pub extensions: Vec<DscExtension>,
/// Storage for lambda parameter bindings during lambda body evaluation
pub lambda_variables: HashMap<String, Value>,
/// Registry of lambda functions by their unique ID

Copilot uses AI. Check for mistakes.
pub lambdas: std::cell::RefCell<HashMap<String, crate::parser::functions::Lambda>>,
Copy link

Copilot AI Nov 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using RefCell<HashMap> for lambdas in a Clone-able Context has implications for how lambda storage works across context clones. When context.clone() is called (e.g., in map.rs line 64), the RefCell is cloned which creates a new RefCell with a new HashMap that is a copy of the original. This means:

  1. Lambdas created in the cloned context won't be visible in the original context
  2. The original lambdas are duplicated, which may not be the intended behavior

If lambdas should be shared across context clones, use Rc<RefCell<HashMap<String, Lambda>>> instead. If lambdas should be scoped to each context clone (current behavior), document this design decision with a comment.

Copilot uses AI. Check for mistakes.
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();
Comment on lines +47 to +64
Copy link

Copilot AI Nov 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The lambda is borrowed from context.lambdas and then cloned on line 64, but the borrow from line 47 is not explicitly dropped before the clone. While Rust's borrow checker allows this because the borrow is only used to get the lambda reference and doesn't extend past line 55, it would be clearer to explicitly drop the borrow before cloning the context. Consider restructuring as:

let lambda = {
    let lambdas = context.lambdas.borrow();
    lambdas.get(lambda_id)
        .ok_or_else(|| DscError::Parser(t!("functions.map.lambdaNotFound", id = lambda_id).to_string()))?
        .clone()
};
// ... validate parameter count ...
// Then use lambda directly without holding the borrow

This makes it clearer that we're cloning the lambda value and releasing the borrow before cloning the context.

Copilot uses AI. Check for mistakes.

// 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