From c89f5dd1c313adf3bac6cb43fab5f1d001db1f41 Mon Sep 17 00:00:00 2001 From: "G.Reijn" Date: Sat, 25 Oct 2025 16:16:27 +0200 Subject: [PATCH 1/7] Add `last()` function --- .../schemas/config/functions/last.md | 231 ++++++++++++++++++ dsc/tests/dsc_functions.tests.ps1 | 22 ++ lib/dsc-lib/locales/en-us.toml | 7 + lib/dsc-lib/src/functions/last.rs | 130 ++++++++++ lib/dsc-lib/src/functions/mod.rs | 2 + 5 files changed, 392 insertions(+) create mode 100644 docs/reference/schemas/config/functions/last.md create mode 100644 lib/dsc-lib/src/functions/last.rs diff --git a/docs/reference/schemas/config/functions/last.md b/docs/reference/schemas/config/functions/last.md new file mode 100644 index 000000000..06f24c194 --- /dev/null +++ b/docs/reference/schemas/config/functions/last.md @@ -0,0 +1,231 @@ +--- +description: Reference for the 'last' DSC configuration document function +ms.date: 01/25/2025 +ms.topic: reference +title: last +--- + +## Synopsis + +Returns the last element of an array, or the last character of a string. + +## Syntax + +```Syntax +last(arg) +``` + +## Description + +The `last()` function returns the final element from an array or the final +character from a string. This is useful when you need to access the most recent +item in a sequence, the final stage in a deployment pipeline, or the last +character in a configuration value. + +For arrays, it returns the element at index `length - 1`. For strings, it +returns the last character as a string. If the input is empty, an error is +returned. + +## Examples + +### Example 1 - Extract the final deployment stage (array of strings) + +Use `last()` to retrieve the final stage in a multi-stage deployment pipeline. +This helps you identify which environment or phase should receive special +handling, such as extended health checks or manual approval gates. This example +uses [`createArray()`][01] to build the deployment stages. + +```yaml +# last.example.1.dsc.config.yaml +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +resources: +- name: Deployment Pipeline + type: Microsoft.DSC.Debug/Echo + properties: + output: + finalStage: "[last(createArray('dev', 'test', 'staging', 'production'))]" + requiresApproval: true +``` + +```bash +dsc config get --file last.example.1.dsc.config.yaml +``` + +```yaml +results: +- name: Deployment Pipeline + type: Microsoft.DSC.Debug/Echo + result: + actualState: + output: + finalStage: production + requiresApproval: true +messages: [] +hadErrors: false +``` + +This identifies `production` as the final stage, allowing you to apply +production-specific policies or validations. + +### Example 2 - Get the last character of a configuration string + +Use `last()` to extract the final character from a string value. This is useful +for parsing identifiers, checking suffixes, or validating format conventions +like version numbers or region codes. + +```yaml +# last.example.2.dsc.config.yaml +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +resources: +- name: Region Identifier + type: Microsoft.DSC.Debug/Echo + properties: + output: + regionCode: us-west-2 + zoneSuffix: "[last('us-west-2')]" + description: "Zone suffix extracted from region code" +``` + +```bash +dsc config get --file last.example.2.dsc.config.yaml +``` + +```yaml +results: +- name: Region Identifier + type: Microsoft.DSC.Debug/Echo + result: + actualState: + output: + regionCode: us-west-2 + zoneSuffix: '2' + description: Zone suffix extracted from region code +messages: [] +hadErrors: false +``` + +The function returns `'2'` as a single-character string, representing the zone +suffix in the region identifier. + +### Example 3 - Identify the most recent backup (array of numbers) + +Use `last()` with numerical arrays to find the most recent timestamp or version +number. This example shows how to select the latest backup from a sorted list +of timestamps. This example uses [`createArray()`][01] to build the backup +timestamps. + +```yaml +# last.example.3.dsc.config.yaml +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +resources: +- name: Backup Selection + type: Microsoft.DSC.Debug/Echo + properties: + output: + availableBackups: "[createArray(1704067200, 1704153600, 1704240000, 1704326400)]" + latestBackup: "[last(createArray(1704067200, 1704153600, 1704240000, 1704326400))]" + description: "Most recent backup timestamp (Unix epoch)" +``` + +```bash +dsc config get --file last.example.3.dsc.config.yaml +``` + +```yaml +results: +- name: Backup Selection + type: Microsoft.DSC.Debug/Echo + result: + actualState: + output: + availableBackups: + - 1704067200 + - 1704153600 + - 1704240000 + - 1704326400 + latestBackup: 1704326400 + description: Most recent backup timestamp (Unix epoch) +messages: [] +hadErrors: false +``` + +The function returns `1704326400`, which represents the most recent backup in +the chronologically sorted array. + +### Example 4 - Combine with other functions for complex logic + +Use `last()` together with [`split()`][02] to extract file extensions or path +components. This example demonstrates parsing a filename to get its extension. + +```yaml +# last.example.4.dsc.config.yaml +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +resources: +- name: File Extension Parser + type: Microsoft.DSC.Debug/Echo + properties: + output: + filename: config.production.yaml + extension: "[last(split('config.production.yaml', '.'))]" +``` + +```bash +dsc config get --file last.example.4.dsc.config.yaml +``` + +```yaml +results: +- name: File Extension Parser + type: Microsoft.DSC.Debug/Echo + result: + actualState: + output: + filename: config.production.yaml + extension: yaml +messages: [] +hadErrors: false +``` + +By combining `split()` and `last()`, you can extract the `yaml` extension from +the full filename. + +## Parameters + +### arg + +The array or string to get the last element or character from. Required. + +```yaml +Type: array | string +Required: true +Position: 1 +``` + +## Output + +Returns the last element of the array (preserving its original type) or the +last character as a string. For arrays, the return type matches the element +type. For strings, returns a single-character string. + +```yaml +Type: any | string +``` + +## Errors + +The function returns an error in the following cases: + +- **Empty array**: The input array has no elements +- **Empty string**: The input string has no characters +- **Invalid type**: The argument is not an array or string + +## Related functions + +- [`first()`][00] - Returns the first element of an array or character of a string +- [`split()`][02] - Splits a string into an array +- [`createArray()`][01] - Creates an array from provided values + + +[00]: ./first.md +[01]: ./createArray.md +[02]: ./split.md diff --git a/dsc/tests/dsc_functions.tests.ps1 b/dsc/tests/dsc_functions.tests.ps1 index 4123a6865..f59b7e1e0 100644 --- a/dsc/tests/dsc_functions.tests.ps1 +++ b/dsc/tests/dsc_functions.tests.ps1 @@ -504,6 +504,28 @@ Describe 'tests for function expressions' { ($out.results[0].result.actualState.output | Out-String) | Should -BeExactly ($expected | Out-String) } + It 'last function works for: ' -TestCases @( + @{ expression = "[last(createArray('hello', 'world'))]"; expected = 'world' } + @{ expression = "[last(createArray(1, 2, 3))]"; expected = 3 } + @{ expression = "[last('hello')]"; expected = 'o' } + @{ expression = "[last('a')]"; expected = 'a' } + @{ expression = "[last(array('mixed'))]"; expected = 'mixed' } + ) { + param($expression, $expected) + + $config_yaml = @" + `$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json + resources: + - name: Echo + type: Microsoft.DSC.Debug/Echo + properties: + output: "$expression" +"@ + $out = dsc -l trace config get -i $config_yaml 2>$TestDrive/error.log | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 -Because (Get-Content $TestDrive/error.log -Raw) + ($out.results[0].result.actualState.output | Out-String) | Should -BeExactly ($expected | Out-String) + } + It 'indexOf function works for: ' -TestCases @( @{ expression = "[indexOf(createArray('apple', 'banana', 'cherry'), 'banana')]"; expected = 1 } @{ expression = "[indexOf(createArray('apple', 'banana', 'cherry'), 'cherry')]"; expected = 2 } diff --git a/lib/dsc-lib/locales/en-us.toml b/lib/dsc-lib/locales/en-us.toml index ad410767c..f292e340b 100644 --- a/lib/dsc-lib/locales/en-us.toml +++ b/lib/dsc-lib/locales/en-us.toml @@ -343,6 +343,13 @@ emptyArray = "Cannot get first element of empty array" emptyString = "Cannot get first character of empty string" invalidArgType = "Invalid argument type, argument must be an array or string" +[functions.last] +description = "Returns the last element of an array or last character of a string" +invoked = "last function" +emptyArray = "Cannot get last element of empty array" +emptyString = "Cannot get last character of empty string" +invalidArgType = "Invalid argument type, argument must be an array or string" + [functions.greater] description = "Evaluates if the first value is greater than the second value" invoked = "greater function" diff --git a/lib/dsc-lib/src/functions/last.rs b/lib/dsc-lib/src/functions/last.rs new file mode 100644 index 000000000..fa855a830 --- /dev/null +++ b/lib/dsc-lib/src/functions/last.rs @@ -0,0 +1,130 @@ +// 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 Last {} + +impl Function for Last { + fn get_metadata(&self) -> FunctionMetadata { + FunctionMetadata { + name: "last".to_string(), + description: t!("functions.last.description").to_string(), + category: vec![FunctionCategory::Array, FunctionCategory::String], + min_args: 1, + max_args: 1, + accepted_arg_ordered_types: vec![vec![FunctionArgKind::Array, FunctionArgKind::String]], + remaining_arg_accepted_types: None, + return_types: vec![FunctionArgKind::String, FunctionArgKind::Number, FunctionArgKind::Array, FunctionArgKind::Object], + } + } + + fn invoke(&self, args: &[Value], _context: &Context) -> Result { + debug!("{}", t!("functions.last.invoked")); + + if let Some(array) = args[0].as_array() { + if array.is_empty() { + return Err(DscError::Parser(t!("functions.last.emptyArray").to_string())); + } + return Ok(array[array.len() - 1].clone()); + } + + if let Some(string) = args[0].as_str() { + if string.is_empty() { + return Err(DscError::Parser(t!("functions.last.emptyString").to_string())); + } + return Ok(Value::String(string.chars().last().unwrap().to_string())); + } + + Err(DscError::Parser(t!("functions.last.invalidArgType").to_string())) + } +} + +#[cfg(test)] +mod tests { + use crate::configure::context::Context; + use crate::parser::Statement; + + #[test] + fn array_of_strings() { + let mut parser = Statement::new().unwrap(); + let result = parser.parse_and_execute("[last(createArray('hello', 'world'))]", &Context::new()).unwrap(); + assert_eq!(result.as_str(), Some("world")); + } + + #[test] + fn array_of_numbers() { + let mut parser = Statement::new().unwrap(); + let result = parser.parse_and_execute("[last(createArray(1, 2, 3))]", &Context::new()).unwrap(); + assert_eq!(result.to_string(), "3"); + } + + #[test] + fn array_of_single_element() { + let mut parser = Statement::new().unwrap(); + let result = parser.parse_and_execute("[last(array('hello'))]", &Context::new()).unwrap(); + assert_eq!(result.as_str(), Some("hello")); + } + + #[test] + fn string_input() { + let mut parser = Statement::new().unwrap(); + let result = parser.parse_and_execute("[last('hello')]", &Context::new()).unwrap(); + assert_eq!(result.as_str(), Some("o")); + } + + #[test] + fn single_character_string() { + let mut parser = Statement::new().unwrap(); + let result = parser.parse_and_execute("[last('a')]", &Context::new()).unwrap(); + assert_eq!(result.as_str(), Some("a")); + } + + #[test] + fn empty_array() { + let mut parser = Statement::new().unwrap(); + let result = parser.parse_and_execute("[last(createArray())]", &Context::new()); + assert!(result.is_err()); + } + + #[test] + fn empty_string() { + let mut parser = Statement::new().unwrap(); + let result = parser.parse_and_execute("[last('')]", &Context::new()); + assert!(result.is_err()); + } + + #[test] + fn invalid_type_object() { + let mut parser = Statement::new().unwrap(); + let result = parser.parse_and_execute("[last(createObject('key', 'value'))]", &Context::new()); + assert!(result.is_err()); + } + + #[test] + fn invalid_type_number() { + let mut parser = Statement::new().unwrap(); + let result = parser.parse_and_execute("[last(42)]", &Context::new()); + assert!(result.is_err()); + } + + #[test] + fn array_of_mixed_types() { + let mut parser = Statement::new().unwrap(); + let result = parser.parse_and_execute("[last(createArray('text', 42, true))]", &Context::new()).unwrap(); + assert_eq!(result.as_bool(), Some(true)); + } + + #[test] + fn unicode_string() { + let mut parser = Statement::new().unwrap(); + let result = parser.parse_and_execute("[last('Hello๐ŸŒ')]", &Context::new()).unwrap(); + assert_eq!(result.as_str(), Some("๐ŸŒ")); + } +} diff --git a/lib/dsc-lib/src/functions/mod.rs b/lib/dsc-lib/src/functions/mod.rs index 01133987e..81ee74455 100644 --- a/lib/dsc-lib/src/functions/mod.rs +++ b/lib/dsc-lib/src/functions/mod.rs @@ -35,6 +35,7 @@ pub mod greater_or_equals; pub mod r#if; pub mod r#false; pub mod first; +pub mod last; pub mod length; pub mod less; pub mod less_or_equals; @@ -163,6 +164,7 @@ impl FunctionDispatcher { Box::new(r#if::If{}), Box::new(r#false::False{}), Box::new(first::First{}), + Box::new(last::Last{}), Box::new(length::Length{}), Box::new(less::Less{}), Box::new(less_or_equals::LessOrEquals{}), From b00a4dd86de4f9a5eddecf232d7074cb9ea360f7 Mon Sep 17 00:00:00 2001 From: "G.Reijn" Date: Sat, 25 Oct 2025 16:34:09 +0200 Subject: [PATCH 2/7] Fix test --- lib/dsc-lib/src/functions/last.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/dsc-lib/src/functions/last.rs b/lib/dsc-lib/src/functions/last.rs index fa855a830..a4fb67ff0 100644 --- a/lib/dsc-lib/src/functions/last.rs +++ b/lib/dsc-lib/src/functions/last.rs @@ -117,8 +117,8 @@ mod tests { #[test] fn array_of_mixed_types() { let mut parser = Statement::new().unwrap(); - let result = parser.parse_and_execute("[last(createArray('text', 42, true))]", &Context::new()).unwrap(); - assert_eq!(result.as_bool(), Some(true)); + let result = parser.parse_and_execute("[last(createArray('text', 42, 'last'))]", &Context::new()).unwrap(); + assert_eq!(result.as_str(), Some("last")); } #[test] From e40ea223464649e5b2cbc409eb826797cdde297a Mon Sep 17 00:00:00 2001 From: "G.Reijn" Date: Sat, 25 Oct 2025 16:59:34 +0200 Subject: [PATCH 3/7] Not allowed integers --- lib/dsc-lib/src/functions/last.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/dsc-lib/src/functions/last.rs b/lib/dsc-lib/src/functions/last.rs index a4fb67ff0..a5dbfcaed 100644 --- a/lib/dsc-lib/src/functions/last.rs +++ b/lib/dsc-lib/src/functions/last.rs @@ -115,9 +115,9 @@ mod tests { } #[test] - fn array_of_mixed_types() { + fn array_of_multiple_strings() { let mut parser = Statement::new().unwrap(); - let result = parser.parse_and_execute("[last(createArray('text', 42, 'last'))]", &Context::new()).unwrap(); + let result = parser.parse_and_execute("[last(createArray('text', 'middle', 'last'))]", &Context::new()).unwrap(); assert_eq!(result.as_str(), Some("last")); } From 0968d54a6a4f8eca54774e510f79fb60caecd08b Mon Sep 17 00:00:00 2001 From: "G.Reijn" Date: Sat, 25 Oct 2025 16:16:27 +0200 Subject: [PATCH 4/7] Add `last()` function --- .../schemas/config/functions/last.md | 231 ++++++++++++++++++ dsc/tests/dsc_functions.tests.ps1 | 22 ++ lib/dsc-lib/locales/en-us.toml | 7 + lib/dsc-lib/src/functions/last.rs | 130 ++++++++++ lib/dsc-lib/src/functions/mod.rs | 2 + 5 files changed, 392 insertions(+) create mode 100644 docs/reference/schemas/config/functions/last.md create mode 100644 lib/dsc-lib/src/functions/last.rs diff --git a/docs/reference/schemas/config/functions/last.md b/docs/reference/schemas/config/functions/last.md new file mode 100644 index 000000000..06f24c194 --- /dev/null +++ b/docs/reference/schemas/config/functions/last.md @@ -0,0 +1,231 @@ +--- +description: Reference for the 'last' DSC configuration document function +ms.date: 01/25/2025 +ms.topic: reference +title: last +--- + +## Synopsis + +Returns the last element of an array, or the last character of a string. + +## Syntax + +```Syntax +last(arg) +``` + +## Description + +The `last()` function returns the final element from an array or the final +character from a string. This is useful when you need to access the most recent +item in a sequence, the final stage in a deployment pipeline, or the last +character in a configuration value. + +For arrays, it returns the element at index `length - 1`. For strings, it +returns the last character as a string. If the input is empty, an error is +returned. + +## Examples + +### Example 1 - Extract the final deployment stage (array of strings) + +Use `last()` to retrieve the final stage in a multi-stage deployment pipeline. +This helps you identify which environment or phase should receive special +handling, such as extended health checks or manual approval gates. This example +uses [`createArray()`][01] to build the deployment stages. + +```yaml +# last.example.1.dsc.config.yaml +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +resources: +- name: Deployment Pipeline + type: Microsoft.DSC.Debug/Echo + properties: + output: + finalStage: "[last(createArray('dev', 'test', 'staging', 'production'))]" + requiresApproval: true +``` + +```bash +dsc config get --file last.example.1.dsc.config.yaml +``` + +```yaml +results: +- name: Deployment Pipeline + type: Microsoft.DSC.Debug/Echo + result: + actualState: + output: + finalStage: production + requiresApproval: true +messages: [] +hadErrors: false +``` + +This identifies `production` as the final stage, allowing you to apply +production-specific policies or validations. + +### Example 2 - Get the last character of a configuration string + +Use `last()` to extract the final character from a string value. This is useful +for parsing identifiers, checking suffixes, or validating format conventions +like version numbers or region codes. + +```yaml +# last.example.2.dsc.config.yaml +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +resources: +- name: Region Identifier + type: Microsoft.DSC.Debug/Echo + properties: + output: + regionCode: us-west-2 + zoneSuffix: "[last('us-west-2')]" + description: "Zone suffix extracted from region code" +``` + +```bash +dsc config get --file last.example.2.dsc.config.yaml +``` + +```yaml +results: +- name: Region Identifier + type: Microsoft.DSC.Debug/Echo + result: + actualState: + output: + regionCode: us-west-2 + zoneSuffix: '2' + description: Zone suffix extracted from region code +messages: [] +hadErrors: false +``` + +The function returns `'2'` as a single-character string, representing the zone +suffix in the region identifier. + +### Example 3 - Identify the most recent backup (array of numbers) + +Use `last()` with numerical arrays to find the most recent timestamp or version +number. This example shows how to select the latest backup from a sorted list +of timestamps. This example uses [`createArray()`][01] to build the backup +timestamps. + +```yaml +# last.example.3.dsc.config.yaml +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +resources: +- name: Backup Selection + type: Microsoft.DSC.Debug/Echo + properties: + output: + availableBackups: "[createArray(1704067200, 1704153600, 1704240000, 1704326400)]" + latestBackup: "[last(createArray(1704067200, 1704153600, 1704240000, 1704326400))]" + description: "Most recent backup timestamp (Unix epoch)" +``` + +```bash +dsc config get --file last.example.3.dsc.config.yaml +``` + +```yaml +results: +- name: Backup Selection + type: Microsoft.DSC.Debug/Echo + result: + actualState: + output: + availableBackups: + - 1704067200 + - 1704153600 + - 1704240000 + - 1704326400 + latestBackup: 1704326400 + description: Most recent backup timestamp (Unix epoch) +messages: [] +hadErrors: false +``` + +The function returns `1704326400`, which represents the most recent backup in +the chronologically sorted array. + +### Example 4 - Combine with other functions for complex logic + +Use `last()` together with [`split()`][02] to extract file extensions or path +components. This example demonstrates parsing a filename to get its extension. + +```yaml +# last.example.4.dsc.config.yaml +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +resources: +- name: File Extension Parser + type: Microsoft.DSC.Debug/Echo + properties: + output: + filename: config.production.yaml + extension: "[last(split('config.production.yaml', '.'))]" +``` + +```bash +dsc config get --file last.example.4.dsc.config.yaml +``` + +```yaml +results: +- name: File Extension Parser + type: Microsoft.DSC.Debug/Echo + result: + actualState: + output: + filename: config.production.yaml + extension: yaml +messages: [] +hadErrors: false +``` + +By combining `split()` and `last()`, you can extract the `yaml` extension from +the full filename. + +## Parameters + +### arg + +The array or string to get the last element or character from. Required. + +```yaml +Type: array | string +Required: true +Position: 1 +``` + +## Output + +Returns the last element of the array (preserving its original type) or the +last character as a string. For arrays, the return type matches the element +type. For strings, returns a single-character string. + +```yaml +Type: any | string +``` + +## Errors + +The function returns an error in the following cases: + +- **Empty array**: The input array has no elements +- **Empty string**: The input string has no characters +- **Invalid type**: The argument is not an array or string + +## Related functions + +- [`first()`][00] - Returns the first element of an array or character of a string +- [`split()`][02] - Splits a string into an array +- [`createArray()`][01] - Creates an array from provided values + + +[00]: ./first.md +[01]: ./createArray.md +[02]: ./split.md diff --git a/dsc/tests/dsc_functions.tests.ps1 b/dsc/tests/dsc_functions.tests.ps1 index 4123a6865..f59b7e1e0 100644 --- a/dsc/tests/dsc_functions.tests.ps1 +++ b/dsc/tests/dsc_functions.tests.ps1 @@ -504,6 +504,28 @@ Describe 'tests for function expressions' { ($out.results[0].result.actualState.output | Out-String) | Should -BeExactly ($expected | Out-String) } + It 'last function works for: ' -TestCases @( + @{ expression = "[last(createArray('hello', 'world'))]"; expected = 'world' } + @{ expression = "[last(createArray(1, 2, 3))]"; expected = 3 } + @{ expression = "[last('hello')]"; expected = 'o' } + @{ expression = "[last('a')]"; expected = 'a' } + @{ expression = "[last(array('mixed'))]"; expected = 'mixed' } + ) { + param($expression, $expected) + + $config_yaml = @" + `$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json + resources: + - name: Echo + type: Microsoft.DSC.Debug/Echo + properties: + output: "$expression" +"@ + $out = dsc -l trace config get -i $config_yaml 2>$TestDrive/error.log | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 -Because (Get-Content $TestDrive/error.log -Raw) + ($out.results[0].result.actualState.output | Out-String) | Should -BeExactly ($expected | Out-String) + } + It 'indexOf function works for: ' -TestCases @( @{ expression = "[indexOf(createArray('apple', 'banana', 'cherry'), 'banana')]"; expected = 1 } @{ expression = "[indexOf(createArray('apple', 'banana', 'cherry'), 'cherry')]"; expected = 2 } diff --git a/lib/dsc-lib/locales/en-us.toml b/lib/dsc-lib/locales/en-us.toml index ad410767c..f292e340b 100644 --- a/lib/dsc-lib/locales/en-us.toml +++ b/lib/dsc-lib/locales/en-us.toml @@ -343,6 +343,13 @@ emptyArray = "Cannot get first element of empty array" emptyString = "Cannot get first character of empty string" invalidArgType = "Invalid argument type, argument must be an array or string" +[functions.last] +description = "Returns the last element of an array or last character of a string" +invoked = "last function" +emptyArray = "Cannot get last element of empty array" +emptyString = "Cannot get last character of empty string" +invalidArgType = "Invalid argument type, argument must be an array or string" + [functions.greater] description = "Evaluates if the first value is greater than the second value" invoked = "greater function" diff --git a/lib/dsc-lib/src/functions/last.rs b/lib/dsc-lib/src/functions/last.rs new file mode 100644 index 000000000..fa855a830 --- /dev/null +++ b/lib/dsc-lib/src/functions/last.rs @@ -0,0 +1,130 @@ +// 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 Last {} + +impl Function for Last { + fn get_metadata(&self) -> FunctionMetadata { + FunctionMetadata { + name: "last".to_string(), + description: t!("functions.last.description").to_string(), + category: vec![FunctionCategory::Array, FunctionCategory::String], + min_args: 1, + max_args: 1, + accepted_arg_ordered_types: vec![vec![FunctionArgKind::Array, FunctionArgKind::String]], + remaining_arg_accepted_types: None, + return_types: vec![FunctionArgKind::String, FunctionArgKind::Number, FunctionArgKind::Array, FunctionArgKind::Object], + } + } + + fn invoke(&self, args: &[Value], _context: &Context) -> Result { + debug!("{}", t!("functions.last.invoked")); + + if let Some(array) = args[0].as_array() { + if array.is_empty() { + return Err(DscError::Parser(t!("functions.last.emptyArray").to_string())); + } + return Ok(array[array.len() - 1].clone()); + } + + if let Some(string) = args[0].as_str() { + if string.is_empty() { + return Err(DscError::Parser(t!("functions.last.emptyString").to_string())); + } + return Ok(Value::String(string.chars().last().unwrap().to_string())); + } + + Err(DscError::Parser(t!("functions.last.invalidArgType").to_string())) + } +} + +#[cfg(test)] +mod tests { + use crate::configure::context::Context; + use crate::parser::Statement; + + #[test] + fn array_of_strings() { + let mut parser = Statement::new().unwrap(); + let result = parser.parse_and_execute("[last(createArray('hello', 'world'))]", &Context::new()).unwrap(); + assert_eq!(result.as_str(), Some("world")); + } + + #[test] + fn array_of_numbers() { + let mut parser = Statement::new().unwrap(); + let result = parser.parse_and_execute("[last(createArray(1, 2, 3))]", &Context::new()).unwrap(); + assert_eq!(result.to_string(), "3"); + } + + #[test] + fn array_of_single_element() { + let mut parser = Statement::new().unwrap(); + let result = parser.parse_and_execute("[last(array('hello'))]", &Context::new()).unwrap(); + assert_eq!(result.as_str(), Some("hello")); + } + + #[test] + fn string_input() { + let mut parser = Statement::new().unwrap(); + let result = parser.parse_and_execute("[last('hello')]", &Context::new()).unwrap(); + assert_eq!(result.as_str(), Some("o")); + } + + #[test] + fn single_character_string() { + let mut parser = Statement::new().unwrap(); + let result = parser.parse_and_execute("[last('a')]", &Context::new()).unwrap(); + assert_eq!(result.as_str(), Some("a")); + } + + #[test] + fn empty_array() { + let mut parser = Statement::new().unwrap(); + let result = parser.parse_and_execute("[last(createArray())]", &Context::new()); + assert!(result.is_err()); + } + + #[test] + fn empty_string() { + let mut parser = Statement::new().unwrap(); + let result = parser.parse_and_execute("[last('')]", &Context::new()); + assert!(result.is_err()); + } + + #[test] + fn invalid_type_object() { + let mut parser = Statement::new().unwrap(); + let result = parser.parse_and_execute("[last(createObject('key', 'value'))]", &Context::new()); + assert!(result.is_err()); + } + + #[test] + fn invalid_type_number() { + let mut parser = Statement::new().unwrap(); + let result = parser.parse_and_execute("[last(42)]", &Context::new()); + assert!(result.is_err()); + } + + #[test] + fn array_of_mixed_types() { + let mut parser = Statement::new().unwrap(); + let result = parser.parse_and_execute("[last(createArray('text', 42, true))]", &Context::new()).unwrap(); + assert_eq!(result.as_bool(), Some(true)); + } + + #[test] + fn unicode_string() { + let mut parser = Statement::new().unwrap(); + let result = parser.parse_and_execute("[last('Hello๐ŸŒ')]", &Context::new()).unwrap(); + assert_eq!(result.as_str(), Some("๐ŸŒ")); + } +} diff --git a/lib/dsc-lib/src/functions/mod.rs b/lib/dsc-lib/src/functions/mod.rs index 01133987e..81ee74455 100644 --- a/lib/dsc-lib/src/functions/mod.rs +++ b/lib/dsc-lib/src/functions/mod.rs @@ -35,6 +35,7 @@ pub mod greater_or_equals; pub mod r#if; pub mod r#false; pub mod first; +pub mod last; pub mod length; pub mod less; pub mod less_or_equals; @@ -163,6 +164,7 @@ impl FunctionDispatcher { Box::new(r#if::If{}), Box::new(r#false::False{}), Box::new(first::First{}), + Box::new(last::Last{}), Box::new(length::Length{}), Box::new(less::Less{}), Box::new(less_or_equals::LessOrEquals{}), From 8d1448351a8d7804c1470028b98fd657f000a9b6 Mon Sep 17 00:00:00 2001 From: "G.Reijn" Date: Sat, 25 Oct 2025 16:34:09 +0200 Subject: [PATCH 5/7] Fix test --- lib/dsc-lib/src/functions/last.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/dsc-lib/src/functions/last.rs b/lib/dsc-lib/src/functions/last.rs index fa855a830..a4fb67ff0 100644 --- a/lib/dsc-lib/src/functions/last.rs +++ b/lib/dsc-lib/src/functions/last.rs @@ -117,8 +117,8 @@ mod tests { #[test] fn array_of_mixed_types() { let mut parser = Statement::new().unwrap(); - let result = parser.parse_and_execute("[last(createArray('text', 42, true))]", &Context::new()).unwrap(); - assert_eq!(result.as_bool(), Some(true)); + let result = parser.parse_and_execute("[last(createArray('text', 42, 'last'))]", &Context::new()).unwrap(); + assert_eq!(result.as_str(), Some("last")); } #[test] From 03841cd0735010c775793fc609f384c2de2c4aad Mon Sep 17 00:00:00 2001 From: "G.Reijn" Date: Sat, 25 Oct 2025 16:59:34 +0200 Subject: [PATCH 6/7] Not allowed integers --- lib/dsc-lib/src/functions/last.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/dsc-lib/src/functions/last.rs b/lib/dsc-lib/src/functions/last.rs index a4fb67ff0..a5dbfcaed 100644 --- a/lib/dsc-lib/src/functions/last.rs +++ b/lib/dsc-lib/src/functions/last.rs @@ -115,9 +115,9 @@ mod tests { } #[test] - fn array_of_mixed_types() { + fn array_of_multiple_strings() { let mut parser = Statement::new().unwrap(); - let result = parser.parse_and_execute("[last(createArray('text', 42, 'last'))]", &Context::new()).unwrap(); + let result = parser.parse_and_execute("[last(createArray('text', 'middle', 'last'))]", &Context::new()).unwrap(); assert_eq!(result.as_str(), Some("last")); } From 25c8e36004cfb09dd8137704244b066dbe80f14c Mon Sep 17 00:00:00 2001 From: "G.Reijn" Date: Tue, 28 Oct 2025 01:41:54 +0100 Subject: [PATCH 7/7] Add null --- .../schemas/config/functions/last.md | 10 +++++----- dsc/tests/dsc_functions.tests.ps1 | 2 ++ lib/dsc-lib/locales/en-us.toml | 2 -- lib/dsc-lib/src/functions/last.rs | 20 +++---------------- 4 files changed, 10 insertions(+), 24 deletions(-) diff --git a/docs/reference/schemas/config/functions/last.md b/docs/reference/schemas/config/functions/last.md index 06f24c194..83f6ebfa5 100644 --- a/docs/reference/schemas/config/functions/last.md +++ b/docs/reference/schemas/config/functions/last.md @@ -23,8 +23,7 @@ item in a sequence, the final stage in a deployment pipeline, or the last character in a configuration value. For arrays, it returns the element at index `length - 1`. For strings, it -returns the last character as a string. If the input is empty, an error is -returned. +returns the last character as a string. ## Examples @@ -207,16 +206,17 @@ Returns the last element of the array (preserving its original type) or the last character as a string. For arrays, the return type matches the element type. For strings, returns a single-character string. +If the input is an empty array, the function returns `null`. If the input is an +empty string, the function returns an empty string. + ```yaml -Type: any | string +Type: any | string | null ``` ## Errors The function returns an error in the following cases: -- **Empty array**: The input array has no elements -- **Empty string**: The input string has no characters - **Invalid type**: The argument is not an array or string ## Related functions diff --git a/dsc/tests/dsc_functions.tests.ps1 b/dsc/tests/dsc_functions.tests.ps1 index f59b7e1e0..feb047d94 100644 --- a/dsc/tests/dsc_functions.tests.ps1 +++ b/dsc/tests/dsc_functions.tests.ps1 @@ -510,6 +510,8 @@ Describe 'tests for function expressions' { @{ expression = "[last('hello')]"; expected = 'o' } @{ expression = "[last('a')]"; expected = 'a' } @{ expression = "[last(array('mixed'))]"; expected = 'mixed' } + @{ expression = "[last(createArray())]"; expected = $null } + @{ expression = "[last('')]"; expected = '' } ) { param($expression, $expected) diff --git a/lib/dsc-lib/locales/en-us.toml b/lib/dsc-lib/locales/en-us.toml index f292e340b..54a1eead7 100644 --- a/lib/dsc-lib/locales/en-us.toml +++ b/lib/dsc-lib/locales/en-us.toml @@ -346,8 +346,6 @@ invalidArgType = "Invalid argument type, argument must be an array or string" [functions.last] description = "Returns the last element of an array or last character of a string" invoked = "last function" -emptyArray = "Cannot get last element of empty array" -emptyString = "Cannot get last character of empty string" invalidArgType = "Invalid argument type, argument must be an array or string" [functions.greater] diff --git a/lib/dsc-lib/src/functions/last.rs b/lib/dsc-lib/src/functions/last.rs index a5dbfcaed..7b8fb471e 100644 --- a/lib/dsc-lib/src/functions/last.rs +++ b/lib/dsc-lib/src/functions/last.rs @@ -21,7 +21,7 @@ impl Function for Last { max_args: 1, accepted_arg_ordered_types: vec![vec![FunctionArgKind::Array, FunctionArgKind::String]], remaining_arg_accepted_types: None, - return_types: vec![FunctionArgKind::String, FunctionArgKind::Number, FunctionArgKind::Array, FunctionArgKind::Object], + return_types: vec![FunctionArgKind::String, FunctionArgKind::Number, FunctionArgKind::Array, FunctionArgKind::Object, FunctionArgKind::Null], } } @@ -30,14 +30,14 @@ impl Function for Last { if let Some(array) = args[0].as_array() { if array.is_empty() { - return Err(DscError::Parser(t!("functions.last.emptyArray").to_string())); + return Ok(Value::Null); } return Ok(array[array.len() - 1].clone()); } if let Some(string) = args[0].as_str() { if string.is_empty() { - return Err(DscError::Parser(t!("functions.last.emptyString").to_string())); + return Ok(Value::String(String::new())); } return Ok(Value::String(string.chars().last().unwrap().to_string())); } @@ -86,20 +86,6 @@ mod tests { assert_eq!(result.as_str(), Some("a")); } - #[test] - fn empty_array() { - let mut parser = Statement::new().unwrap(); - let result = parser.parse_and_execute("[last(createArray())]", &Context::new()); - assert!(result.is_err()); - } - - #[test] - fn empty_string() { - let mut parser = Statement::new().unwrap(); - let result = parser.parse_and_execute("[last('')]", &Context::new()); - assert!(result.is_err()); - } - #[test] fn invalid_type_object() { let mut parser = Statement::new().unwrap();