Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions bindings/csharp/Expressif.Syntax.Tests/SyntaxBindingTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,32 @@ public void BooleanLiteralsExposeTypedValue(string source, bool expected)
Assert.That(((BooleanLiteralSyntax)root.Value).Value, Is.EqualTo(expected));
}

[Test]
public void NullLiteralExposesNullSemanticValueAndPreservesText()
{
var literal = (NullLiteralSyntax)((ClosedExpressionSyntax)ExpressifSyntax.Parse("#null")).Value;

Assert.Multiple(() =>
{
Assert.That(literal.Kind, Is.EqualTo(SyntaxKind.NullLiteral));
Assert.That(literal.Value, Is.Null);
Assert.That(literal.Text, Is.EqualTo("#null"));
});
}

[Test]
public void NullLiteralComposesAsAnArgumentAndCollectionValue()
{
var call = (FunctionCallSyntax)((OpenExpressionSyntax)ExpressifSyntax.Parse("coalesce(#null)")).Pipeline.Single();
var array = (ArrayLiteralSyntax)((ClosedExpressionSyntax)ExpressifSyntax.Parse("{#null, #true}")).Value;

Assert.Multiple(() =>
{
Assert.That(call.Arguments.Single().Value, Is.TypeOf<NullLiteralSyntax>());
Assert.That(array.Values[0], Is.TypeOf<NullLiteralSyntax>());
});
}

[TestCase("true")]
[TestCase("false")]
public void BareBooleanWordsRemainFunctionCalls(string source)
Expand Down Expand Up @@ -613,6 +639,65 @@ public void MapShorthandAcceptsAParenthesizedOperation()
Assert.That(shorthand.Expression.Pipeline.Single(), Is.TypeOf<ParenthesizedExpressionSyntax>());
}

[TestCase("foo(5,)", "foo(5)")]
[TestCase("record(name := \"Alice\",)", "record(name := \"Alice\")")]
[TestCase("record(name := \"Alice\", age := 30,)", "record(name := \"Alice\", age := 30)")]
public void FunctionCallsAcceptATrailingComma(string source, string equivalentSource)
{
var withComma = (FunctionCallSyntax)((OpenExpressionSyntax)ExpressifSyntax.Parse(source)).Pipeline.Single();
var withoutComma = (FunctionCallSyntax)((OpenExpressionSyntax)ExpressifSyntax.Parse(equivalentSource)).Pipeline.Single();

Assert.Multiple(() =>
{
Assert.That(withComma.Arguments.Select(argument => argument.Kind),
Is.EqualTo(withoutComma.Arguments.Select(argument => argument.Kind)));
Assert.That(withComma.Arguments.OfType<NamedArgumentSyntax>().Select(argument => argument.Name),
Is.EqualTo(withoutComma.Arguments.OfType<NamedArgumentSyntax>().Select(argument => argument.Name)));
});
}

[Test]
public void RecordFieldShorthandCanContinueAPipeline()
{
var root = (ClosedExpressionSyntax)ExpressifSyntax.Parse(".address | .city | .name");

Assert.Multiple(() =>
{
Assert.That(((RecordAccessSyntax)root.Value).Fields.Single().Name, Is.EqualTo("address"));
Assert.That(root.Pipeline.Cast<RecordAccessSyntax>()
.Select(access => access.Fields.Single().Name), Is.EqualTo(new[] { "city", "name" }));
});
}

[Test]
public void RecordFieldShorthandAcceptsUnderscores()
{
var root = (ClosedExpressionSyntax)ExpressifSyntax.Parse(".first_name._display_name");
var access = (RecordAccessSyntax)root.Value;

Assert.That(access.Fields.Select(field => field.Name),
Is.EqualTo(new[] { "first_name", "_display_name" }));
}

[Test]
public void RecordFieldShorthandRejectsOperators()
=> Assert.Throws<ExpressifSyntaxException>(() => ExpressifSyntax.Parse(".+"));

[Test]
public void LeadingMapShorthandPreservesOuterPipelineBoundary()
{
var root = (OpenExpressionSyntax)ExpressifSyntax.Parse("|> add(1) | sum");
var shorthand = (MapShorthandSyntax)root.Pipeline[0];

Assert.Multiple(() =>
{
Assert.That(shorthand.Expression.Pipeline.Cast<FunctionCallSyntax>().Select(call => call.Name),
Is.EqualTo(new[] { "add" }));
Assert.That(root.Pipeline[1],
Is.TypeOf<FunctionCallSyntax>().With.Property(nameof(FunctionCallSyntax.Name)).EqualTo("sum"));
});
}

[Test]
public void ParenthesizedMapShorthandCannotFollowAnOrdinaryPipe()
=> Assert.That(
Expand Down Expand Up @@ -722,6 +807,8 @@ public void BareDateLookingIntervalBoundsAreRejected()
[TestCase("foo({| lower})", false)]
[TestCase("append(.firstName |)", false)]
[TestCase("foo(name :=)", false)]
[TestCase("foo(, 5)", false)]
[TestCase("foo(5,,6)", false)]
[TestCase("....", false)]
[TestCase("{ ..., }", false)]
public void MalformedInputExposesTreeSitterErrors(string source, bool hasMissingError)
Expand Down
3 changes: 2 additions & 1 deletion bindings/csharp/Expressif.Syntax/ExpressifSyntax.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ public static class ExpressifSyntax
{
internal static IReadOnlySet<string> SupportedValueNodeTypes { get; } = new HashSet<string>
{
"array_literal", "boolean_literal", "incoming_value", "numeric_literal",
"array_literal", "boolean_literal", "incoming_value", "null_literal", "numeric_literal",
"interval_literal", "quoted_literal", "record_access", "record_literal", "temporal_literal", "tuple_literal", "variable",
};

Expand Down Expand Up @@ -165,6 +165,7 @@ private static TupleProjectionSyntax BindTupleProjection(TsNode node)
"record_access" => BindRecordAccess(node),
"numeric_literal" => new NumericLiteralSyntax(Span(node), node.Text),
"boolean_literal" => new BooleanLiteralSyntax(Span(node), node.Text),
"null_literal" => new NullLiteralSyntax(Span(node), node.Text),
"double_quoted_literal" => new QuotedLiteralSyntax(Span(node), node.Text, QuotingStyle.DoubleQuote),
"backtick_quoted_literal" => new QuotedLiteralSyntax(Span(node), node.Text, QuotingStyle.Backtick),
"date_literal" => new DateLiteralSyntax(Span(node), node.Text),
Expand Down
9 changes: 9 additions & 0 deletions bindings/csharp/Expressif.Syntax/SyntaxNodes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ public enum SyntaxKind
NamedArgument,
NumericLiteral,
BooleanLiteral,
NullLiteral,
QuotedLiteral,
DateLiteral,
DateTimeLiteral,
Expand Down Expand Up @@ -304,6 +305,14 @@ internal BooleanLiteralSyntax(SourceSpan span, string text)
public bool Value { get; }
}

public sealed class NullLiteralSyntax : ValueSyntax
{
internal NullLiteralSyntax(SourceSpan span, string text)
: base(SyntaxKind.NullLiteral, span, text) { }

public object? Value => null;
}

public enum QuotingStyle { DoubleQuote, Backtick }

public sealed class QuotedLiteralSyntax : ValueSyntax
Expand Down
33 changes: 25 additions & 8 deletions grammar.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export default grammar({

map_shorthand: ($) => seq(
"|>",
field("expression", $.open_expression),
field("expression", alias($.expression, $.open_expression)),
),

// In a closed-expression pipeline, the next ordinary `|` belongs to the
Expand All @@ -72,6 +72,7 @@ export default grammar({

_pipeline_expression: ($) => choice(
$.function_call,
prec(1, $.record_access),
$.tuple_projection,
alias($._parenthesized_pipeline_expression, $.parenthesized_expression),
),
Expand Down Expand Up @@ -114,15 +115,28 @@ export default grammar({

function_call: ($) => seq(
field("name", $.function_name),
optional(seq("(", optional($.argument_list), ")")),
optional(seq(
"(",
optional(choice(
$.argument_list,
alias($._trailing_argument_list, $.argument_list),
)),
")",
)),
),

function_name: (_) => /[A-Za-z]+(?:-[A-Za-z]+)*/,

argument_list: ($) => seq(
argument_list: ($) => prec.left(seq(
choice($.positional_argument, $.named_argument),
repeat(seq(",", choice($.positional_argument, $.named_argument))),
),
)),

_trailing_argument_list: ($) => prec.right(seq(
choice($.positional_argument, $.named_argument),
repeat(seq(",", choice($.positional_argument, $.named_argument))),
",",
)),

positional_argument: ($) => $._argument_value,

Expand Down Expand Up @@ -173,6 +187,7 @@ export default grammar({
$.record_access,
$.numeric_literal,
$.boolean_literal,
$.null_literal,
$.quoted_literal,
$.temporal_literal,
$.array_literal,
Expand Down Expand Up @@ -254,7 +269,7 @@ export default grammar({
$.backtick_quoted_literal,
),

unquoted_record_field_name: (_) => /[A-Za-z][A-Za-z0-9]*(?:-[A-Za-z0-9]+)*/,
unquoted_record_field_name: (_) => /[A-Za-z_][A-Za-z0-9_]*(?:-[A-Za-z0-9_]+)*/,

numeric_literal: (_) => /-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?/,

Expand All @@ -279,21 +294,23 @@ export default grammar({
),

immediate_record_field_selector: ($) => choice(
alias(token.immediate(/\.[A-Za-z][A-Za-z0-9]*(?:-[A-Za-z0-9]+)*/), $.named_record_field),
alias(token.immediate(/\.[A-Za-z_][A-Za-z0-9_]*(?:-[A-Za-z0-9_]+)*/), $.named_record_field),
alias(token.immediate(/\.(?:0|[1-9][0-9]*)/), $.positional_record_field),
),

original_record_field_selector: ($) => choice(
alias(token.immediate(prec(-1, /[A-Za-z][A-Za-z0-9]*(?:-[A-Za-z0-9]+)*/)), $.named_record_field),
alias(token.immediate(prec(-1, /[A-Za-z_][A-Za-z0-9_]*(?:-[A-Za-z0-9_]+)*/)), $.named_record_field),
alias(token.immediate(prec(-1, /(?:0|[1-9][0-9]*)/)), $.positional_record_field),
),

named_record_field: (_) => /\.[A-Za-z][A-Za-z0-9]*(?:-[A-Za-z0-9]+)*/,
named_record_field: (_) => /\.[A-Za-z_][A-Za-z0-9_]*(?:-[A-Za-z0-9_]+)*/,

positional_record_field: (_) => /\.(?:0|[1-9][0-9]*)/,

boolean_literal: (_) => choice("#true", "#false"),

null_literal: (_) => "#null",

quoted_literal: ($) => choice(
$.double_quoted_literal,
$.backtick_quoted_literal,
Expand Down
Loading
Loading