diff --git a/src/Microsoft.DotNet.Interactive.Http.Parsing/Parsing/HttpEscapedCharacterSequenceNode.cs b/src/Microsoft.DotNet.Interactive.Http.Parsing/Parsing/HttpEscapedCharacterSequenceNode.cs new file mode 100644 index 0000000000..c00079c5a9 --- /dev/null +++ b/src/Microsoft.DotNet.Interactive.Http.Parsing/Parsing/HttpEscapedCharacterSequenceNode.cs @@ -0,0 +1,17 @@ +#nullable enable + +using Microsoft.CodeAnalysis.Text; +using System; +using System.Collections.Generic; +using System.Text; + +namespace Microsoft.DotNet.Interactive.Http.Parsing; +internal class HttpEscapedCharacterSequenceNode : HttpSyntaxNode +{ + public HttpEscapedCharacterSequenceNode(SourceText sourceText, HttpSyntaxTree syntaxTree) : base(sourceText, syntaxTree) + { + } + + public string UnescapedText => Text.TrimStart('\\'); + +} diff --git a/src/Microsoft.DotNet.Interactive.Http.Parsing/Parsing/HttpRequestParser.cs b/src/Microsoft.DotNet.Interactive.Http.Parsing/Parsing/HttpRequestParser.cs index 1a0c316667..8b4aa8e50b 100644 --- a/src/Microsoft.DotNet.Interactive.Http.Parsing/Parsing/HttpRequestParser.cs +++ b/src/Microsoft.DotNet.Interactive.Http.Parsing/Parsing/HttpRequestParser.cs @@ -138,13 +138,19 @@ private static void AddCommentsIfAny( if (node is null) { if (CurrentToken is - { Kind: TokenKind.Word } or - { Kind: TokenKind.Punctuation } and ({ Text: "/" } or { Text: "'" } or { Text: "\"" })) + { Kind: TokenKind.Word } or + { Kind: TokenKind.Punctuation } and ({ Text: "/" } or { Text: "'" } or { Text: "\"" })) { node = new HttpVariableValueNode(_sourceText, _syntaxTree); ParseLeadingWhitespaceAndComments(node); } + else if (IsStartOfEscapeSequence()) + { + node = new HttpVariableValueNode(_sourceText, _syntaxTree); + var escapedSequence = ParseEscapeSequence(); + node.Add(escapedSequence); + } else if (IsAtStartOfEmbeddedExpression()) { node = new HttpVariableValueNode(_sourceText, _syntaxTree); @@ -164,6 +170,10 @@ private static void AddCommentsIfAny( { node.Add(ParseEmbeddedExpression()); } + else if (IsStartOfEscapeSequence()) + { + node.Add(ParseEscapeSequence()); + } else { ConsumeCurrentTokenInto(node); @@ -541,6 +551,11 @@ private bool IsAtStartOfEmbeddedExpression() => CurrentToken is { Text: "{" } && CurrentTokenPlus(1) is { Text: "{" }; + private bool IsStartOfEscapeSequence() => + CurrentToken is { Kind: TokenKind.Punctuation } and { Text: @"\" } && + (CurrentTokenPlus(1) is { Kind: TokenKind.Punctuation } and { Text: "{" } && + CurrentTokenPlus(2) is { Kind: TokenKind.Punctuation } and { Text: "{" }); + private HttpEmbeddedExpressionNode ParseEmbeddedExpression() { var node = new HttpEmbeddedExpressionNode(_sourceText, _syntaxTree); @@ -889,6 +904,17 @@ private HttpCommentStartNode ParseCommentStart() return node; } + private HttpEscapedCharacterSequenceNode ParseEscapeSequence() + { + var node = new HttpEscapedCharacterSequenceNode(_sourceText, _syntaxTree); + + ConsumeCurrentTokenInto(node); // parse the first \ + ConsumeCurrentTokenInto(node); // parse the first { or } + ConsumeCurrentTokenInto(node); // parse the second { or } + + return ParseTrailingWhitespace(node); + } + private bool IsComment() { if (MoreTokens() && !IsRequestSeparator()) diff --git a/src/Microsoft.DotNet.Interactive.Http.Parsing/Parsing/HttpRootSyntaxNode.cs b/src/Microsoft.DotNet.Interactive.Http.Parsing/Parsing/HttpRootSyntaxNode.cs index 761acfcd89..c69f3ff32d 100644 --- a/src/Microsoft.DotNet.Interactive.Http.Parsing/Parsing/HttpRootSyntaxNode.cs +++ b/src/Microsoft.DotNet.Interactive.Http.Parsing/Parsing/HttpRootSyntaxNode.cs @@ -8,6 +8,7 @@ using System.Collections.Generic; using System.Linq; using Microsoft.DotNet.Interactive.Parsing; +using System.Text; namespace Microsoft.DotNet.Interactive.Http.Parsing; @@ -53,51 +54,71 @@ public void Add(HttpRequestSeparatorNode separatorNode) if (node.ValueNode is not null && node.DeclarationNode is not null) { var embeddedExpressionNodes = node.ValueNode.ChildNodes.OfType(); - if (!embeddedExpressionNodes.Any()) + var potentialEscapedCharacters = node.ValueNode.ChildNodes.OfType(); + if (potentialEscapedCharacters.Any()) { - foundVariableValues[node.DeclarationNode.VariableName] = node.ValueNode.Text; - declaredVariables[node.DeclarationNode.VariableName] = new DeclaredVariable(node.DeclarationNode.VariableName, node.ValueNode.Text, HttpBindingResult.Success(Text)); - } - else - { - var value = node.ValueNode.TryGetValue(node => + StringBuilder sb = new StringBuilder(); + foreach (var child in node.ValueNode.ChildNodesAndTokens) { - if (foundVariableValues.TryGetValue(node.Text, out string? stringValue)) + if (child is HttpEscapedCharacterSequenceNode sequenceNode) { - return node.CreateBindingSuccess(stringValue); - } - else if (bind != null) - { - return bind(node); + sb.Append(sequenceNode.UnescapedText); } else { - return DynamicExpressionUtilities.ResolveExpressionBinding(node, node.Text); + sb.Append(child.Text); } + } + + var value = sb.ToString(); + foundVariableValues[node.DeclarationNode.VariableName] = value; + declaredVariables[node.DeclarationNode.VariableName] = new DeclaredVariable(node.DeclarationNode.VariableName, value, HttpBindingResult.Success(Text)); + } + else if (!embeddedExpressionNodes.Any()) + { + foundVariableValues[node.DeclarationNode.VariableName] = node.ValueNode.Text; + declaredVariables[node.DeclarationNode.VariableName] = new DeclaredVariable(node.DeclarationNode.VariableName, node.ValueNode.Text, HttpBindingResult.Success(Text)); + } + else + { + var value = node.ValueNode.TryGetValue(node => + { + if (foundVariableValues.TryGetValue(node.Text, out string? stringValue)) + { + return node.CreateBindingSuccess(stringValue); + } + else if (bind != null) + { + return bind(node); + } + else + { + return DynamicExpressionUtilities.ResolveExpressionBinding(node, node.Text); + } - }); + }); - if (value?.Value != null) + if (value?.Value != null) + { + declaredVariables[node.DeclarationNode.VariableName] = new DeclaredVariable(node.DeclarationNode.VariableName, value.Value, value); + } + else + { + if(diagnostics is null) { - declaredVariables[node.DeclarationNode.VariableName] = new DeclaredVariable(node.DeclarationNode.VariableName, value.Value, value); - } - else + diagnostics = value?.Diagnostics; + } + else { - if(diagnostics is null) - { - diagnostics = value?.Diagnostics; - } - else + if (value is not null) { - if (value is not null) - { - diagnostics.AddRange(value.Diagnostics); - } - - } + diagnostics.AddRange(value.Diagnostics); + } + } } } + } } return (declaredVariables, diagnostics); diff --git a/src/Microsoft.DotNet.Interactive.Http.Parsing/Parsing/HttpVariableValueNode.cs b/src/Microsoft.DotNet.Interactive.Http.Parsing/Parsing/HttpVariableValueNode.cs index 802f390b16..d7a7a2628c 100644 --- a/src/Microsoft.DotNet.Interactive.Http.Parsing/Parsing/HttpVariableValueNode.cs +++ b/src/Microsoft.DotNet.Interactive.Http.Parsing/Parsing/HttpVariableValueNode.cs @@ -15,5 +15,7 @@ internal HttpVariableValueNode(SourceText sourceText, HttpSyntaxTree syntaxTree) public void Add(HttpEmbeddedExpressionNode node) => AddInternal(node); + public void Add(HttpEscapedCharacterSequenceNode node) => AddInternal(node); + public HttpBindingResult TryGetValue(HttpBindingDelegate bind) => this.BindByInterpolation(bind); } \ No newline at end of file diff --git a/src/Microsoft.DotNet.Interactive.Http.Tests/ParserTests.Combinatorial.cs b/src/Microsoft.DotNet.Interactive.Http.Tests/ParserTests.Combinatorial.cs index f612be8859..c1f9a3ce74 100644 --- a/src/Microsoft.DotNet.Interactive.Http.Tests/ParserTests.Combinatorial.cs +++ b/src/Microsoft.DotNet.Interactive.Http.Tests/ParserTests.Combinatorial.cs @@ -106,6 +106,7 @@ public static IEnumerable GenerateValidRequests() var generationNumber = 0; foreach(var namedRequest in ValidNamedRequests()) + foreach (var variables in ValidVariableDeclarations()) foreach (var method in ValidMethods()) foreach (var url in ValidUrls()) foreach (var version in ValidVersions()) @@ -115,7 +116,7 @@ public static IEnumerable GenerateValidRequests() ++generationNumber; yield return new object[] { - new HttpRequestNodeSyntaxSpec(namedRequest, method, url, version, headerSection, bodySection), + new HttpRequestNodeSyntaxSpec(namedRequest, variables, method, url, version, headerSection, bodySection), generationNumber }; } @@ -125,6 +126,7 @@ public static IEnumerable GenerateValidRequestsWithExtraTrivia() { var generationNumber = 0; + foreach(var variables in ValidVariableDeclarations()) foreach (var namedRequest in ValidNamedRequests()) foreach (var method in ValidMethods()) foreach (var url in ValidUrls()) @@ -135,7 +137,7 @@ public static IEnumerable GenerateValidRequestsWithExtraTrivia() ++generationNumber; yield return new object[] { - new HttpRequestNodeSyntaxSpec(namedRequest, method, url, version, headerSection, bodySection) + new HttpRequestNodeSyntaxSpec(namedRequest, variables, method, url, version, headerSection, bodySection) { Randomizer = new Random(1) }, @@ -149,6 +151,7 @@ public static IEnumerable GenerateInvalidRequests() var generationNumber = 0; foreach (var namedRequest in ValidNamedRequests()) + foreach (var variables in ValidVariableDeclarations()) foreach (var method in InvalidMethods()) foreach (var url in ValidUrls()) foreach (var version in ValidVersions()) @@ -158,12 +161,13 @@ public static IEnumerable GenerateInvalidRequests() ++generationNumber; yield return new object[] { - new HttpRequestNodeSyntaxSpec(namedRequest, method, url, version, headerSection, bodySection), + new HttpRequestNodeSyntaxSpec(namedRequest, variables, method, url, version, headerSection, bodySection), generationNumber }; } foreach (var namedRequest in ValidNamedRequests()) + foreach (var variables in ValidVariableDeclarations()) foreach (var method in ValidMethods()) foreach (var url in InvalidUrls()) foreach (var version in ValidVersions()) @@ -173,12 +177,13 @@ public static IEnumerable GenerateInvalidRequests() ++generationNumber; yield return new object[] { - new HttpRequestNodeSyntaxSpec(namedRequest, method, url, version, headerSection, bodySection), + new HttpRequestNodeSyntaxSpec(namedRequest, variables, method, url, version, headerSection, bodySection), generationNumber }; } foreach(var namedRequest in ValidNamedRequests()) + foreach (var variables in ValidVariableDeclarations()) foreach (var method in ValidMethods()) foreach (var url in ValidUrls()) foreach (var version in InvalidVersions()) @@ -188,12 +193,13 @@ public static IEnumerable GenerateInvalidRequests() ++generationNumber; yield return new object[] { - new HttpRequestNodeSyntaxSpec(namedRequest, method, url, version, headerSection, bodySection), + new HttpRequestNodeSyntaxSpec(namedRequest, variables, method, url, version, headerSection, bodySection), generationNumber }; } foreach(var namedRequest in ValidNamedRequests()) + foreach (var variables in ValidVariableDeclarations()) foreach (var method in ValidMethods()) foreach (var url in ValidUrls()) foreach (var version in ValidVersions()) @@ -203,12 +209,14 @@ public static IEnumerable GenerateInvalidRequests() ++generationNumber; yield return new object[] { - new HttpRequestNodeSyntaxSpec(namedRequest, method, url, version, headerSection, bodySection), + new HttpRequestNodeSyntaxSpec(namedRequest, variables, method, url, version, headerSection, bodySection), generationNumber }; } + foreach (var namedRequest in InvalidNamedRequests()) + foreach (var variables in ValidVariableDeclarations()) foreach (var method in ValidMethods()) foreach (var url in ValidUrls()) foreach (var version in ValidVersions()) @@ -218,11 +226,12 @@ public static IEnumerable GenerateInvalidRequests() ++generationNumber; yield return new object[] { - new HttpRequestNodeSyntaxSpec(namedRequest, method, url, version, headerSection, bodySection), + new HttpRequestNodeSyntaxSpec(namedRequest, variables, method, url, version, headerSection, bodySection), generationNumber }; } } + } private static IEnumerable ValidMethods() { @@ -355,5 +364,64 @@ with XML. .Should().BeEquivalentTo("numberValue", "stringValue"); }); } + + private static IEnumerable ValidVariableDeclarations() + { + yield return null; + + yield return new HttpVariableDeclarationAndAssignmentNodeSyntaxSpec("@host=localhost"); + + yield return new HttpVariableDeclarationAndAssignmentNodeSyntaxSpec("@host=https://example.com"); + + yield return new HttpVariableDeclarationAndAssignmentNodeSyntaxSpec("@api_key=secret123"); + + yield return new HttpVariableDeclarationAndAssignmentNodeSyntaxSpec("@base_url=https://{{host}}/api", node => + { + node.ValueNode.DescendantNodesAndTokens().OfType() + .Should().ContainSingle() + .Which.ExpressionNode.Text.Should().Be("host"); + }); + + yield return new HttpVariableDeclarationAndAssignmentNodeSyntaxSpec("@escaped=\\{\\{text\\}\\}", node => + { + node.ValueNode.Text.Should().Be("{{text}}"); + }); + + yield return new HttpVariableDeclarationAndAssignmentNodeSyntaxSpec("@with_spaces=one two three"); + + yield return new HttpVariableDeclarationAndAssignmentNodeSyntaxSpec("@quoted=\"hello world\""); + + yield return new HttpVariableDeclarationAndAssignmentNodeSyntaxSpec("@single_quoted='hello world'"); + + yield return new HttpVariableDeclarationAndAssignmentNodeSyntaxSpec("@user.name=john_doe"); + + yield return new HttpVariableDeclarationAndAssignmentNodeSyntaxSpec("@dynamic={{$guid}}", node => + { + node.ValueNode.DescendantNodesAndTokens().OfType() + .Should().ContainSingle() + .Which.ExpressionNode.Text.Should().Be("$guid"); + }); + } + + private static IEnumerable InvalidVariableDeclarations() + { + // Missing variable name + yield return new HttpVariableDeclarationAndAssignmentNodeSyntaxSpec("@=value"); + + // Variable name starting with number + yield return new HttpVariableDeclarationAndAssignmentNodeSyntaxSpec("@123invalid=value"); + + // Invalid character in variable name (hyphen) + yield return new HttpVariableDeclarationAndAssignmentNodeSyntaxSpec("@my-var=value"); + + // Space in variable name + yield return new HttpVariableDeclarationAndAssignmentNodeSyntaxSpec("@var name=value"); + + // Missing equals sign + yield return new HttpVariableDeclarationAndAssignmentNodeSyntaxSpec("@host value"); + + // Special characters in variable name + yield return new HttpVariableDeclarationAndAssignmentNodeSyntaxSpec("@host!name=value"); + } } -} \ No newline at end of file + diff --git a/src/Microsoft.DotNet.Interactive.Http.Tests/ParserTests.Variables.cs b/src/Microsoft.DotNet.Interactive.Http.Tests/ParserTests.Variables.cs index 691db97ecc..fd7f3b0836 100644 --- a/src/Microsoft.DotNet.Interactive.Http.Tests/ParserTests.Variables.cs +++ b/src/Microsoft.DotNet.Interactive.Http.Tests/ParserTests.Variables.cs @@ -299,6 +299,35 @@ public void single_quotes_in_variable_values_are_supported() } + [Fact] + public void escaping_double_curly_braces_with_a_backslash_produces_literal_double_braces_in_variable_value() + { + var result = Parse( + """ + + @text=\{{text}} + """ + ); + + var variables = result.SyntaxTree.RootNode.TryGetDeclaredVariables().declaredVariables; + variables.Should().Contain(n => n.Key == "text").Which.Value.Should().BeOfType().Which.Value.Should().Be("{{text}}"); + } + + [Fact] + public void escaping_triple_curly_braces_with_a_backslash_produces_literal_triple_braces_in_variable_value() + { + var result = Parse( + """ + + @text=\{{{text}}} + """ + ); + + var variables = result.SyntaxTree.RootNode.TryGetDeclaredVariables().declaredVariables; + variables.Should().Contain(n => n.Key == "text").Which.Value.Should().BeOfType().Which.Value.Should().Be("{{{text}}}"); + + } + [Fact] public void double_quotes_in_variable_values_are_supported() { diff --git a/src/Microsoft.DotNet.Interactive.Http.Tests/Utility/HttpBodyNodeSyntaxSpec.cs b/src/Microsoft.DotNet.Interactive.Http.Tests/Utility/HttpBodyNodeSyntaxSpec.cs index ce19e70ac5..c005e628d1 100644 --- a/src/Microsoft.DotNet.Interactive.Http.Tests/Utility/HttpBodyNodeSyntaxSpec.cs +++ b/src/Microsoft.DotNet.Interactive.Http.Tests/Utility/HttpBodyNodeSyntaxSpec.cs @@ -65,4 +65,26 @@ internal class HttpHeadersNodeSyntaxSpec : SyntaxSpecBase public HttpHeadersNodeSyntaxSpec(string text, params Action[] assertions) : base(text, assertions) { } +} + +internal class HttpVariableDeclarationAndAssignmentNodeSyntaxSpec : SyntaxSpecBase +{ + public HttpVariableDeclarationAndAssignmentNodeSyntaxSpec(string text, params Action[] assertions) + : base(text, assertions) + { + } + + public override void Validate(HttpVariableDeclarationAndAssignmentNode syntaxNode) + { + base.Validate(syntaxNode); + + // Additional validation specific to variable declarations + syntaxNode.DeclarationNode.Should().NotBeNull(); + syntaxNode.AssignmentNode.Should().NotBeNull(); + + if (!syntaxNode.GetDiagnostics().Any()) + { + syntaxNode.ValueNode.Should().NotBeNull(); + } + } } \ No newline at end of file diff --git a/src/Microsoft.DotNet.Interactive.Http.Tests/Utility/HttpRequestNodeSyntaxSpec.cs b/src/Microsoft.DotNet.Interactive.Http.Tests/Utility/HttpRequestNodeSyntaxSpec.cs index 4e5bda0a05..6142ea2c4e 100644 --- a/src/Microsoft.DotNet.Interactive.Http.Tests/Utility/HttpRequestNodeSyntaxSpec.cs +++ b/src/Microsoft.DotNet.Interactive.Http.Tests/Utility/HttpRequestNodeSyntaxSpec.cs @@ -15,6 +15,7 @@ internal class HttpRequestNodeSyntaxSpec : SyntaxSpecBase { public HttpRequestNodeSyntaxSpec( HttpCommentNodeSyntaxSpec commentNamedRequest, + HttpVariableDeclarationAndAssignmentNodeSyntaxSpec variableDeclarationAndAssignment, HttpMethodNodeSyntaxSpec method, HttpUrlNodeSyntaxSpec url, HttpVersionNodeSyntaxSpec version = null, @@ -37,6 +38,8 @@ public HttpRequestNodeSyntaxSpec( public HttpCommentNodeSyntaxSpec CommentNamedRequest { get; } + public HttpVariableDeclarationAndAssignmentNodeSyntaxSpec VariableDeclarationAndAssignment { get; } + public HttpMethodNodeSyntaxSpec Method { get; } public HttpUrlNodeSyntaxSpec Url { get; } @@ -66,6 +69,15 @@ public override void Validate(HttpRequestNode requestNode) CommentNamedRequest.Validate(syntaxNode: commentNode); } + if(!string.IsNullOrEmpty(VariableDeclarationAndAssignment?.Text)) + { + var httpVariableDeclarationAndAssignmentNode = requestNode.ChildNodes.OfType().SingleOrDefault(); + + httpVariableDeclarationAndAssignmentNode.Should().NotBeNull(); + + VariableDeclarationAndAssignment.Validate(httpVariableDeclarationAndAssignmentNode); + } + if (!string.IsNullOrEmpty(Method?.Text)) { var httpMethodNode = requestNode.ChildNodes.OfType().SingleOrDefault(); @@ -141,6 +153,12 @@ public override string ToString() sb.Append(MaybeLineComment()); sb.Append(MaybeWhitespace()); + if(VariableDeclarationAndAssignment is not null) + { + sb.Append(VariableDeclarationAndAssignment); + sb.AppendLine(); + } + sb.Append(Method); sb.Append(" "); sb.Append(MaybeWhitespace());