Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
dd9d3d3
Initial setup
Gijsreyn Nov 5, 2025
fed30d4
Fix comments
Gijsreyn Nov 5, 2025
9b05728
Re-add localization
Gijsreyn Nov 5, 2025
97cf256
Merge branch 'main' of https://github.com/Gijsreyn/operation-methods …
Gijsreyn Nov 14, 2025
acc8181
Remove dead code and add instructions
Gijsreyn Nov 14, 2025
3e019fe
Initial setup
Gijsreyn Nov 5, 2025
a81882c
Fix comments
Gijsreyn Nov 5, 2025
b180986
Re-add localization
Gijsreyn Nov 5, 2025
fc04a3b
Merge branch 'gh-57/main/add-map-lambda-function' of https://github.c…
Gijsreyn Nov 14, 2025
00f9701
Merge branch 'main' into gh-57/main/add-map-lambda-function
Gijsreyn Nov 15, 2025
3176d5b
Remove conflict
Gijsreyn Nov 15, 2025
a93f9e2
Fix test to look at output
Gijsreyn Nov 15, 2025
3139835
Merge branch 'main' into gh-57/main/add-map-lambda-function
Gijsreyn Dec 2, 2025
0869cf0
Add lambda expression support for DSC function expressions
Gijsreyn Dec 2, 2025
4d831b1
Merge branch 'main' into gh-57/main/add-map-lambda-function
Gijsreyn Dec 12, 2025
fb353f4
Add support for Lambda function arguments in the function dispatcher
Gijsreyn Dec 12, 2025
e9df293
Merge branch 'main' into gh-57/main/add-map-lambda-function
Gijsreyn Dec 13, 2025
af25cc2
Refactor argument handling
Gijsreyn Dec 13, 2025
7a1de83
Remove test
Gijsreyn Dec 13, 2025
9057146
Remove unused localization strings
Gijsreyn Dec 13, 2025
7c3c6b6
Add Lambda process mode and update function invocation logic
Gijsreyn Dec 15, 2025
f5a902d
Merge branch 'main' into gh-57/main/add-map-lambda-function
Gijsreyn Dec 16, 2025
c3d6d77
Implement filter function with lambda support and update related meta…
Gijsreyn Dec 17, 2025
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
}
}
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>,
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);
}
Copy link
Member

Choose a reason for hiding this comment

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

I haven't thought this through, but instead of having each lambda function implement processing lambdas, it would seem like this could be in lambda() itself so the other lambda functions in https://learn.microsoft.com/en-us/azure/azure-resource-manager/templates/template-functions-lambda would just handle how to apply the lambda result.

Perhaps it would help to also implement filter() in this PR to see how it would work?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I added the filter() function to compare, where I had introduced a new file (lamba_helpers.rs). Mind checking again?


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