diff --git a/Directory.Build.props b/Directory.Build.props
index 822cbde..6852b86 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -9,7 +9,7 @@
Simpra
ALTA Software llc.
Copyright © 2024 ALTA Software llc.
- 1.0.15
+ 1.0.16
diff --git a/README.md b/README.md
index c72354f..aee7fc0 100644
--- a/README.md
+++ b/README.md
@@ -179,6 +179,7 @@ Simpra provides a set of internal functions out-of-the-box:
- **Strings**
- `number(str)` – converts to number
- `date(str)` – converts to date
+ - `substring(str, index, count)` – Retrieves part of a string starting at a **1-based position**.
---
### Extended Membership Operators
diff --git a/src/AltaSoft.Simpra/InternalLanguageFunctions.cs b/src/AltaSoft.Simpra/InternalLanguageFunctions.cs
index d4f6f1d..bcdef88 100644
--- a/src/AltaSoft.Simpra/InternalLanguageFunctions.cs
+++ b/src/AltaSoft.Simpra/InternalLanguageFunctions.cs
@@ -10,6 +10,8 @@ internal static class InternalLanguageFunctions
// ReSharper disable InconsistentNaming
#pragma warning disable IDE1006 // Naming Styles
+ public static SimpraString substring(SimpraString input, SimpraNumber index) => SimpraString.Substring(input, index);
+ public static SimpraString substring(SimpraString input, SimpraNumber index, SimpraNumber count) => SimpraString.Substring(input, index, count);
public static SimpraNumber length(SimpraString input) => new(input.Value.Length);
public static SimpraNumber length(SimpraString[] input) => new(input.Length);
public static SimpraNumber length(SimpraNumber[] input) => new(input.Length);
@@ -48,7 +50,8 @@ internal static class InternalLanguageFunctions
nameof(sum),
nameof(@string),
nameof(@number),
- nameof(@date)
+ nameof(@date),
+ nameof(substring)
];
internal static bool IsSimpraBuiltInFunction(this string name) => s_builtInFunctions.Contains(name);
diff --git a/src/AltaSoft.Simpra/Types/SimpraString.cs b/src/AltaSoft.Simpra/Types/SimpraString.cs
index 1b9e517..d7ebf6c 100644
--- a/src/AltaSoft.Simpra/Types/SimpraString.cs
+++ b/src/AltaSoft.Simpra/Types/SimpraString.cs
@@ -68,15 +68,26 @@ public static SimpraString Substring(SimpraString value, SimpraNumber index, Sim
if (!value.HasValue)
return value;
- var startIndex = (int)index.Value;
+ var startIndex = (int)index.Value - 1; // convert 1-based to 0-based index
var length = (int)count.Value;
- return startIndex < 0 || length < 0 || startIndex + length > value.Value.Length
- ? throw new InvalidOperationException("The index and count must be within the bounds of the string")
- : new SimpraString(value.Value.Substring(startIndex, length));
+ if (startIndex < 0)
+ startIndex = 0;
+
+ if (length < 0)
+ length = 0;
+
+ if (startIndex >= value.Value.Length || length == 0)
+ return new SimpraString(string.Empty);
+
+ if (startIndex + length >= value.Value.Length)
+ {
+ length = value.Value.Length - startIndex;
+ }
+ return new SimpraString(value.Value.Substring(startIndex, length));
}
- public static SimpraString Substring(SimpraString value, SimpraNumber index) => !value.HasValue ? value : new SimpraString(value.Value.Substring((int)index.Value, 1));
+ public static SimpraString Substring(SimpraString value, SimpraNumber index) => Substring(value, index, 1);
public static SimpraBool Like(SimpraString value, SimpraString pattern)
{
diff --git a/tests/AltaSoft.Simpra.tests/SimpraExpressionTests.cs b/tests/AltaSoft.Simpra.tests/SimpraExpressionTests.cs
index f270f16..3d99ba6 100644
--- a/tests/AltaSoft.Simpra.tests/SimpraExpressionTests.cs
+++ b/tests/AltaSoft.Simpra.tests/SimpraExpressionTests.cs
@@ -4,6 +4,171 @@ namespace AltaSoft.Simpra.Tests;
public class SimpraExpressionTests
{
+ [Theory]
+ // basic / within bounds
+ [InlineData("USD", 1, 3, "USD")] // from U
+ [InlineData("USD", 1, 2, "US")]
+ [InlineData("USD", 2, 2, "SD")] // from S
+ [InlineData("USD", 3, 1, "D")] // from D
+
+ // length clamping at the end
+ [InlineData("USD", 1, 10003, "USD")]
+ [InlineData("USD", 3, 10, "D")]
+
+ // start beyond end
+ [InlineData("USD", 1000, 1, "")]
+ [InlineData("USD", 4, 1, "")] // beyond "USD"
+
+ // start exactly at end
+ [InlineData("USD", 4, 0, "")]
+ [InlineData("USD", 4, 10, "")]
+
+ // zero length
+ [InlineData("USD", 1, 0, "")]
+ [InlineData("USD", 2, 0, "")]
+ [InlineData("USD", 3, 0, "")]
+
+ // negative/zero start → clamp to 1
+ [InlineData("USD", 0, 1, "U")]
+ [InlineData("USD", -5, 2, "US")]
+ [InlineData("USD", -2, 100, "USD")]
+
+ // negative length → empty
+ [InlineData("USD", 1, -1, "")]
+ [InlineData("USD", 3, -10, "")]
+ [InlineData("USD", -2, -10, "")]
+
+ // empty input
+ [InlineData("", 1, 5, "")]
+ [InlineData("", 10, 1, "")]
+ [InlineData("", -3, 2, "")]
+ [InlineData("", 1, 0, "")]
+
+ // whitespace
+ [InlineData(" ", 1, 1, " ")]
+ [InlineData(" ", 2, 2, " ")]
+ [InlineData(" ", 2, 100, " ")]
+
+ // non-ASCII (safe checks with multi-byte chars)
+ [InlineData("თბილისი", 1, 2, "თბ")]
+ [InlineData("თბილისი", 3, 3, "ილი")]
+ [InlineData("თბილისი", 11, 5, "")]
+ [InlineData("თბილისი", -3, 100, "თბილისი")]
+
+ public void BuiltInFunction_Substring_EdgeCases(string input, int start, int length, string expected)
+ {
+ var simpra = new Simpra();
+ var model = GetTestModel();
+ model.Ccy = input;
+ var expressionCode = $"return substring(Ccy,{start},{length})";
+ var result = simpra.Execute(model, new TestFunctions(), expressionCode);
+
+ Assert.Equal(expected, result);
+ }
+
+ [Fact]
+ public void BuiltInFunction_Substring_ShouldMatchExamplesFromPrompt()
+ {
+ var simpra = new Simpra();
+ var model = GetTestModel();
+ model.Ccy = "USD";
+
+ var result = simpra.Execute(model, new TestFunctions(), "return substring(Ccy,0,3)");
+ Assert.Equal("USD", result);
+
+ result = simpra.Execute(model, new TestFunctions(), "return substring(Ccy,0,10003)");
+ Assert.Equal("USD", result);
+
+ result = simpra.Execute(model, new TestFunctions(), "return substring(Ccy,1000,1)");
+ Assert.Equal("", result);
+
+ result = simpra.Execute(model, new TestFunctions(), "return substring(Ccy,1000,0)");
+ Assert.Equal("", result);
+
+ result = simpra.Execute(model, new TestFunctions(), "return substring(Ccy,1,0)");
+ Assert.Equal("", result);
+ }
+ [Theory]
+ // basic in-bounds
+ [InlineData("USD", 1, "U")]
+ [InlineData("USD", 2, "S")]
+ [InlineData("USD", 3, "D")]
+
+ // at/after end => empty
+ [InlineData("USD", 4, "")]
+ [InlineData("USD", 1000, "")]
+ [InlineData("U", 2, "")]
+ [InlineData("", 1, "")]
+ [InlineData("", 5, "")]
+
+ // zero/negative start → clamp to 1
+ [InlineData("USD", 0, "U")]
+ [InlineData("USD", -1, "U")]
+ [InlineData("USD", -5, "U")]
+ [InlineData("", -3, "")]
+
+ // whitespace
+ [InlineData(" X ", 1, " ")]
+ [InlineData(" X ", 2, "X")]
+ [InlineData(" X ", 3, " ")]
+
+ // non-ASCII (single UTF-16 code units)
+ [InlineData("თბილისი", 1, "თ")]
+ [InlineData("თბილისი", 3, "ი")]
+ [InlineData("თბილისი", 6, "ს")]
+ [InlineData("თბილისი", 7, "ი")]
+ [InlineData("თბილისი", 8, "")]
+
+ // very large index
+ [InlineData("USD", int.MaxValue, "")]
+ public void BuiltInFunction_Substring_StartOnly_EdgeCases(string input, int start, string expected)
+ {
+ var simpra = new Simpra();
+ var model = GetTestModel();
+ model.Ccy = input;
+ var expressionCode = $"return substring(Ccy,{start})";
+ var result = simpra.Execute(model, new TestFunctions(), expressionCode);
+
+ Assert.Equal(expected, result);
+ }
+
+ [Fact]
+ public void BuiltInFunction_Substring_StartOnly_NullSource_ShouldBeEmpty()
+ {
+ var simpra = new Simpra();
+ var model = GetTestModel();
+ model.Ccy = null;
+
+ var result = simpra.Execute(model, new TestFunctions(), "return substring(Ccy,1)");
+ Assert.Equal(null, result);
+ }
+
+ [Fact]
+ public void BuiltInFunction_Substring_NullSource_ShouldBeNull()
+ {
+ // If your DSL defines a behavior for nulls, keep this.
+ // If it should throw instead, change to Assert.Throws.
+ var simpra = new Simpra();
+ var model = GetTestModel();
+ model.Ccy = null!;
+
+ var result = simpra.Execute(model, new TestFunctions(), "return substring(Ccy,0,3)");
+ Assert.Null(result);
+ }
+
+ [Fact]
+ public void BuiltInFunction_Substring_ShouldReturnCorrectSubstringOfLength1()
+ {
+ const string expressionCode = "return substring(Ccy,1)";
+
+ var simpra = new Simpra();
+ var model = GetTestModel();
+ model.Ccy = "USD";
+
+ var result = simpra.Execute(model, new TestFunctions(), expressionCode);
+ Assert.Equal("U", result);
+ }
+
[Fact]
public void InvalidSimpraSyntax_ShouldThrowException_WhenIncorrectAndSignIsUsedAndReturnStatement()
{