From 0be046cf383a5b59476729c9650cbeefad21ece6 Mon Sep 17 00:00:00 2001 From: "G.Reijn" Date: Sat, 27 Sep 2025 08:53:46 +0200 Subject: [PATCH 1/5] Add `substring()` function --- .../schemas/config/functions/substring.md | 267 ++++++++++++++++++ dsc/tests/dsc_functions.tests.ps1 | 51 ++++ dsc_lib/locales/en-us.toml | 8 + dsc_lib/src/functions/mod.rs | 2 + dsc_lib/src/functions/substring.rs | 225 +++++++++++++++ 5 files changed, 553 insertions(+) create mode 100644 docs/reference/schemas/config/functions/substring.md create mode 100644 dsc_lib/src/functions/substring.rs diff --git a/docs/reference/schemas/config/functions/substring.md b/docs/reference/schemas/config/functions/substring.md new file mode 100644 index 000000000..56d788cd0 --- /dev/null +++ b/docs/reference/schemas/config/functions/substring.md @@ -0,0 +1,267 @@ +--- +description: Reference for the 'substring' DSC configuration document function +ms.date: 09/27/2025 +ms.topic: reference +title: substring +--- + +# substring + +## Synopsis + +Returns a substring that starts at the specified character position and contains +the specified number of characters. + +## Syntax + +```Syntax +substring(, ) +substring(, , ) +``` + +## Description + +The `substring()` function extracts a portion of a string based on the specified +starting position and optional length. The function uses zero-based indexing, +meaning the first character is at position 0. This is useful for parsing +identifiers, extracting prefixes or suffixes, manipulating configuration values, +or formatting display strings. + +Key behaviors: + +- **Zero-based indexing**: The first character is at index 0 +- **Optional length**: If length is omitted, returns the remainder of the string + from the start position +- **Boundary validation**: Prevents access beyond string boundaries with clear + error messages + +## Examples + +### Example 1 - Extract environment from resource name + +This example demonstrates extracting environment information from standardized +resource names for conditional configuration. It uses the [`parameters()`][08] +function to retrieve the resource name. + +```yaml +# substring.example.1.dsc.config.yaml +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +parameters: + resourceName: + type: string + defaultValue: svc-api-prod-east +resources: +- name: Extract environment + type: Microsoft.DSC.Debug/Echo + properties: + output: + environment: "[substring(parameters('resourceName'), 8, 4)]" +``` + +```bash +dsc config get --file substring.example.1.dsc.config.yaml +``` + +```yaml +results: +- name: Extract environment + type: Microsoft.DSC.Debug/Echo + result: + actualState: + output: + environment: prod +messages: [] +hadErrors: false +``` + +### Example 2 - Extract region from resource identifier + +This example shows extracting a region code from a standardized resource +identifier without specifying length, using [`parameters()`][08] to retrieve +the identifier. + +```yaml +# substring.example.2.dsc.config.yaml +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +parameters: + resourceId: + type: string + defaultValue: app-web-eastus2-001 +resources: +- name: Extract region + type: Microsoft.DSC.Debug/Echo + properties: + output: + region: "[substring(parameters('resourceId'), 8)]" +``` + +```bash +dsc config get --file substring.example.2.dsc.config.yaml +``` + +```yaml +results: +- name: Extract region + type: Microsoft.DSC.Debug/Echo + result: + actualState: + output: + region: eastus2-001 +messages: [] +hadErrors: false +``` + +### Example 3 - Parse version components + +This example demonstrates parsing semantic version strings to extract major, +minor, and patch components. It uses [`parameters()`][08] to retrieve the +version string. + +```yaml +# substring.example.3.dsc.config.yaml +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +parameters: + version: + type: string + defaultValue: "3.2.1" +resources: +- name: Parse version + type: Microsoft.DSC.Debug/Echo + properties: + output: + major: "[substring(parameters('version'), 0, 1)]" + minor: "[substring(parameters('version'), 2, 1)]" + patch: "[substring(parameters('version'), 4, 1)]" +``` + +```bash +dsc config get --file substring.example.3.dsc.config.yaml +``` + +```yaml +results: +- name: Parse version + type: Microsoft.DSC.Debug/Echo + result: + actualState: + output: + major: "3" + minor: "2" + patch: "1" +messages: [] +hadErrors: false +``` + +### Example 4 - Unicode and emoji support + +This example shows that `substring()` correctly handles Unicode characters and +emojis. It uses [`parameters()`][08] to retrieve the message string. + +```yaml +# substring.example.4.dsc.config.yaml +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +parameters: + message: + type: string + defaultValue: "Hello 🌍 World!" +resources: +- name: Unicode substring + type: Microsoft.DSC.Debug/Echo + properties: + output: + greeting: "[substring(parameters('message'), 0, 5)]" + emoji: "[substring(parameters('message'), 6, 1)]" + remainder: "[substring(parameters('message'), 8)]" +``` + +```bash +dsc config get --file substring.example.4.dsc.config.yaml +``` + +```yaml +results: +- name: Unicode substring + type: Microsoft.DSC.Debug/Echo + result: + actualState: + output: + greeting: Hello + emoji: 🌍 + remainder: " World!" +messages: [] +hadErrors: false +``` + +## Parameters + +### stringToParse + +The original string from which the substring is extracted. + +```yaml +Type: string +Required: true +Position: 1 +``` + +### startIndex + +The zero-based starting character position for the substring. Must be a +non-negative integer and cannot exceed the length of the string. + +```yaml +Type: int +Required: true +Position: 2 +``` + +### length + +The number of characters for the substring. Must be a non-negative integer. The +start index plus length cannot exceed the length of the string. If omitted, the +remainder of the string from the start position is returned. + +```yaml +Type: int +Required: false +Position: 3 +``` + +## Output + +The `substring()` function returns a string containing the extracted portion of +the original string. + +```yaml +Type: string +``` + +## Exceptions + +The `substring()` function raises errors for the following conditions: + +- **Invalid start index**: When `startIndex` is negative +- **Start index out of bounds**: When `startIndex` exceeds the string length +- **Invalid length**: When `length` is negative +- **Length out of bounds**: When `startIndex + length` exceeds the string + length + +## Related functions + +- [`string()`][00] - Converts values to strings +- [`concat()`][01] - Concatenates strings together +- [`indexOf()`][02] - Finds the index of a substring in a string +- [`lastIndexOf()`][03] - Finds the last index of a substring in a string +- [`length()`][04] - Returns the length of a string or array +- [`startsWith()`][05] - Checks if a string starts with a prefix +- [`endsWith()`][06] - Checks if a string ends with a suffix + + +[00]: ./string.md +[01]: ./concat.md +[02]: ./indexOf.md +[03]: ./lastIndexOf.md +[04]: ./length.md +[05]: ./startsWith.md +[06]: ./endsWith.md +[08]: ./parameters.md diff --git a/dsc/tests/dsc_functions.tests.ps1 b/dsc/tests/dsc_functions.tests.ps1 index ea856c2bf..6d8045a4c 100644 --- a/dsc/tests/dsc_functions.tests.ps1 +++ b/dsc/tests/dsc_functions.tests.ps1 @@ -666,4 +666,55 @@ Describe 'tests for function expressions' { $errorContent = Get-Content $TestDrive/error.log -Raw $errorContent | Should -Match ([regex]::Escape($expectedError)) } + + It 'substring function works for: ' -TestCases @( + @{ expression = "[substring('hello world', 6, 5)]"; expected = 'world' } + @{ expression = "[substring('hello', 0, 2)]"; expected = 'he' } + @{ expression = "[substring('hello', 1, 3)]"; expected = 'ell' } + @{ expression = "[substring('hello', 2)]"; expected = 'llo' } + @{ expression = "[substring('hello', 0)]"; expected = 'hello' } + @{ expression = "[substring('hello', 5)]"; expected = '' } + @{ expression = "[substring('hello', 1, 1)]"; expected = 'e' } + @{ expression = "[substring('hello', 5, 0)]"; expected = '' } + @{ expression = "[substring('', 0)]"; expected = '' } + @{ expression = "[substring('', 0, 0)]"; expected = '' } + @{ expression = "[substring('héllo', 1, 2)]"; expected = 'él' } + ) { + param($expression, $expected) + + $escapedExpression = $expression -replace "'", "''" + $config_yaml = @" + `$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json + resources: + - name: Echo + type: Microsoft.DSC.Debug/Echo + properties: + output: '$escapedExpression' +"@ + $out = $config_yaml | dsc config get -f - | ConvertFrom-Json + $out.results[0].result.actualState.output | Should -Be $expected + } + + # type validation is done on system-level + It 'substring function error handling: ' -TestCases @( + @{ expression = '[substring("hello", -1, 2)]'; expectedError = 'Start index cannot be negative' } + @{ expression = '[substring("hello", 1, -1)]'; expectedError = 'Length cannot be negative' } + @{ expression = '[substring("hello", 10, 1)]'; expectedError = 'Start index is beyond the end of the string' } + @{ expression = '[substring("hello", 2, 10)]'; expectedError = 'Length extends beyond the end of the string' } + ) { + param($expression, $expectedError) + + $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 + $LASTEXITCODE | Should -Not -Be 0 + $errorContent = Get-Content $TestDrive/error.log -Raw + $errorContent | Should -Match ([regex]::Escape($expectedError)) + } } diff --git a/dsc_lib/locales/en-us.toml b/dsc_lib/locales/en-us.toml index b2ed68060..13e94c5c5 100644 --- a/dsc_lib/locales/en-us.toml +++ b/dsc_lib/locales/en-us.toml @@ -479,6 +479,14 @@ invalidOriginalValue = "First argument must be an array or string" [functions.string] description = "Converts a value to a string" +[functions.substring] +description = "Returns a substring that starts at the specified character position and contains the specified number of characters" +invoked = "substring function" +startIndexNegative = "Start index cannot be negative" +lengthNegative = "Length cannot be negative" +startIndexTooLarge = "Start index is beyond the end of the string" +lengthTooLarge = "Length extends beyond the end of the string" + [functions.sub] description = "Subtracts the second number from the first" invoked = "sub function" diff --git a/dsc_lib/src/functions/mod.rs b/dsc_lib/src/functions/mod.rs index be0d5d532..d9013bfcf 100644 --- a/dsc_lib/src/functions/mod.rs +++ b/dsc_lib/src/functions/mod.rs @@ -60,6 +60,7 @@ pub mod skip; pub mod starts_with; pub mod string; pub mod sub; +pub mod substring; pub mod system_root; pub mod r#true; pub mod union; @@ -177,6 +178,7 @@ impl FunctionDispatcher { Box::new(starts_with::StartsWith{}), Box::new(string::StringFn{}), Box::new(sub::Sub{}), + Box::new(substring::Substring{}), Box::new(system_root::SystemRoot{}), Box::new(r#true::True{}), Box::new(utc_now::UtcNow{}), diff --git a/dsc_lib/src/functions/substring.rs b/dsc_lib/src/functions/substring.rs new file mode 100644 index 000000000..f81598fd0 --- /dev/null +++ b/dsc_lib/src/functions/substring.rs @@ -0,0 +1,225 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use super::Function; +use crate::configure::context::Context; +use crate::functions::{FunctionArgKind, FunctionCategory, FunctionMetadata}; +use crate::DscError; +use rust_i18n::t; +use serde_json::Value; +use tracing::debug; + +#[derive(Debug, Default)] +pub struct Substring {} + +impl Function for Substring { + fn get_metadata(&self) -> FunctionMetadata { + FunctionMetadata { + name: "substring".to_string(), + description: t!("functions.substring.description").to_string(), + category: vec![FunctionCategory::String], + min_args: 2, + max_args: 3, + accepted_arg_ordered_types: vec![ + vec![FunctionArgKind::String], + vec![FunctionArgKind::Number], + vec![FunctionArgKind::Number], + ], + remaining_arg_accepted_types: None, + return_types: vec![FunctionArgKind::String], + } + } + + fn invoke(&self, args: &[Value], _context: &Context) -> Result { + debug!("{}", t!("functions.substring.invoked")); + + let string_to_parse = args[0].as_str().unwrap(); + let start_index_value = args[1].as_i64().unwrap(); + + if start_index_value < 0 { + return Err(DscError::FunctionArg( + "substring".to_string(), + t!("functions.substring.startIndexNegative").to_string(), + )); + } + + let start_index = start_index_value as usize; + let string_length = string_to_parse.chars().count(); + + if start_index > string_length { + return Err(DscError::FunctionArg( + "substring".to_string(), + t!("functions.substring.startIndexTooLarge").to_string(), + )); + } + + let length = if args.len() == 2 { + string_length - start_index + } else { + let length_value = args[2].as_i64().unwrap(); + + if length_value < 0 { + return Err(DscError::FunctionArg( + "substring".to_string(), + t!("functions.substring.lengthNegative").to_string(), + )); + } + + let length = length_value as usize; + + if start_index + length > string_length { + return Err(DscError::FunctionArg( + "substring".to_string(), + t!("functions.substring.lengthTooLarge").to_string(), + )); + } + + length + }; + + let result: String = string_to_parse + .chars() + .skip(start_index) + .take(length) + .collect(); + + Ok(Value::String(result)) + } +} + +#[cfg(test)] +mod tests { + use crate::configure::context::Context; + use crate::parser::Statement; + use serde_json::Value; + + #[test] + fn substring_basic() { + let mut parser = Statement::new().unwrap(); + let result = parser + .parse_and_execute("[substring('hello', 1, 3)]", &Context::new()) + .unwrap(); + assert_eq!(result, Value::String("ell".to_string())); + } + + #[test] + fn substring_from_start() { + let mut parser = Statement::new().unwrap(); + let result = parser + .parse_and_execute("[substring('hello', 0, 2)]", &Context::new()) + .unwrap(); + assert_eq!(result, Value::String("he".to_string())); + } + + #[test] + fn substring_to_end() { + let mut parser = Statement::new().unwrap(); + let result = parser + .parse_and_execute("[substring('hello', 2)]", &Context::new()) + .unwrap(); + assert_eq!(result, Value::String("llo".to_string())); + } + + #[test] + fn substring_empty_result() { + let mut parser = Statement::new().unwrap(); + let result = parser + .parse_and_execute("[substring('hello', 5, 0)]", &Context::new()) + .unwrap(); + assert_eq!(result, Value::String("".to_string())); + } + + #[test] + fn substring_entire_string() { + let mut parser = Statement::new().unwrap(); + let result = parser + .parse_and_execute("[substring('hello', 0)]", &Context::new()) + .unwrap(); + assert_eq!(result, Value::String("hello".to_string())); + } + + #[test] + fn substring_single_char() { + let mut parser = Statement::new().unwrap(); + let result = parser + .parse_and_execute("[substring('hello', 1, 1)]", &Context::new()) + .unwrap(); + assert_eq!(result, Value::String("e".to_string())); + } + + #[test] + fn substring_unicode_support() { + let mut parser = Statement::new().unwrap(); + let result = parser + .parse_and_execute("[substring('héllo', 1, 2)]", &Context::new()) + .unwrap(); + assert_eq!(result, Value::String("él".to_string())); + } + + #[test] + fn substring_emoji_support() { + let mut parser = Statement::new().unwrap(); + let result = parser + .parse_and_execute("[substring('🚀🎉🔥', 1, 1)]", &Context::new()) + .unwrap(); + assert_eq!(result, Value::String("🎉".to_string())); + } + + #[test] + fn substring_negative_start_index() { + let mut parser = Statement::new().unwrap(); + let result = parser.parse_and_execute("[substring('hello', -1, 2)]", &Context::new()); + assert!(result.is_err()); + } + + #[test] + fn substring_negative_length() { + let mut parser = Statement::new().unwrap(); + let result = parser.parse_and_execute("[substring('hello', 1, -1)]", &Context::new()); + assert!(result.is_err()); + } + + #[test] + fn substring_start_index_too_large() { + let mut parser = Statement::new().unwrap(); + let result = parser.parse_and_execute("[substring('hello', 10, 1)]", &Context::new()); + assert!(result.is_err()); + } + + #[test] + fn substring_length_too_large() { + let mut parser = Statement::new().unwrap(); + let result = parser.parse_and_execute("[substring('hello', 2, 10)]", &Context::new()); + assert!(result.is_err()); + } + + // Type validation tests are removed as they are now handled at the system level + // The system automatically rejects invalid argument types based on accepted_arg_ordered_types + + #[test] + fn substring_empty_string() { + let mut parser = Statement::new().unwrap(); + let result = parser + .parse_and_execute("[substring('', 0, 0)]", &Context::new()) + .unwrap(); + assert_eq!(result, Value::String("".to_string())); + } + + #[test] + fn substring_empty_string_to_end() { + let mut parser = Statement::new().unwrap(); + let result = parser + .parse_and_execute("[substring('', 0)]", &Context::new()) + .unwrap(); + assert_eq!(result, Value::String("".to_string())); + } + + #[test] + fn substring_at_string_boundary() { + let mut parser = Statement::new().unwrap(); + let result = parser + .parse_and_execute("[substring('hello', 5)]", &Context::new()) + .unwrap(); + assert_eq!(result, Value::String("".to_string())); + } +} From be07e8af7ac256117f34c5ddb35ea77d2fac1910 Mon Sep 17 00:00:00 2001 From: "G.Reijn" Date: Sat, 27 Sep 2025 08:55:00 +0200 Subject: [PATCH 2/5] Remove comment --- dsc_lib/src/functions/substring.rs | 3 --- 1 file changed, 3 deletions(-) diff --git a/dsc_lib/src/functions/substring.rs b/dsc_lib/src/functions/substring.rs index f81598fd0..28a0d1d6c 100644 --- a/dsc_lib/src/functions/substring.rs +++ b/dsc_lib/src/functions/substring.rs @@ -193,9 +193,6 @@ mod tests { assert!(result.is_err()); } - // Type validation tests are removed as they are now handled at the system level - // The system automatically rejects invalid argument types based on accepted_arg_ordered_types - #[test] fn substring_empty_string() { let mut parser = Statement::new().unwrap(); From cf7d7bbf626bba010a714e236be589224cf85e6f Mon Sep 17 00:00:00 2001 From: "G.Reijn" Date: Sat, 27 Sep 2025 09:04:44 +0200 Subject: [PATCH 3/5] Fix clippy --- dsc_lib/locales/en-us.toml | 4 +++- dsc_lib/src/functions/substring.rs | 15 +++++++++++++-- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/dsc_lib/locales/en-us.toml b/dsc_lib/locales/en-us.toml index 13e94c5c5..29a4bb792 100644 --- a/dsc_lib/locales/en-us.toml +++ b/dsc_lib/locales/en-us.toml @@ -483,9 +483,11 @@ description = "Converts a value to a string" description = "Returns a substring that starts at the specified character position and contains the specified number of characters" invoked = "substring function" startIndexNegative = "Start index cannot be negative" -lengthNegative = "Length cannot be negative" startIndexTooLarge = "Start index is beyond the end of the string" +startIndexValueTooLarge = "Start index value too large" +lengthNegative = "Length cannot be negative" lengthTooLarge = "Length extends beyond the end of the string" +lengthValueTooLarge = "Length value too large" [functions.sub] description = "Subtracts the second number from the first" diff --git a/dsc_lib/src/functions/substring.rs b/dsc_lib/src/functions/substring.rs index 28a0d1d6c..18318cdd6 100644 --- a/dsc_lib/src/functions/substring.rs +++ b/dsc_lib/src/functions/substring.rs @@ -43,7 +43,12 @@ impl Function for Substring { )); } - let start_index = start_index_value as usize; + let start_index = usize::try_from(start_index_value).map_err(|_| { + DscError::FunctionArg( + "substring".to_string(), + t!("functions.substring.startIndexValueTooLarge").to_string(), + ) + })?; let string_length = string_to_parse.chars().count(); if start_index > string_length { @@ -65,7 +70,12 @@ impl Function for Substring { )); } - let length = length_value as usize; + let length = usize::try_from(length_value).map_err(|_| { + DscError::FunctionArg( + "substring".to_string(), + t!("functions.substring.lengthValueTooLarge").to_string(), + ) + })?; if start_index + length > string_length { return Err(DscError::FunctionArg( @@ -220,3 +230,4 @@ mod tests { assert_eq!(result, Value::String("".to_string())); } } + From df5b99dc0427c69a8fda728752515f28d698d6d5 Mon Sep 17 00:00:00 2001 From: "G.Reijn" Date: Sat, 27 Sep 2025 09:30:58 +0200 Subject: [PATCH 4/5] escape expression --- dsc/tests/dsc_functions.tests.ps1 | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/dsc/tests/dsc_functions.tests.ps1 b/dsc/tests/dsc_functions.tests.ps1 index 6d8045a4c..246477498 100644 --- a/dsc/tests/dsc_functions.tests.ps1 +++ b/dsc/tests/dsc_functions.tests.ps1 @@ -704,13 +704,14 @@ Describe 'tests for function expressions' { ) { param($expression, $expectedError) + $escapedExpression = $expression -replace "'", "''" $config_yaml = @" `$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json resources: - name: Echo type: Microsoft.DSC.Debug/Echo properties: - output: "$expression" + output: '$escapedExpression' "@ $out = dsc -l trace config get -i $config_yaml 2>$TestDrive/error.log $LASTEXITCODE | Should -Not -Be 0 From ff5006e5772f3744d6f1179851a47de1b954e384 Mon Sep 17 00:00:00 2001 From: "G.Reijn" Date: Sun, 28 Sep 2025 03:55:50 +0200 Subject: [PATCH 5/5] Fix up tests --- dsc/tests/dsc_functions.tests.ps1 | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/dsc/tests/dsc_functions.tests.ps1 b/dsc/tests/dsc_functions.tests.ps1 index 246477498..f69a2e4d0 100644 --- a/dsc/tests/dsc_functions.tests.ps1 +++ b/dsc/tests/dsc_functions.tests.ps1 @@ -695,23 +695,21 @@ Describe 'tests for function expressions' { $out.results[0].result.actualState.output | Should -Be $expected } - # type validation is done on system-level It 'substring function error handling: ' -TestCases @( - @{ expression = '[substring("hello", -1, 2)]'; expectedError = 'Start index cannot be negative' } - @{ expression = '[substring("hello", 1, -1)]'; expectedError = 'Length cannot be negative' } - @{ expression = '[substring("hello", 10, 1)]'; expectedError = 'Start index is beyond the end of the string' } - @{ expression = '[substring("hello", 2, 10)]'; expectedError = 'Length extends beyond the end of the string' } + @{ expression = "[substring('hello', -1, 2)]"; expectedError = 'Start index cannot be negative' } + @{ expression = "[substring('hello', 1, -1)]"; expectedError = 'Length cannot be negative' } + @{ expression = "[substring('hello', 10, 1)]"; expectedError = 'Start index is beyond the end of the string' } + @{ expression = "[substring('hello', 2, 10)]"; expectedError = 'Length extends beyond the end of the string' } ) { param($expression, $expectedError) - $escapedExpression = $expression -replace "'", "''" $config_yaml = @" `$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json resources: - name: Echo type: Microsoft.DSC.Debug/Echo properties: - output: '$escapedExpression' + output: `"$expression`" "@ $out = dsc -l trace config get -i $config_yaml 2>$TestDrive/error.log $LASTEXITCODE | Should -Not -Be 0