From ebc45c33c96e203d34f39f6f8c64f5fa65c63254 Mon Sep 17 00:00:00 2001 From: Roman Kondrat'ev <62770895+RomeCore@users.noreply.github.com> Date: Sun, 21 Jun 2026 20:27:50 +0500 Subject: [PATCH 1/2] feat: add type system, length operator (#), null coalescing (??), inline arrays/objects and instance parser --- .../DataAccessors/TemplateArrayAccessor.cs | 6 + .../DataAccessors/TemplateBooleanAccessor.cs | 29 +++- .../TemplateDictionaryAccessor.cs | 7 + .../DataAccessors/TemplateNullAccessor.cs | 2 + .../DataAccessors/TemplateNumberAccessor.cs | 9 + .../DataAccessors/TemplateObjectAccessor.cs | 2 + .../DataAccessors/TemplateStringAccessor.cs | 8 +- .../TemplateArrayExpressionNode.cs | 52 ++++++ .../TemplateHasPropertyExpressionNode.cs | 46 +++++ .../TemplateObjectExpressionNode.cs | 51 ++++++ .../TemplatePropertyExpressionNode.cs | 41 ----- src/LLTSharp/LLTParser.cs | 157 +++++++++++++----- src/LLTSharp/LLTSharp.csproj | 4 +- src/LLTSharp/MessagesTemplate.cs | 11 +- src/LLTSharp/TemplateContextAccessor.cs | 38 +++++ src/LLTSharp/TemplateDataAccessor.cs | 29 ++++ src/LLTSharp/TemplateFunction.cs | 3 + src/LLTSharp/TemplateFunctionSet.cs | 48 ++++-- src/LLTSharp/TemplateFunctions.cs | 36 ++++ src/LLTSharp/TemplateLibrary.cs | 16 ++ src/LLTSharp/TextTemplate.cs | 11 +- .../LLTSharp.Tests/TemplateRenderingTests.cs | 82 +++++++++ 22 files changed, 576 insertions(+), 112 deletions(-) create mode 100644 src/LLTSharp/ExpressionNodes/TemplateArrayExpressionNode.cs create mode 100644 src/LLTSharp/ExpressionNodes/TemplateHasPropertyExpressionNode.cs create mode 100644 src/LLTSharp/ExpressionNodes/TemplateObjectExpressionNode.cs create mode 100644 src/LLTSharp/TemplateFunctions.cs diff --git a/src/LLTSharp/DataAccessors/TemplateArrayAccessor.cs b/src/LLTSharp/DataAccessors/TemplateArrayAccessor.cs index 20bb4db..d4b8e8c 100644 --- a/src/LLTSharp/DataAccessors/TemplateArrayAccessor.cs +++ b/src/LLTSharp/DataAccessors/TemplateArrayAccessor.cs @@ -27,6 +27,8 @@ public TemplateArrayAccessor(IEnumerable array) _array = array?.ToArray() ?? throw new ArgumentNullException(nameof(array)); } + public override string Type => "array"; + public override TemplateDataAccessor Index(TemplateDataAccessor index, bool safe) { try @@ -69,6 +71,8 @@ public override TemplateDataAccessor Operator(UnaryOperatorType type) { UnaryOperatorType.LogicalNot => new TemplateBooleanAccessor(!AsBoolean()), + UnaryOperatorType.LengthOf => new TemplateNumberAccessor(Length), + _ => throw new TemplateRuntimeException( $"Unary operator '{type}' is not valid for array values", dataAccessor: this) @@ -104,6 +108,8 @@ BinaryOperatorType.GreaterThan or $"Operator '{type}' cannot be applied to array values", dataAccessor: this), + BinaryOperatorType.Coalesce => this, + _ => throw new TemplateRuntimeException( $"Unknown operator type: {type}", dataAccessor: this) diff --git a/src/LLTSharp/DataAccessors/TemplateBooleanAccessor.cs b/src/LLTSharp/DataAccessors/TemplateBooleanAccessor.cs index 223e629..466e9e0 100644 --- a/src/LLTSharp/DataAccessors/TemplateBooleanAccessor.cs +++ b/src/LLTSharp/DataAccessors/TemplateBooleanAccessor.cs @@ -9,6 +9,16 @@ namespace LLTSharp.DataAccessors /// public class TemplateBooleanAccessor : TemplateDataAccessor { + /// + /// Gets a singleton instance representing true. + /// + public static TemplateBooleanAccessor True { get; } = new TemplateBooleanAccessor(true); + + /// + /// Gets a singleton instance representing false. + /// + public static TemplateBooleanAccessor False { get; } = new TemplateBooleanAccessor(false); + /// /// Gets the value of the boolean accessor. /// @@ -22,6 +32,8 @@ public TemplateBooleanAccessor(bool value) Value = value; } + public override string Type => "boolean"; + public override bool AsBoolean() { return Value; @@ -47,15 +59,16 @@ public override string ToString(string? format = null) public override TemplateDataAccessor Operator(UnaryOperatorType type) { - switch (type) + return type switch { - case UnaryOperatorType.Negate: - case UnaryOperatorType.LogicalNot: - return new TemplateBooleanAccessor(!AsBoolean()); + UnaryOperatorType.Negate or UnaryOperatorType.LogicalNot => new TemplateBooleanAccessor(!AsBoolean()), - default: - throw new TemplateRuntimeException("Invalid operator type.", dataAccessor: this); - } + UnaryOperatorType.LengthOf => new TemplateNumberAccessor(Length), + + _ => throw new TemplateRuntimeException( + $"Unary operator '{type}' is not valid for boolean values", + dataAccessor: this) + }; } public override TemplateDataAccessor Operator(TemplateDataAccessor other, BinaryOperatorType type) @@ -85,6 +98,8 @@ BinaryOperatorType.GreaterThan or throw new TemplateRuntimeException( $"Comparison operator '{type}' is not valid for boolean values"), + BinaryOperatorType.Coalesce => this, + _ => throw new TemplateRuntimeException( $"Unknown operator type: {type}") }; diff --git a/src/LLTSharp/DataAccessors/TemplateDictionaryAccessor.cs b/src/LLTSharp/DataAccessors/TemplateDictionaryAccessor.cs index 9192286..1314146 100644 --- a/src/LLTSharp/DataAccessors/TemplateDictionaryAccessor.cs +++ b/src/LLTSharp/DataAccessors/TemplateDictionaryAccessor.cs @@ -41,6 +41,8 @@ public TemplateDictionaryAccessor(IDictionary dict Dictionary = new ReadOnlyDictionary(_dictionary); } + public override string Type => "object"; + public override TemplateDataAccessor Index(TemplateDataAccessor index, bool safe) { return Property(index.ToString(), safe); @@ -78,6 +80,9 @@ public override TemplateDataAccessor Operator(UnaryOperatorType type) return type switch { UnaryOperatorType.LogicalNot => new TemplateBooleanAccessor(!AsBoolean()), + + UnaryOperatorType.LengthOf => new TemplateNumberAccessor(Length), + _ => throw new TemplateRuntimeException( $"Unary operator '{type}' is not valid for dictionary values", dataAccessor: this) @@ -113,6 +118,8 @@ BinaryOperatorType.GreaterThan or $"Operator '{type}' cannot be applied to dictionary values", dataAccessor: this), + BinaryOperatorType.Coalesce => this, + _ => throw new TemplateRuntimeException( $"Unknown operator type: {type}", dataAccessor: this) diff --git a/src/LLTSharp/DataAccessors/TemplateNullAccessor.cs b/src/LLTSharp/DataAccessors/TemplateNullAccessor.cs index 9b8d25a..df9c2b8 100644 --- a/src/LLTSharp/DataAccessors/TemplateNullAccessor.cs +++ b/src/LLTSharp/DataAccessors/TemplateNullAccessor.cs @@ -23,6 +23,8 @@ public class TemplateNullAccessor : TemplateDataAccessor /// private TemplateNullAccessor() { } + public override string Type => "null"; + public override bool AsBoolean() { return false; diff --git a/src/LLTSharp/DataAccessors/TemplateNumberAccessor.cs b/src/LLTSharp/DataAccessors/TemplateNumberAccessor.cs index d89b10a..15509ed 100644 --- a/src/LLTSharp/DataAccessors/TemplateNumberAccessor.cs +++ b/src/LLTSharp/DataAccessors/TemplateNumberAccessor.cs @@ -1,4 +1,5 @@ using System; +using System.Collections; using System.Collections.Generic; using System.Text; @@ -19,6 +20,8 @@ public TemplateNumberAccessor(double value) Value = value; } + public override string Type => "number"; + public override bool AsBoolean() { return Value != 0; @@ -39,7 +42,11 @@ public override TemplateDataAccessor Operator(UnaryOperatorType type) return type switch { UnaryOperatorType.Negate => new TemplateNumberAccessor(-Value), + UnaryOperatorType.LogicalNot => new TemplateBooleanAccessor(!AsBoolean()), + + UnaryOperatorType.LengthOf => new TemplateNumberAccessor(Length), + _ => throw new TemplateRuntimeException("Invalid operator type for number.", dataAccessor: this) }; } @@ -70,6 +77,8 @@ public override TemplateDataAccessor Operator(TemplateDataAccessor other, Binary BinaryOperatorType.LogicalAnd => new TemplateBooleanAccessor(AsBoolean() && other.AsBoolean()), BinaryOperatorType.LogicalOr => new TemplateBooleanAccessor(AsBoolean() || other.AsBoolean()), + BinaryOperatorType.Coalesce => this, + _ => throw new TemplateRuntimeException($"Unknown operator type: {type}") }; } diff --git a/src/LLTSharp/DataAccessors/TemplateObjectAccessor.cs b/src/LLTSharp/DataAccessors/TemplateObjectAccessor.cs index b6db479..e21c0d9 100644 --- a/src/LLTSharp/DataAccessors/TemplateObjectAccessor.cs +++ b/src/LLTSharp/DataAccessors/TemplateObjectAccessor.cs @@ -27,6 +27,8 @@ public TemplateObjectAccessor(object target, DataAccessorCreationOptions options _propertyAccessors = CreatePropertyAccessors(target, options); } + public override string Type => "object"; + private static IReadOnlyDictionary> CreatePropertyAccessors(object obj, DataAccessorCreationOptions options) { var accessors = new Dictionary>(); diff --git a/src/LLTSharp/DataAccessors/TemplateStringAccessor.cs b/src/LLTSharp/DataAccessors/TemplateStringAccessor.cs index 9571f18..05bace3 100644 --- a/src/LLTSharp/DataAccessors/TemplateStringAccessor.cs +++ b/src/LLTSharp/DataAccessors/TemplateStringAccessor.cs @@ -21,6 +21,8 @@ public TemplateStringAccessor(string? value) Value = value ?? string.Empty; } + public override string Type => "string"; + public override bool AsBoolean() { return !string.IsNullOrEmpty(Value); @@ -74,7 +76,9 @@ public override TemplateDataAccessor Operator(UnaryOperatorType type) return type switch { UnaryOperatorType.LogicalNot => new TemplateBooleanAccessor(!AsBoolean()), - + + UnaryOperatorType.LengthOf => new TemplateNumberAccessor(Length), + _ => throw new TemplateRuntimeException( $"Unary operator '{type}' is not valid for string values", dataAccessor: this) @@ -108,6 +112,8 @@ BinaryOperatorType.Divide or $"Arithmetic operator '{type}' cannot be applied to string values", dataAccessor: this), + BinaryOperatorType.Coalesce => this, + _ => throw new TemplateRuntimeException( $"Unknown operator type: {type}", dataAccessor: this) diff --git a/src/LLTSharp/ExpressionNodes/TemplateArrayExpressionNode.cs b/src/LLTSharp/ExpressionNodes/TemplateArrayExpressionNode.cs new file mode 100644 index 0000000..1d95f11 --- /dev/null +++ b/src/LLTSharp/ExpressionNodes/TemplateArrayExpressionNode.cs @@ -0,0 +1,52 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Text; +using LLTSharp.DataAccessors; + +namespace LLTSharp.ExpressionNodes +{ + /// + /// Represents an array creation expression node: [expr1, expr2, ...]. + /// Evaluates each element as an expression and returns a . + /// + public class TemplateArrayExpressionNode : TemplateExpressionNode + { + private readonly TemplateExpressionNode[] _items; + + /// + /// Gets the items of the array expression. + /// + public IReadOnlyList Items { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The expressions for each element in the array. + /// Thrown when is null. + public TemplateArrayExpressionNode(IEnumerable items) + { + _items = items?.ToArray() ?? throw new ArgumentNullException(nameof(items)); + Items = new ReadOnlyCollection(_items); + } + + public override TemplateDataAccessor Evaluate(TemplateContextAccessor context) + { + var evaluatedItems = new TemplateDataAccessor[_items.Length]; + for (int i = 0; i < _items.Length; i++) + { + var item = _items[i].Evaluate(context); + if (item == null) + throw new TemplateRuntimeException($"Array element at index {i} evaluated to null.", dataAccessor: null); + evaluatedItems[i] = item; + } + return new TemplateArrayAccessor(evaluatedItems); + } + + public override string ToString() + { + return "[" + string.Join(", ", _items.Select(i => i.ToString())) + "]"; + } + } +} diff --git a/src/LLTSharp/ExpressionNodes/TemplateHasPropertyExpressionNode.cs b/src/LLTSharp/ExpressionNodes/TemplateHasPropertyExpressionNode.cs new file mode 100644 index 0000000..0b2e955 --- /dev/null +++ b/src/LLTSharp/ExpressionNodes/TemplateHasPropertyExpressionNode.cs @@ -0,0 +1,46 @@ +using System; +using LLTSharp.DataAccessors; + +namespace LLTSharp.ExpressionNodes +{ + /// + /// Represents a property check expression node in a template. + /// + public class TemplateHasPropertyExpressionNode : TemplateExpressionNode + { + /// + /// Gets the child expression node that has the property to check. + /// + public TemplateExpressionNode Child { get; } + + /// + /// Gets the name of the property to check. + /// + public TemplateExpressionNode PropertyName { get; } + + /// + /// Creates a new instance of the class. + /// + /// The child expression node that has the property to check. + /// The name of the property to check. + /// + /// + public TemplateHasPropertyExpressionNode(TemplateExpressionNode child, TemplateExpressionNode propertyName) + { + Child = child ?? throw new ArgumentNullException(nameof(child)); + PropertyName = propertyName ?? throw new ArgumentException("Property name cannot be null or empty.", nameof(propertyName)); + } + + public override TemplateDataAccessor Evaluate(TemplateContextAccessor context) + { + var child = Child.Evaluate(context); + var propertyName = PropertyName.Evaluate(context).ToString(); + return new TemplateBooleanAccessor(child.HasProperty(propertyName)); + } + + public override string ToString() + { + return $"{Child} ?: {PropertyName}"; + } + } +} \ No newline at end of file diff --git a/src/LLTSharp/ExpressionNodes/TemplateObjectExpressionNode.cs b/src/LLTSharp/ExpressionNodes/TemplateObjectExpressionNode.cs new file mode 100644 index 0000000..035b0df --- /dev/null +++ b/src/LLTSharp/ExpressionNodes/TemplateObjectExpressionNode.cs @@ -0,0 +1,51 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Text; +using LLTSharp.DataAccessors; + +namespace LLTSharp.ExpressionNodes +{ + /// + /// Represents an object/dictionary creation expression node: { key: expr, ... }. + /// Evaluates each value as an expression and returns a . + /// + public class TemplateObjectExpressionNode : TemplateExpressionNode + { + private readonly KeyValuePair[] _pairs; + + /// + /// Gets the key-expression pairs of the object expression. + /// + public IReadOnlyList> Pairs { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The key-expression pairs for the object. + /// Thrown when is null. + public TemplateObjectExpressionNode(IEnumerable> pairs) + { + _pairs = pairs?.ToArray() ?? throw new ArgumentNullException(nameof(pairs)); + Pairs = new ReadOnlyCollection>(_pairs); + } + + public override TemplateDataAccessor Evaluate(TemplateContextAccessor context) + { + var dict = new Dictionary(_pairs.Length); + foreach (var pair in _pairs) + { + var key = pair.Key.Evaluate(context).ToString(); + var value = pair.Value.Evaluate(context); + dict[key] = value; + } + return new TemplateDictionaryAccessor(dict); + } + + public override string ToString() + { + return "{" + string.Join(", ", _pairs.Select(p => $"{p.Key}: {p.Value}")) + "}"; + } + } +} diff --git a/src/LLTSharp/ExpressionNodes/TemplatePropertyExpressionNode.cs b/src/LLTSharp/ExpressionNodes/TemplatePropertyExpressionNode.cs index 3ad681f..3e4749c 100644 --- a/src/LLTSharp/ExpressionNodes/TemplatePropertyExpressionNode.cs +++ b/src/LLTSharp/ExpressionNodes/TemplatePropertyExpressionNode.cs @@ -51,45 +51,4 @@ public override string ToString() return $"{Child}.{PropertyName}"; } } - - /// - /// Represents a property check expression node in a template. - /// - public class TemplateHasPropertyExpressionNode : TemplateExpressionNode - { - /// - /// Gets the child expression node that has the property to check. - /// - public TemplateExpressionNode Child { get; } - - /// - /// Gets the name of the property to check. - /// - public TemplateExpressionNode PropertyName { get; } - - /// - /// Creates a new instance of the class. - /// - /// The child expression node that has the property to check. - /// The name of the property to check. - /// - /// - public TemplateHasPropertyExpressionNode(TemplateExpressionNode child, TemplateExpressionNode propertyName) - { - Child = child ?? throw new ArgumentNullException(nameof(child)); - PropertyName = propertyName ?? throw new ArgumentException("Property name cannot be null or empty.", nameof(propertyName)); - } - - public override TemplateDataAccessor Evaluate(TemplateContextAccessor context) - { - var child = Child.Evaluate(context); - var propertyName = PropertyName.Evaluate(context).ToString(); - return new TemplateBooleanAccessor(child.HasProperty(propertyName)); - } - - public override string ToString() - { - return $"{Child} ? {PropertyName}"; - } - } } \ No newline at end of file diff --git a/src/LLTSharp/LLTParser.cs b/src/LLTSharp/LLTParser.cs index d44a9dc..c2471f3 100644 --- a/src/LLTSharp/LLTParser.cs +++ b/src/LLTSharp/LLTParser.cs @@ -17,55 +17,82 @@ namespace LLTSharp /// public class LLTParser : ITemplateParser { - private class LLTParsingContext + protected class LLTParsingContext { public TemplateLibrary LocalLibrary { get; set; } public IEnumerable MetadataFactories { get; set; } } - private static readonly Parser _parser; - - public static Parser Parser => _parser; - private static void DeclareValues(ParserBuilder builder) { builder.CreateToken("identifier") - .Identifier() - .Transform(v => v.Text); + .CaptureText(b => b.Identifier()); + + builder.CreateToken("identifier_or_string") + .Choice( + b => b.CaptureText(b => b.Identifier()), + b => b + .Literal('\'') + .EscapedTextDoubleChars('\'') + .Literal('\'') + .Pass(1), + b => b + .Literal('"') + .EscapedTextDoubleChars('"') + .Literal('"') + .Pass(1) + ); builder.CreateToken("method_name") - .Identifier() - .Transform(v => v.Text); + .CaptureText(b => b.Identifier()); builder.CreateToken("field_name") - .Identifier() - .Transform(v => v.Text); + .CaptureText(b => b.Identifier()); builder.CreateToken("number") - .Number() - .Transform(v => new TemplateNumberAccessor(v.GetIntermediateValue())); + .Map(b => b.Number(), n => new TemplateNumberAccessor(n)); builder.CreateToken("string") - .Literal('\'') - .EscapedTextDoubleChars('\'') - .Literal('\'') - .Pass(1) + .Choice( + b => b + .Literal('\'') + .EscapedTextDoubleChars('\'') + .Literal('\'') + .Pass(1), + b => b + .Literal('"') + .EscapedTextDoubleChars('"') + .Literal('"') + .Pass(1) + ) .Transform(v => new TemplateStringAccessor(v.GetIntermediateValue())); builder.CreateToken("raw_string") - .Literal('\'') - .EscapedTextDoubleChars('\'') - .Literal('\'') - .Pass(1) + .Choice( + b => b + .Literal('\'') + .EscapedTextDoubleChars('\'') + .Literal('\'') + .Pass(1), + b => b + .Literal('"') + .EscapedTextDoubleChars('"') + .Literal('"') + .Pass(1) + ) .Transform(v => v.GetIntermediateValue()); builder.CreateToken("boolean") - .LiteralChoice("true", "false") - .Transform(v => new TemplateBooleanAccessor(v.GetIntermediateValue() == "true")); + .LiteralChoice(("true", TemplateBooleanAccessor.True), + ("false", TemplateBooleanAccessor.False)); builder.CreateToken("null") - .Literal("null") - .Transform(v => TemplateNullAccessor.Instance); + .Return(b => b.Literal("null"), TemplateNullAccessor.Instance); + + builder.CreateToken("literal") + .LiteralChoice(("true", TemplateBooleanAccessor.True), + ("false", TemplateBooleanAccessor.False), + ("null", TemplateNullAccessor.Instance)); // Constants // @@ -73,13 +100,12 @@ private static void DeclareValues(ParserBuilder builder) .Choice( c => c.Token("number"), c => c.Token("string"), - c => c.Token("boolean"), - c => c.Token("null"), + c => c.Token("literal"), c => c.Rule("constant_array"), c => c.Rule("constant_object")); builder.CreateRule("constant_pair") - .Token("identifier") + .Token("identifier_or_string") .Literal(":") .Rule("constant") .Transform(v => new KeyValuePair(v.GetValue(0), v.GetValue(2))); @@ -87,14 +113,12 @@ private static void DeclareValues(ParserBuilder builder) builder.CreateRule("constant_array") .Literal("[") .ZeroOrMoreSeparated(b => b.Rule("constant"), b => b.Literal(","), allowTrailingSeparator: true) - .ConfigureLast(c => c.Skip(b => b.Rule("skip"), ParserSkippingStrategy.SkipBeforeParsingGreedy)) .Literal("]") .Transform(v => new TemplateArrayAccessor(v.Children[1].SelectValues())); builder.CreateRule("constant_object") .Literal("{") .ZeroOrMoreSeparated(b => b.Rule("constant_pair"), b => b.Literal(","), allowTrailingSeparator: true) - .ConfigureLast(c => c.Skip(b => b.Rule("skip"), ParserSkippingStrategy.SkipBeforeParsingGreedy)) .Literal("}") .Transform(v => { @@ -104,6 +128,40 @@ private static void DeclareValues(ParserBuilder builder) // Expression values // + builder.CreateRule("pair_key") + .Choice( + b => b + .Literal('[') + .Rule("expression") + .Literal(']') + .TransformSelect(1), + b => b.Token("identifier_or_string") + .Transform(v => new TemplateStringAccessor(v.GetIntermediateValue())) + ) + .Transform(v => new TemplateDataAccessorExpressionNode(v.GetValue(0))); + + builder.CreateRule("pair") + .Rule("pair_key") + .Literal(":") + .Rule("expression") + .Transform(v => new KeyValuePair(v.GetValue(0), v.GetValue(2))); + + builder.CreateRule("array") + .Literal("[") + .ZeroOrMoreSeparated(b => b.Rule("expression"), b => b.Literal(","), allowTrailingSeparator: true) + .Literal("]") + .Transform(v => new TemplateArrayExpressionNode(v.Children[1].SelectValues())); + + builder.CreateRule("object") + .Literal("{") + .ZeroOrMoreSeparated(b => b.Rule("pair"), b => b.Literal(","), allowTrailingSeparator: true) + .Literal("}") + .Transform(v => + { + var pairs = v.Children[1].SelectValues>(); + return new TemplateObjectExpressionNode(pairs.ToDictionary(p => p.Key, p => p.Value)); + }); + builder.CreateRule("function_access") .Identifier() .Optional(b => b.Literal('?')) @@ -122,18 +180,25 @@ private static void DeclareValues(ParserBuilder builder) builder.CreateRule("context_access") .Choice( - b => b.Literal("ctx") + b => b + .Literal("ctx") .Transform(_ => new TemplateContextAccessExpressionNode()), - b => b.Optional(b => b.Literal('?')) - .Identifier() + b => b + .NegativeLookahead(b => b.Token("literal")) + .Optional(b => b.Literal('?')) + .Identifier() .Transform(v => new TemplatePropertyExpressionNode( - new TemplateContextAccessExpressionNode(), v[1].Text, v[0].Length > 0))); + new TemplateContextAccessExpressionNode(), v[2].Text, v[1].Length > 0))); builder.CreateRule("primary") .Choice( - c => c.Rule("constant"), + c => c.Rule("array"), + c => c.Rule("object"), c => c.Rule("function_access"), c => c.Rule("context_access"), + c => c.Token("number"), + c => c.Token("string"), + c => c.Token("literal"), c => c.Literal('(').Rule("expression").Literal(')').Transform(v => v.GetValue(1))) .Transform(v => { @@ -751,7 +816,12 @@ private static void DeclareMainRules(ParserBuilder builder) .Transform(v => v.Children[0].SelectArray()); } - static LLTParser() + /// + /// The parser that is used to parse the input. + /// + public Parser Parser { get; } + + public LLTParser() { var builder = new ParserBuilder(); @@ -760,7 +830,7 @@ static LLTParser() .Choice( c => c.Whitespaces(), c => c.Literal("@/").TextUntil('\n', '\r'), // @/ C#-like comments - c => c.Literal("@*").TextUntil("*@").Literal("*@")) // @*...*@ comments + c => c.Literal("@*").TextUntil("*@").Literal("*@")) // @*...*@ Razor-like comments .ConfigureForSkip(); // Ignore all errors when parsing comments and unnecessary whitespace // Settings // @@ -783,17 +853,24 @@ static LLTParser() // ---- Main rules ---- // DeclareMainRules(builder); - _parser = builder.Build(); + // Modify parser if inheritors want to add more rules or modify the existing ones + ModifyParser(builder); + + Parser = builder.Build(); + } + + protected virtual void ModifyParser(ParserBuilder builder) + { } - public IEnumerable Parse(string templateString, IEnumerable? metadataFactories = null) + public virtual IEnumerable Parse(string templateString, IEnumerable? metadataFactories = null) { var ctx = new LLTParsingContext { LocalLibrary = new TemplateLibrary(), MetadataFactories = metadataFactories?.ToList() ?? Enumerable.Empty() }; - return _parser.Parse(templateString, ctx).GetValue>(); + return Parser.Parse(templateString, ctx).GetValue>(); } } } \ No newline at end of file diff --git a/src/LLTSharp/LLTSharp.csproj b/src/LLTSharp/LLTSharp.csproj index a973d08..466d1d9 100644 --- a/src/LLTSharp/LLTSharp.csproj +++ b/src/LLTSharp/LLTSharp.csproj @@ -8,7 +8,7 @@ LLTSharp - 1.4.0 + 1.5.0 Roman K. RomeCore LLTSharp @@ -25,7 +25,7 @@ - + \ No newline at end of file diff --git a/src/LLTSharp/MessagesTemplate.cs b/src/LLTSharp/MessagesTemplate.cs index c592e59..3bced72 100644 --- a/src/LLTSharp/MessagesTemplate.cs +++ b/src/LLTSharp/MessagesTemplate.cs @@ -10,7 +10,10 @@ namespace LLTSharp /// public class MessagesTemplate : IMessagesTemplate { - private readonly MessagesTemplateNode _node; + /// + /// The main node of the prompt template. This is where the actual messages are defined. + /// + public MessagesTemplateNode MainNode { get; } public IMetadataCollection Metadata { get; } @@ -28,7 +31,7 @@ public class MessagesTemplate : IMessagesTemplate /// public MessagesTemplate(MessagesTemplateNode mainNode, IMetadataCollection metadata, TemplateLibrary localLibrary) { - _node = mainNode ?? throw new ArgumentNullException(nameof(mainNode)); + MainNode = mainNode ?? throw new ArgumentNullException(nameof(mainNode)); Metadata = metadata ?? throw new ArgumentNullException(nameof(metadata)); LocalLibrary = localLibrary ?? throw new ArgumentNullException(nameof(localLibrary)); } @@ -36,13 +39,13 @@ public MessagesTemplate(MessagesTemplateNode mainNode, IMetadataCollection metad public IEnumerable Render(object? context = null, TemplateFunctionSet? functions = null) { var ctx = new TemplateContextAccessor(TemplateDataAccessor.Create(context), Metadata, functions: functions, library: LocalLibrary); - return _node.Render(ctx); + return MainNode.Render(ctx); } object ITemplate.Render(object? context, TemplateFunctionSet? functions) { var ctx = new TemplateContextAccessor(TemplateDataAccessor.Create(context), Metadata, functions: functions, library: LocalLibrary); - return _node.Render(ctx); + return MainNode.Render(ctx); } } } diff --git a/src/LLTSharp/TemplateContextAccessor.cs b/src/LLTSharp/TemplateContextAccessor.cs index 6f7b931..7a9fc3f 100644 --- a/src/LLTSharp/TemplateContextAccessor.cs +++ b/src/LLTSharp/TemplateContextAccessor.cs @@ -185,8 +185,46 @@ public override TemplateDataAccessor Property(string key, bool safe) return Context.Property(key, safe); } + public override bool HasProperty(string name) + { + foreach (var frame in _frames) + { + var data = frame.TryGetVariable(name); + if (data != null) + return true; + } + + return Context.HasProperty(name); + } + + public override TemplateDataAccessor Index(TemplateDataAccessor index, bool safe) + { + return Context.Index(index, safe); + } + + public override TemplateDataAccessor Operator(TemplateDataAccessor other, BinaryOperatorType type) + { + return Context.Operator(other, type); + } + + public override TemplateDataAccessor Operator(UnaryOperatorType type) + { + return Context.Operator(type); + } + + public override string Type => Context.Type; + + public override int Length => Context.Length; + public override TemplateDataAccessor Call(string methodName, bool safe, TemplateDataAccessor[] arguments) { + if (safe) + { + var function = Functions.TryGetFunction(methodName); + if (function != null) + return function.Call(this, arguments); + return TemplateNullAccessor.Instance; + } return Functions.CallFunction(methodName, this, arguments); } diff --git a/src/LLTSharp/TemplateDataAccessor.cs b/src/LLTSharp/TemplateDataAccessor.cs index a45d341..7333d74 100644 --- a/src/LLTSharp/TemplateDataAccessor.cs +++ b/src/LLTSharp/TemplateDataAccessor.cs @@ -13,6 +13,11 @@ namespace LLTSharp /// public abstract class TemplateDataAccessor : IDisposable { + /// + /// Gets the type of the data. This can be used to determine how to handle the data during rendering. + /// + public virtual string Type => "unknown"; + /// /// Gets the length of the data if it is an array or a string. /// @@ -137,6 +142,30 @@ public virtual TemplateDataAccessor Operator(TemplateDataAccessor other, BinaryO /// The value of the current context. public abstract object GetValue(); + /// + /// Gets the value of the current context converted to a specific type. + /// + /// The type to convert the current context to. + /// The value of the current context converted to the specified type. If no data is found, returns the default value for the type. + /// Thrown when value conversion is failed. + public T GetValue() + { + var value = GetValue(); + if (value is T res1) + return res1; + if (value is null) + return default; + + try + { + return Convert.ChangeType(value, typeof(T)) is T res2 ? res2 : default; + } + catch (Exception ex) + { + throw new TemplateRuntimeException($"Cannot convert value '{value}' to type '{typeof(T)}'", ex, this); + } + } + /// /// Converts the template data to a string representation. /// diff --git a/src/LLTSharp/TemplateFunction.cs b/src/LLTSharp/TemplateFunction.cs index e46ee99..e4ac6ac 100644 --- a/src/LLTSharp/TemplateFunction.cs +++ b/src/LLTSharp/TemplateFunction.cs @@ -1,4 +1,6 @@ using System; +using System.IO; +using System.Linq; using LLTSharp.DataAccessors; namespace LLTSharp @@ -72,4 +74,5 @@ public TemplateDataAccessor Call(TemplateDataAccessor? self, TemplateDataAccesso parameters ?? throw new ArgumentNullException(nameof(parameters))); } } + } \ No newline at end of file diff --git a/src/LLTSharp/TemplateFunctionSet.cs b/src/LLTSharp/TemplateFunctionSet.cs index dcaf4fd..7d13c1c 100644 --- a/src/LLTSharp/TemplateFunctionSet.cs +++ b/src/LLTSharp/TemplateFunctionSet.cs @@ -12,7 +12,7 @@ namespace LLTSharp /// public class TemplateFunctionSet : IEnumerable { - private Dictionary _functions; + private readonly Dictionary _functions; /// /// Initializes a new instance of the class. @@ -21,7 +21,22 @@ public class TemplateFunctionSet : IEnumerable /// Thrown when the parameter is null. public TemplateFunctionSet(IEnumerable functions) { - _functions = functions?.ToDictionary(k => k.Name, v => v) ?? throw new ArgumentNullException(nameof(functions)); + _functions = functions?.GroupBy(f => f.Name).Select(f => f.Last()) + .ToDictionary(k => k.Name, v => v) ?? throw new ArgumentNullException(nameof(functions)); + } + + /// + /// Initializes a new instance of the class. + /// + /// If set to true, include default functions specified in . + /// A collection of template functions. Functions must have unique not- names. + /// Thrown when the parameter is null. + public TemplateFunctionSet(bool includeDefault, IEnumerable functions) + { + if (includeDefault && functions != null) + functions = TemplateFunctions.All.Concat(functions); + _functions = functions?.GroupBy(f => f.Name).Select(f => f.Last()) + .ToDictionary(k => k.Name, v => v) ?? throw new ArgumentNullException(nameof(functions)); } /// @@ -31,7 +46,23 @@ public TemplateFunctionSet(IEnumerable functions) /// Thrown when the parameter is null. public TemplateFunctionSet(params TemplateFunction[] functions) { - _functions = functions?.ToDictionary(k => k.Name, v => v) ?? throw new ArgumentNullException(nameof(functions)); + _functions = functions?.GroupBy(f => f.Name).Select(f => f.Last()) + .ToDictionary(k => k.Name, v => v) ?? throw new ArgumentNullException(nameof(functions)); + } + + /// + /// Initializes a new instance of the class. + /// + /// If set to true, include default functions specified in . + /// A collection of template functions. Functions must have unique not- names. + /// Thrown when the parameter is null. + public TemplateFunctionSet(bool includeDefault, params TemplateFunction[] functions) + { + var __functions = functions ?? Enumerable.Empty(); + if (includeDefault && functions != null) + __functions = TemplateFunctions.All.Concat(functions); + _functions = __functions?.GroupBy(f => f.Name).Select(f => f.Last()) + .ToDictionary(k => k.Name, v => v) ?? throw new ArgumentNullException(nameof(functions)); } /// @@ -106,15 +137,6 @@ IEnumerator IEnumerable.GetEnumerator() /// /// Gets the default set of functions. /// - public static TemplateFunctionSet Default { get; } - - static TemplateFunctionSet() - { - Default = new TemplateFunctionSet( - new TemplateFunction("length", (self, args) => self.Length), - new TemplateFunction("strcat", (self, args) => string.Join("", args.Select(a => a.GetValue().ToString()))), - new TemplateFunction("substr", (self, args) => args[0].GetValue().ToString().Substring((int)args[1].GetValue(), (int)args[2].GetValue())) - ); - } + public static TemplateFunctionSet Default { get; } = new TemplateFunctionSet(TemplateFunctions.All); } } \ No newline at end of file diff --git a/src/LLTSharp/TemplateFunctions.cs b/src/LLTSharp/TemplateFunctions.cs new file mode 100644 index 0000000..ec5418d --- /dev/null +++ b/src/LLTSharp/TemplateFunctions.cs @@ -0,0 +1,36 @@ +using System.Collections.Generic; +using System.Linq; +using LLTSharp.DataAccessors; + +namespace LLTSharp +{ + /// + /// Represents a collection of predefined template functions. + /// + public static class TemplateFunctions + { + /// + /// Returns a collection of all predefined template functions. + /// + public static IEnumerable All => new TemplateFunction[] + { + Type, + Length, + Strcat, + Substr + }; + + public static TemplateFunction Type { get; } = new TemplateFunction("type", + (self, args) => args[0].Type, canBeMethod: false); + + public static TemplateFunction Length { get; } = new TemplateFunction("length", + (self, args) => args[0].Length, canBeMethod: false); + + public static TemplateFunction Strcat { get; } = new TemplateFunction("strcat", + (self, args) => string.Join("", args.Select(a => a.GetValue().ToString())), canBeMethod: false); + + public static TemplateFunction Substr { get; } = new TemplateFunction("substr", + (self, args) => args[0].ToString().Substring(args[1].GetValue(), args[2].GetValue()), canBeMethod: false); + } + +} \ No newline at end of file diff --git a/src/LLTSharp/TemplateLibrary.cs b/src/LLTSharp/TemplateLibrary.cs index 416c318..9d826cd 100644 --- a/src/LLTSharp/TemplateLibrary.cs +++ b/src/LLTSharp/TemplateLibrary.cs @@ -448,6 +448,22 @@ private bool TryGetParser(string languageCode, out ITemplateParser parser) return false; } + /// + /// Parses the specified template contents into a collection of templates without importing to library. + /// + /// The contents of the template to parse. Cannot be . + /// The language code of the template contents. Defaults to "llt". + /// A collection of templates parsed from the specified contents. Cannot be . + /// Thrown when no parser is registered for the specified language code. + /// Thrown when the template contents cannot be parsed. + public IEnumerable ParseString(string templateContents, string languageCode = "llt") + { + if (!TryGetParser(languageCode, out var parser)) + throw new ArgumentException($"No parser registered for language: '{languageCode}'."); + + return parser.Parse(templateContents, MetadataFactories); + } + /// /// Imports a set of templates from the specified string contents. /// diff --git a/src/LLTSharp/TextTemplate.cs b/src/LLTSharp/TextTemplate.cs index f8ecee4..1a5cc34 100644 --- a/src/LLTSharp/TextTemplate.cs +++ b/src/LLTSharp/TextTemplate.cs @@ -12,7 +12,10 @@ namespace LLTSharp /// public class TextTemplate : ITextTemplate { - private readonly TextTemplateNode _node; + /// + /// The main node of the text template. + /// + public TextTemplateNode MainNode { get; } public IMetadataCollection Metadata { get; } @@ -30,7 +33,7 @@ public class TextTemplate : ITextTemplate /// public TextTemplate(TextTemplateNode mainNode, IMetadataCollection metadata, TemplateLibrary localLibrary) { - _node = mainNode ?? throw new ArgumentNullException(nameof(mainNode)); + MainNode = mainNode ?? throw new ArgumentNullException(nameof(mainNode)); Metadata = metadata ?? throw new ArgumentNullException(nameof(metadata)); LocalLibrary = localLibrary ?? throw new ArgumentNullException(nameof(localLibrary)); } @@ -38,13 +41,13 @@ public TextTemplate(TextTemplateNode mainNode, IMetadataCollection metadata, Tem public string Render(object? context = null, TemplateFunctionSet? functions = null) { var ctx = new TemplateContextAccessor(TemplateDataAccessor.Create(context), Metadata, functions: functions, library: LocalLibrary); - return _node.Render(ctx); + return MainNode.Render(ctx); } object ITemplate.Render(object? context, TemplateFunctionSet? functions) { var ctx = new TemplateContextAccessor(TemplateDataAccessor.Create(context), Metadata, functions: functions, library: LocalLibrary); - return _node.Render(ctx); + return MainNode.Render(ctx); } } } \ No newline at end of file diff --git a/tests/LLTSharp.Tests/TemplateRenderingTests.cs b/tests/LLTSharp.Tests/TemplateRenderingTests.cs index e7373e2..8e6f67e 100644 --- a/tests/LLTSharp.Tests/TemplateRenderingTests.cs +++ b/tests/LLTSharp.Tests/TemplateRenderingTests.cs @@ -157,6 +157,88 @@ @foreach item in items { Assert.Contains("item", ex.Message); } + [Fact] + public void NullSafetyRendering() + { + var template = new LLTParser().Parse( + """ + @template t { + 1: @(?value ?? 'No value') + 2: @(ctx ?: 'value' ? value ?? 'Null' : 'No value') + } + """).First() as ITextTemplate ?? throw new InvalidOperationException("The parsed template is not an instance of ITextTemplate."); + + var result1 = template.Render(new { value = "Hello" }); + var result2 = template.Render(new { value = (string?)null }); + var result3 = template.Render(new { }); + + // Value is present + Assert.Equal( + """ + 1: Hello + 2: Hello + """, + result1); + + // In case 1 there is coalescing works, in case 2 we have a property, but it's null. + Assert.Equal( + """ + 1: No value + 2: Null + """, + result2); + + // Property is not present at all + Assert.Equal( + """ + 1: No value + 2: No value + """, + result3); + } + + [Fact] + public void DynamicArraysRendering() + { + var template = new LLTParser().Parse( + """ + @template t { + @let arr = [ ctx, ctx?.value, ctx ?: 'value' ] + @foreach item in arr { + - @type(item) + } + } + """).First() as ITextTemplate ?? throw new InvalidOperationException("The parsed template is not an instance of ITextTemplate."); + + var result1 = template.Render(new { value = "Hello" }); + var result2 = template.Render(new { value = (string?)null }); + var result3 = template.Render(new { }); + + Assert.Equal( + """ + - object + - string + - boolean + """, + result1); + + Assert.Equal( + """ + - object + - null + - boolean + """, + result2); + + Assert.Equal( + """ + - object + - null + - boolean + """, + result3); + } + [Fact] public void MessagesTemplateRendering() { From 48cad7c0f466ff9b5a7ad81f3729a677003eac5a Mon Sep 17 00:00:00 2001 From: Roman Kondrat'ev <62770895+RomeCore@users.noreply.github.com> Date: Sun, 21 Jun 2026 20:28:00 +0500 Subject: [PATCH 2/2] Update BasicTemplateParsingTests.cs --- tests/LLTSharp.Tests/BasicTemplateParsingTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/LLTSharp.Tests/BasicTemplateParsingTests.cs b/tests/LLTSharp.Tests/BasicTemplateParsingTests.cs index fc90775..a119311 100644 --- a/tests/LLTSharp.Tests/BasicTemplateParsingTests.cs +++ b/tests/LLTSharp.Tests/BasicTemplateParsingTests.cs @@ -21,7 +21,7 @@ public BasicTemplateParsingTests(ITestOutputHelper output) [Fact] public void ExpressionsASTPasing() { - var parser = LLTParser.Parser; + var parser = new LLTParser().Parser; var value = parser.ParseRule("expression", "1 + 2 * 3 - 10").GetValue(); Assert.Equal("((1 + (2 * 3)) - 10)", value.ToString());