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