diff --git a/bindings/csharp/Expressif.Syntax.Tests/Expressif.Syntax.Tests.csproj b/bindings/csharp/Expressif.Syntax.Tests/Expressif.Syntax.Tests.csproj
index 7a42775..35e756e 100644
--- a/bindings/csharp/Expressif.Syntax.Tests/Expressif.Syntax.Tests.csproj
+++ b/bindings/csharp/Expressif.Syntax.Tests/Expressif.Syntax.Tests.csproj
@@ -24,6 +24,7 @@
+
diff --git a/bindings/csharp/Expressif.Syntax.Tests/SyntaxBindingTests.cs b/bindings/csharp/Expressif.Syntax.Tests/SyntaxBindingTests.cs
index b9397d6..fd1ad09 100644
--- a/bindings/csharp/Expressif.Syntax.Tests/SyntaxBindingTests.cs
+++ b/bindings/csharp/Expressif.Syntax.Tests/SyntaxBindingTests.cs
@@ -1,5 +1,7 @@
namespace Expressif.Syntax.Tests;
+using System.Text.Json;
+
public class SyntaxBindingTests
{
[TestCase("lower", typeof(OpenExpressionSyntax))]
@@ -111,6 +113,70 @@ public void PositionalElementAccessCanBeAnArgumentAndPipelineSource()
});
}
+ [TestCase(".name", false, "name", null)]
+ [TestCase(".10", false, null, 10)]
+ [TestCase("^.name", true, "name", null)]
+ [TestCase("^.10", true, null, 10)]
+ public void RecordAccessExposesRootAndField(string source, bool original, string? name, int? index)
+ {
+ var root = (ClosedExpressionSyntax)ExpressifSyntax.Parse(source);
+ var access = (RecordAccessSyntax)root.Value;
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(access.IsOriginalInput, Is.EqualTo(original));
+ Assert.That(access.Fields.Single().Name, Is.EqualTo(name));
+ Assert.That(access.Fields.Single().Index, Is.EqualTo(index));
+ });
+ }
+
+ [Test]
+ public void NestedRecordAccessPreservesEveryField()
+ {
+ var access = (RecordAccessSyntax)((ClosedExpressionSyntax)ExpressifSyntax.Parse("^.customer.0")).Value;
+
+ Assert.That(access.Fields, Is.EqualTo(new[]
+ {
+ new RecordFieldSelector("customer", null),
+ new RecordFieldSelector(null, 0),
+ }));
+ }
+
+ [TestCase("@value", typeof(VariableSyntax))]
+ [TestCase("{}", typeof(ArrayLiteralSyntax))]
+ [TestCase("T(1, 2)", typeof(TupleLiteralSyntax))]
+ [TestCase("{:}", typeof(RecordLiteralSyntax))]
+ public void RemainingGrammarValuesHaveManagedSyntaxNodes(string source, Type expected)
+ => Assert.That(((ClosedExpressionSyntax)ExpressifSyntax.Parse(source)).Value, Is.TypeOf(expected));
+
+ [Test]
+ public void CompoundValuesBindNestedValuesAndRecordFields()
+ {
+ var record = (RecordLiteralSyntax)((ClosedExpressionSyntax)ExpressifSyntax.Parse(
+ "{name := @value, `scores` := {1, ^.total}}")).Value;
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(record.Fields.Select(field => field.Name), Is.EqualTo(new[] { "name", "scores" }));
+ Assert.That(record.Fields[1].QuotingStyle, Is.EqualTo(QuotingStyle.Backtick));
+ Assert.That(record.Fields[0].Value, Is.TypeOf());
+ Assert.That(((ArrayLiteralSyntax)record.Fields[1].Value).Values[1], Is.TypeOf());
+ Assert.That(record.Children, Is.EqualTo(record.Fields));
+ });
+ }
+
+ [Test]
+ public void BinderSupportsEveryGrammarValueNodeType()
+ {
+ using var document = JsonDocument.Parse(File.ReadAllText(Path.Combine(TestContext.CurrentContext.TestDirectory, "node-types.json")));
+ var value = document.RootElement.EnumerateArray().Single(node => node.GetProperty("type").GetString() == "value");
+ var grammarTypes = value.GetProperty("subtypes").EnumerateArray()
+ .Select(node => node.GetProperty("type").GetString()!)
+ .ToHashSet();
+
+ Assert.That(ExpressifSyntax.SupportedValueNodeTypes, Is.EquivalentTo(grammarTypes));
+ }
+
[Test]
public void SourceTextAndRangesAreLossless()
{
diff --git a/bindings/csharp/Expressif.Syntax/ExpressifSyntax.cs b/bindings/csharp/Expressif.Syntax/ExpressifSyntax.cs
index 888b020..fd5f849 100644
--- a/bindings/csharp/Expressif.Syntax/ExpressifSyntax.cs
+++ b/bindings/csharp/Expressif.Syntax/ExpressifSyntax.cs
@@ -5,6 +5,12 @@ namespace Expressif.Syntax;
public static class ExpressifSyntax
{
+ internal static IReadOnlySet SupportedValueNodeTypes { get; } = new HashSet
+ {
+ "array_literal", "boolean_literal", "numeric_literal", "positional_element_access",
+ "quoted_literal", "record_access", "record_literal", "temporal_literal", "tuple_literal", "variable",
+ };
+
public static RootExpressionSyntax Parse(string source)
{
ArgumentNullException.ThrowIfNull(source);
@@ -77,6 +83,8 @@ private static PositionalArgumentSyntax BindArgument(TsNode node)
private static ValueSyntax BindValue(TsNode node) => node.Type switch
{
+ "variable" => new VariableSyntax(Span(node), node.Text),
+ "record_access" => BindRecordAccess(node),
"positional_element_access" => new PositionalElementAccessSyntax(Span(node), node.Text),
"numeric_literal" => new NumericLiteralSyntax(Span(node), node.Text),
"boolean_literal" => new BooleanLiteralSyntax(Span(node), node.Text),
@@ -85,10 +93,44 @@ private static PositionalArgumentSyntax BindArgument(TsNode node)
"date_literal" => new DateLiteralSyntax(Span(node), node.Text),
"date_time_literal" => new DateTimeLiteralSyntax(Span(node), node.Text),
"time_literal" => new TimeLiteralSyntax(Span(node), node.Text),
+ "array_literal" => new ArrayLiteralSyntax(Span(node), node.Text, node.NamedChildren.Select(BindValue).ToArray()),
+ "tuple_literal" => new TupleLiteralSyntax(Span(node), node.Text, node.NamedChildren.Select(BindValue).ToArray()),
+ "record_literal" => new RecordLiteralSyntax(Span(node), node.Text, node.NamedChildren.Select(BindRecordField).ToArray()),
"value" or "quoted_literal" or "temporal_literal" => BindValue(SingleNamedChild(node, node.Type)),
_ => throw Unknown(node),
};
+ private static RecordAccessSyntax BindRecordAccess(TsNode node)
+ {
+ var fields = node.NamedChildren
+ .Where(child => child.Type != "original_input")
+ .Select(selector => SingleNamedChild(selector, selector.Type))
+ .Select(field => field.Type switch
+ {
+ "named_record_field" => new RecordFieldSelector(field.Text.TrimStart('.'), null),
+ "positional_record_field" => new RecordFieldSelector(null,
+ int.Parse(field.Text.AsSpan(field.Text[0] == '.' ? 1 : 0), System.Globalization.CultureInfo.InvariantCulture)),
+ _ => throw Unknown(field),
+ });
+ return new(Span(node), node.Text, node.NamedChildren.Any(child => child.Type == "original_input"), fields);
+ }
+
+ private static RecordFieldSyntax BindRecordField(TsNode node)
+ {
+ var nameContainer = node.GetChildForField("name") ?? throw Unknown(node);
+ var name = SingleNamedChild(nameContainer, nameContainer.Type);
+ var value = node.GetChildForField("value") ?? throw Unknown(node);
+ QuotingStyle? quotingStyle = name.Type switch
+ {
+ "double_quoted_literal" => QuotingStyle.DoubleQuote,
+ "backtick_quoted_literal" => QuotingStyle.Backtick,
+ "unquoted_record_field_name" => null,
+ _ => throw Unknown(name),
+ };
+ var nameText = quotingStyle is null ? name.Text : name.Text[1..^1];
+ return new(Span(node), node.Text, nameText, quotingStyle, BindValue(value));
+ }
+
private static TsNode SingleNamedChild(TsNode node, string container)
{
var children = node.NamedChildren.ToArray();
diff --git a/bindings/csharp/Expressif.Syntax/SyntaxNodes.cs b/bindings/csharp/Expressif.Syntax/SyntaxNodes.cs
index d63c87f..36dfafb 100644
--- a/bindings/csharp/Expressif.Syntax/SyntaxNodes.cs
+++ b/bindings/csharp/Expressif.Syntax/SyntaxNodes.cs
@@ -15,6 +15,12 @@ public enum SyntaxKind
DateTimeLiteral,
TimeLiteral,
PositionalElementAccess,
+ Variable,
+ RecordAccess,
+ ArrayLiteral,
+ TupleLiteral,
+ RecordLiteral,
+ RecordField,
}
public readonly record struct SourceSpan(int Start, int Length)
@@ -105,8 +111,80 @@ internal PositionalArgumentSyntax(SourceSpan span, string text, ValueSyntax valu
public abstract class ValueSyntax : SyntaxNode
{
- protected ValueSyntax(SyntaxKind kind, SourceSpan span, string text)
- : base(kind, span, text) { }
+ protected ValueSyntax(SyntaxKind kind, SourceSpan span, string text, IEnumerable? children = null)
+ : base(kind, span, text, children) { }
+}
+
+public sealed class VariableSyntax : ValueSyntax
+{
+ internal VariableSyntax(SourceSpan span, string text) : base(SyntaxKind.Variable, span, text)
+ => Name = text[1..];
+
+ public string Name { get; }
+}
+
+public readonly record struct RecordFieldSelector(string? Name, int? Index)
+{
+ public bool IsNamed => Name is not null;
+ public bool IsPositional => Index is not null;
+}
+
+public sealed class RecordAccessSyntax : ValueSyntax
+{
+ internal RecordAccessSyntax(SourceSpan span, string text, bool isOriginalInput, IEnumerable fields)
+ : base(SyntaxKind.RecordAccess, span, text)
+ {
+ IsOriginalInput = isOriginalInput;
+ Fields = Array.AsReadOnly(fields.ToArray());
+ }
+
+ public bool IsOriginalInput { get; }
+ public IReadOnlyList Fields { get; }
+}
+
+public abstract class SequenceLiteralSyntax : ValueSyntax
+{
+ protected SequenceLiteralSyntax(SyntaxKind kind, SourceSpan span, string text, IEnumerable values)
+ : base(kind, span, text, values)
+ => Values = Array.AsReadOnly(values.ToArray());
+
+ public IReadOnlyList Values { get; }
+}
+
+public sealed class ArrayLiteralSyntax : SequenceLiteralSyntax
+{
+ internal ArrayLiteralSyntax(SourceSpan span, string text, IEnumerable values)
+ : base(SyntaxKind.ArrayLiteral, span, text, values) { }
+}
+
+public sealed class TupleLiteralSyntax : SequenceLiteralSyntax
+{
+ internal TupleLiteralSyntax(SourceSpan span, string text, IEnumerable values)
+ : base(SyntaxKind.TupleLiteral, span, text, values) { }
+}
+
+public sealed class RecordLiteralSyntax : ValueSyntax
+{
+ internal RecordLiteralSyntax(SourceSpan span, string text, IEnumerable fields)
+ : base(SyntaxKind.RecordLiteral, span, text, fields)
+ => Fields = Array.AsReadOnly(fields.ToArray());
+
+ public IReadOnlyList Fields { get; }
+}
+
+public sealed class RecordFieldSyntax : SyntaxNode
+{
+ internal RecordFieldSyntax(SourceSpan span, string text, string name, QuotingStyle? quotingStyle, ValueSyntax value)
+ : base(SyntaxKind.RecordField, span, text, [value])
+ {
+ Name = name;
+ QuotingStyle = quotingStyle;
+ Value = value;
+ }
+
+ public string Name { get; }
+ public QuotingStyle? QuotingStyle { get; }
+ public ValueSyntax Value { get; }
}
public sealed class PositionalElementAccessSyntax : ValueSyntax