diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 87f8028b..f82dbc2d 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -1,7 +1,7 @@ - 7.1.0-preview + 7.2.0-preview 8.0 true true diff --git a/src/GraphQLParser.Tests/LexerTests.cs b/src/GraphQLParser.Tests/LexerTests.cs index c100f21a..5169e7f1 100644 --- a/src/GraphQLParser.Tests/LexerTests.cs +++ b/src/GraphQLParser.Tests/LexerTests.cs @@ -974,6 +974,47 @@ public void Lex_WhiteSpaceStringToken_HasStringKind() token.Kind.ShouldBe(TokenKind.STRING); } + [Theory] + [InlineData("test", "test")] + [InlineData("te\\\"\"\"st", "te\"\"\"st")] + [InlineData("\ntest", "test")] + [InlineData("\r\ntest", "test")] + [InlineData(" \ntest", "test")] + [InlineData("\t\ntest", "test")] + [InlineData("\n\ntest", "test")] + [InlineData("test\nline2", "test\nline2")] + [InlineData("test\rline2", "test\nline2")] + [InlineData("test\r\nline2", "test\nline2")] + [InlineData("test\r\r\nline2", "test\n\nline2")] + [InlineData("test\r\n\nline2", "test\n\nline2")] + [InlineData("test\n", "test")] + [InlineData("test\n ", "test")] + [InlineData("test\n\t", "test")] + [InlineData("test\n\n", "test")] + [InlineData("test\n line2", "test\nline2")] + [InlineData("test\n\t\tline2", "test\nline2")] + [InlineData("test\n \tline2", "test\nline2")] + [InlineData(" test\nline2", " test\nline2")] + [InlineData(" test\n line2", " test\nline2")] + [InlineData("\n test\n line2", "test\nline2")] + [InlineData(" test\n line2\n\t\tline3\n line4", " test\nline2\n\tline3\n line4")] + [InlineData(" test\n Hello,\n\n world!\n ", " test\nHello,\n\n world!")] + [InlineData(" \n Hello,\r\n\n world!\n ", "Hello,\n\n world!")] + [InlineData(" \n Hello,\r\n\n wor___ld!\n ", "Hello,\n\n wor___ld!")] + [InlineData("\r\n Hello,\r\n World!\r\n\r\n Yours,\r\n GraphQL.\r\n ", "Hello,\n World!\n\nYours,\n GraphQL.")] + [InlineData("Test \\n escaping", "Test \\n escaping")] + [InlineData("Test \\u1234 escaping", "Test \\u1234 escaping")] + [InlineData("Test \\ escaping", "Test \\ escaping")] + public void Lex_BlockString_Tests(string input, string expected) + { + input = input.Replace("___", new string('_', 9000)); + expected = expected.Replace("___", new string('_', 9000)); + input = "\"\"\"" + input + "\"\"\""; + var actual = input.Lex(); + actual.Kind.ShouldBe(TokenKind.STRING); + actual.Value.ToString().ShouldBe(expected); + } + private static Token GetATPunctuationTokenLexer() { return "@".Lex(); diff --git a/src/GraphQLParser.Tests/ParserTests.cs b/src/GraphQLParser.Tests/ParserTests.cs index ad3f698e..5ca01510 100644 --- a/src/GraphQLParser.Tests/ParserTests.cs +++ b/src/GraphQLParser.Tests/ParserTests.cs @@ -644,5 +644,535 @@ directive @TestDirective ( directiveDef.Arguments[0].Description.Value.ShouldBe("Example"); } + [Theory] + [InlineData(IgnoreOptions.None)] + [InlineData(IgnoreOptions.IgnoreComments)] + [InlineData(IgnoreOptions.IgnoreCommentsAndLocations)] + public void Descriptions_WithComments_Should_Read_Correctly_1(IgnoreOptions options) + { + using var document = @" +# comment 1 +""A JSON scalar"" +# comment 2 +scalar JSON + +# comment 3 +"""""" +Human type +"""""" +# comment 4 +type Human { + # comment 5 + """""" + Name of human + """""" + # comment 6 + name: String + + # comment 7 + ""Test"" + # comment 8 + test( + # comment 9 + ""desc"" + # comment 10 + arg: Int + ): Int +} + +# comment 11 +""Test interface"" +# comment 12 +interface TestInterface { + # comment 13 + ""Object name"" + # comment 14 + name: String +} + +# comment 15 +"""""" +Test union +"""""" +# comment 16 +union TestUnion = Test1 | Test2 + +# comment 17 +""Example enum"" +# comment 18 +enum Colors { + # comment 19 + ""Red"" + # comment 20 + RED + # comment 21 + ""Blue"" + # comment 22 + BLUE +} + +# comment 23 +"""""" +This is an example input object +Line two of the description +"""""" +# comment 24 +input TestInputObject { + # comment 25 + """""" + The value of the input object + (any JSON value is accepted) + """""" + # comment 26 + Value: JSON +} + +# comment 27 +""Test directive"" +# comment 28 +directive @TestDirective ( + # comment 29 + ""Example"" + # comment 30 + Value: Int +) on QUERY +".Parse(new ParserOptions { Ignore = options }); + var defs = document.Definitions; + defs.Count.ShouldBe(7); + var parseComments = options == IgnoreOptions.None; + + var scalarDef = defs.Single(x => x is GraphQLScalarTypeDefinition) as GraphQLScalarTypeDefinition; + scalarDef.Name.Value.ShouldBe("JSON"); + scalarDef.Description.Value.ShouldBe("A JSON scalar"); + if (parseComments) + scalarDef.Comment.Text.ShouldBe(" comment 2"); + + var objectDef = defs.Single(x => x is GraphQLObjectTypeDefinition) as GraphQLObjectTypeDefinition; + objectDef.Name.Value.ShouldBe("Human"); + objectDef.Description.Value.ShouldBe("Human type"); + if (parseComments) + objectDef.Comment.Text.ShouldBe(" comment 4"); + objectDef.Fields.Count.ShouldBe(2); + objectDef.Fields[0].Name.Value.ShouldBe("name"); + objectDef.Fields[0].Type.ShouldBeAssignableTo().Name.Value.ShouldBe("String"); + objectDef.Fields[0].Description.Value.ShouldBe("Name of human"); + if (parseComments) + objectDef.Fields[0].Comment.Text.ShouldBe(" comment 6"); + objectDef.Fields[1].Name.Value.ShouldBe("test"); + objectDef.Fields[1].Type.ShouldBeAssignableTo().Name.Value.ShouldBe("Int"); + objectDef.Fields[1].Description.Value.ShouldBe("Test"); + if (parseComments) + objectDef.Fields[1].Comment.Text.ShouldBe(" comment 8"); + objectDef.Fields[1].Arguments.Count.ShouldBe(1); + objectDef.Fields[1].Arguments[0].Name.Value.ShouldBe("arg"); + objectDef.Fields[1].Arguments[0].Description.Value.ShouldBe("desc"); + objectDef.Fields[1].Arguments[0].Type.ShouldBeAssignableTo().Name.Value.ShouldBe("Int"); + if (parseComments) + objectDef.Fields[1].Arguments[0].Comment.Text.ShouldBe(" comment 10"); + + var interfaceDef = defs.Single(x => x is GraphQLInterfaceTypeDefinition) as GraphQLInterfaceTypeDefinition; + interfaceDef.Name.Value.ShouldBe("TestInterface"); + interfaceDef.Description.Value.ShouldBe("Test interface"); + if (parseComments) + interfaceDef.Comment.Text.ShouldBe(" comment 12"); + interfaceDef.Fields.Count.ShouldBe(1); + interfaceDef.Fields[0].Name.Value.ShouldBe("name"); + interfaceDef.Fields[0].Type.ShouldBeAssignableTo().Name.Value.ShouldBe("String"); + interfaceDef.Fields[0].Description.Value.ShouldBe("Object name"); + if (parseComments) + interfaceDef.Fields[0].Comment.Text.ShouldBe(" comment 14"); + + var unionDef = defs.Single(x => x is GraphQLUnionTypeDefinition) as GraphQLUnionTypeDefinition; + unionDef.Name.Value.ShouldBe("TestUnion"); + unionDef.Description.Value.ShouldBe("Test union"); + if (parseComments) + unionDef.Comment.Text.ShouldBe(" comment 16"); + unionDef.Types.Count.ShouldBe(2); + unionDef.Types[0].Name.Value.ShouldBe("Test1"); + unionDef.Types[1].Name.Value.ShouldBe("Test2"); + + var enumDef = defs.Single(x => x is GraphQLEnumTypeDefinition) as GraphQLEnumTypeDefinition; + enumDef.Name.Value.ShouldBe("Colors"); + enumDef.Description.Value.ShouldBe("Example enum"); + if (parseComments) + enumDef.Comment.Text.ShouldBe(" comment 18"); + enumDef.Values.Count.ShouldBe(2); + enumDef.Values[0].Name.Value.ShouldBe("RED"); + enumDef.Values[0].Description.Value.ShouldBe("Red"); + if (parseComments) + enumDef.Values[0].Comment.Text.ShouldBe(" comment 20"); + enumDef.Values[1].Name.Value.ShouldBe("BLUE"); + enumDef.Values[1].Description.Value.ShouldBe("Blue"); + if (parseComments) + enumDef.Values[1].Comment.Text.ShouldBe(" comment 22"); + + var inputDef = defs.Single(x => x is GraphQLInputObjectTypeDefinition) as GraphQLInputObjectTypeDefinition; + inputDef.Name.Value.ShouldBe("TestInputObject"); + inputDef.Description.Value.ShouldBe("This is an example input object\nLine two of the description"); + if (parseComments) + inputDef.Comment.Text.ShouldBe(" comment 24"); + inputDef.Fields.Count.ShouldBe(1); + inputDef.Fields[0].Name.Value.ShouldBe("Value"); + inputDef.Fields[0].Type.ShouldBeAssignableTo().Name.Value.ShouldBe("JSON"); + inputDef.Fields[0].Description.Value.ShouldBe("The value of the input object\n (any JSON value is accepted)"); + if (parseComments) + inputDef.Fields[0].Comment.Text.ShouldBe(" comment 26"); + + var directiveDef = defs.Single(x => x is GraphQLDirectiveDefinition) as GraphQLDirectiveDefinition; + directiveDef.Name.Value.ShouldBe("TestDirective"); + directiveDef.Description.Value.ShouldBe("Test directive"); + if (parseComments) + directiveDef.Comment.Text.ShouldBe(" comment 28"); + directiveDef.Arguments.Count.ShouldBe(1); + directiveDef.Arguments[0].Name.Value.ShouldBe("Value"); + directiveDef.Arguments[0].Type.ShouldBeAssignableTo().Name.Value.ShouldBe("Int"); + directiveDef.Arguments[0].Description.Value.ShouldBe("Example"); + if (parseComments) + directiveDef.Arguments[0].Comment.Text.ShouldBe(" comment 30"); + } + + [Theory] + [InlineData(IgnoreOptions.None)] + [InlineData(IgnoreOptions.IgnoreComments)] + [InlineData(IgnoreOptions.IgnoreCommentsAndLocations)] + public void Descriptions_WithComments_Should_Read_Correctly_2(IgnoreOptions options) + { + using var document = @" +""A JSON scalar"" +# comment 2 +scalar JSON + +"""""" +Human type +"""""" +# comment 4 +type Human { + """""" + Name of human + """""" + # comment 6 + name: String + + ""Test"" + # comment 8 + test( + ""desc"" + # comment 10 + arg: Int + ): Int +} + +""Test interface"" +# comment 12 +interface TestInterface { + ""Object name"" + # comment 14 + name: String +} + +"""""" +Test union +"""""" +# comment 16 +union TestUnion = Test1 | Test2 + +""Example enum"" +# comment 18 +enum Colors { + ""Red"" + # comment 20 + RED + ""Blue"" + # comment 22 + BLUE +} + +"""""" +This is an example input object +Line two of the description +"""""" +# comment 24 +input TestInputObject { + """""" + The value of the input object + (any JSON value is accepted) + """""" + # comment 26 + Value: JSON +} + +""Test directive"" +# comment 28 +directive @TestDirective ( + ""Example"" + # comment 30 + Value: Int +) on QUERY +".Parse(new ParserOptions { Ignore = options }); + var defs = document.Definitions; + defs.Count.ShouldBe(7); + var parseComments = options == IgnoreOptions.None; + + var scalarDef = defs.Single(x => x is GraphQLScalarTypeDefinition) as GraphQLScalarTypeDefinition; + scalarDef.Name.Value.ShouldBe("JSON"); + scalarDef.Description.Value.ShouldBe("A JSON scalar"); + if (parseComments) + scalarDef.Comment.Text.ShouldBe(" comment 2"); + + var objectDef = defs.Single(x => x is GraphQLObjectTypeDefinition) as GraphQLObjectTypeDefinition; + objectDef.Name.Value.ShouldBe("Human"); + objectDef.Description.Value.ShouldBe("Human type"); + if (parseComments) + objectDef.Comment.Text.ShouldBe(" comment 4"); + objectDef.Fields.Count.ShouldBe(2); + objectDef.Fields[0].Name.Value.ShouldBe("name"); + objectDef.Fields[0].Type.ShouldBeAssignableTo().Name.Value.ShouldBe("String"); + objectDef.Fields[0].Description.Value.ShouldBe("Name of human"); + if (parseComments) + objectDef.Fields[0].Comment.Text.ShouldBe(" comment 6"); + objectDef.Fields[1].Name.Value.ShouldBe("test"); + objectDef.Fields[1].Type.ShouldBeAssignableTo().Name.Value.ShouldBe("Int"); + objectDef.Fields[1].Description.Value.ShouldBe("Test"); + if (parseComments) + objectDef.Fields[1].Comment.Text.ShouldBe(" comment 8"); + objectDef.Fields[1].Arguments.Count.ShouldBe(1); + objectDef.Fields[1].Arguments[0].Name.Value.ShouldBe("arg"); + objectDef.Fields[1].Arguments[0].Description.Value.ShouldBe("desc"); + objectDef.Fields[1].Arguments[0].Type.ShouldBeAssignableTo().Name.Value.ShouldBe("Int"); + if (parseComments) + objectDef.Fields[1].Arguments[0].Comment.Text.ShouldBe(" comment 10"); + + var interfaceDef = defs.Single(x => x is GraphQLInterfaceTypeDefinition) as GraphQLInterfaceTypeDefinition; + interfaceDef.Name.Value.ShouldBe("TestInterface"); + interfaceDef.Description.Value.ShouldBe("Test interface"); + if (parseComments) + interfaceDef.Comment.Text.ShouldBe(" comment 12"); + interfaceDef.Fields.Count.ShouldBe(1); + interfaceDef.Fields[0].Name.Value.ShouldBe("name"); + interfaceDef.Fields[0].Type.ShouldBeAssignableTo().Name.Value.ShouldBe("String"); + interfaceDef.Fields[0].Description.Value.ShouldBe("Object name"); + if (parseComments) + interfaceDef.Fields[0].Comment.Text.ShouldBe(" comment 14"); + + var unionDef = defs.Single(x => x is GraphQLUnionTypeDefinition) as GraphQLUnionTypeDefinition; + unionDef.Name.Value.ShouldBe("TestUnion"); + unionDef.Description.Value.ShouldBe("Test union"); + if (parseComments) + unionDef.Comment.Text.ShouldBe(" comment 16"); + unionDef.Types.Count.ShouldBe(2); + unionDef.Types[0].Name.Value.ShouldBe("Test1"); + unionDef.Types[1].Name.Value.ShouldBe("Test2"); + + var enumDef = defs.Single(x => x is GraphQLEnumTypeDefinition) as GraphQLEnumTypeDefinition; + enumDef.Name.Value.ShouldBe("Colors"); + enumDef.Description.Value.ShouldBe("Example enum"); + if (parseComments) + enumDef.Comment.Text.ShouldBe(" comment 18"); + enumDef.Values.Count.ShouldBe(2); + enumDef.Values[0].Name.Value.ShouldBe("RED"); + enumDef.Values[0].Description.Value.ShouldBe("Red"); + if (parseComments) + enumDef.Values[0].Comment.Text.ShouldBe(" comment 20"); + enumDef.Values[1].Name.Value.ShouldBe("BLUE"); + enumDef.Values[1].Description.Value.ShouldBe("Blue"); + if (parseComments) + enumDef.Values[1].Comment.Text.ShouldBe(" comment 22"); + + var inputDef = defs.Single(x => x is GraphQLInputObjectTypeDefinition) as GraphQLInputObjectTypeDefinition; + inputDef.Name.Value.ShouldBe("TestInputObject"); + inputDef.Description.Value.ShouldBe("This is an example input object\nLine two of the description"); + if (parseComments) + inputDef.Comment.Text.ShouldBe(" comment 24"); + inputDef.Fields.Count.ShouldBe(1); + inputDef.Fields[0].Name.Value.ShouldBe("Value"); + inputDef.Fields[0].Type.ShouldBeAssignableTo().Name.Value.ShouldBe("JSON"); + inputDef.Fields[0].Description.Value.ShouldBe("The value of the input object\n (any JSON value is accepted)"); + if (parseComments) + inputDef.Fields[0].Comment.Text.ShouldBe(" comment 26"); + + var directiveDef = defs.Single(x => x is GraphQLDirectiveDefinition) as GraphQLDirectiveDefinition; + directiveDef.Name.Value.ShouldBe("TestDirective"); + directiveDef.Description.Value.ShouldBe("Test directive"); + if (parseComments) + directiveDef.Comment.Text.ShouldBe(" comment 28"); + directiveDef.Arguments.Count.ShouldBe(1); + directiveDef.Arguments[0].Name.Value.ShouldBe("Value"); + directiveDef.Arguments[0].Type.ShouldBeAssignableTo().Name.Value.ShouldBe("Int"); + directiveDef.Arguments[0].Description.Value.ShouldBe("Example"); + if (parseComments) + directiveDef.Arguments[0].Comment.Text.ShouldBe(" comment 30"); + } + + [Theory] + [InlineData(IgnoreOptions.None)] + [InlineData(IgnoreOptions.IgnoreComments)] + [InlineData(IgnoreOptions.IgnoreCommentsAndLocations)] + public void Descriptions_WithComments_Should_Read_Correctly_3(IgnoreOptions options) + { + using var document = @" +# comment 1 +""A JSON scalar"" +scalar JSON + +# comment 3 +"""""" +Human type +"""""" +type Human { + # comment 5 + """""" + Name of human + """""" + name: String + + # comment 7 + ""Test"" + test( + # comment 9 + ""desc"" + arg: Int + ): Int +} + +# comment 11 +""Test interface"" +interface TestInterface { + # comment 13 + ""Object name"" + name: String +} + +# comment 15 +"""""" +Test union +"""""" +union TestUnion = Test1 | Test2 + +# comment 17 +""Example enum"" +enum Colors { + # comment 19 + ""Red"" + RED + # comment 21 + ""Blue"" + BLUE +} + +# comment 23 +"""""" +This is an example input object +Line two of the description +"""""" +input TestInputObject { + # comment 25 + """""" + The value of the input object + (any JSON value is accepted) + """""" + Value: JSON +} + +# comment 27 +""Test directive"" +directive @TestDirective ( + # comment 29 + ""Example"" + Value: Int +) on QUERY +".Parse(new ParserOptions { Ignore = options }); + var defs = document.Definitions; + defs.Count.ShouldBe(7); + var parseComments = options == IgnoreOptions.None; + + var scalarDef = defs.Single(x => x is GraphQLScalarTypeDefinition) as GraphQLScalarTypeDefinition; + scalarDef.Name.Value.ShouldBe("JSON"); + scalarDef.Description.Value.ShouldBe("A JSON scalar"); + if (parseComments) + scalarDef.Comment.Text.ShouldBe(" comment 1"); + + var objectDef = defs.Single(x => x is GraphQLObjectTypeDefinition) as GraphQLObjectTypeDefinition; + objectDef.Name.Value.ShouldBe("Human"); + objectDef.Description.Value.ShouldBe("Human type"); + if (parseComments) + objectDef.Comment.Text.ShouldBe(" comment 3"); + objectDef.Fields.Count.ShouldBe(2); + objectDef.Fields[0].Name.Value.ShouldBe("name"); + objectDef.Fields[0].Type.ShouldBeAssignableTo().Name.Value.ShouldBe("String"); + objectDef.Fields[0].Description.Value.ShouldBe("Name of human"); + if (parseComments) + objectDef.Fields[0].Comment.Text.ShouldBe(" comment 5"); + objectDef.Fields[1].Name.Value.ShouldBe("test"); + objectDef.Fields[1].Type.ShouldBeAssignableTo().Name.Value.ShouldBe("Int"); + objectDef.Fields[1].Description.Value.ShouldBe("Test"); + if (parseComments) + objectDef.Fields[1].Comment.Text.ShouldBe(" comment 7"); + objectDef.Fields[1].Arguments.Count.ShouldBe(1); + objectDef.Fields[1].Arguments[0].Name.Value.ShouldBe("arg"); + objectDef.Fields[1].Arguments[0].Description.Value.ShouldBe("desc"); + objectDef.Fields[1].Arguments[0].Type.ShouldBeAssignableTo().Name.Value.ShouldBe("Int"); + if (parseComments) + objectDef.Fields[1].Arguments[0].Comment.Text.ShouldBe(" comment 9"); + + var interfaceDef = defs.Single(x => x is GraphQLInterfaceTypeDefinition) as GraphQLInterfaceTypeDefinition; + interfaceDef.Name.Value.ShouldBe("TestInterface"); + interfaceDef.Description.Value.ShouldBe("Test interface"); + if (parseComments) + interfaceDef.Comment.Text.ShouldBe(" comment 11"); + interfaceDef.Fields.Count.ShouldBe(1); + interfaceDef.Fields[0].Name.Value.ShouldBe("name"); + interfaceDef.Fields[0].Type.ShouldBeAssignableTo().Name.Value.ShouldBe("String"); + interfaceDef.Fields[0].Description.Value.ShouldBe("Object name"); + if (parseComments) + interfaceDef.Fields[0].Comment.Text.ShouldBe(" comment 13"); + + var unionDef = defs.Single(x => x is GraphQLUnionTypeDefinition) as GraphQLUnionTypeDefinition; + unionDef.Name.Value.ShouldBe("TestUnion"); + unionDef.Description.Value.ShouldBe("Test union"); + if (parseComments) + unionDef.Comment.Text.ShouldBe(" comment 15"); + unionDef.Types.Count.ShouldBe(2); + unionDef.Types[0].Name.Value.ShouldBe("Test1"); + unionDef.Types[1].Name.Value.ShouldBe("Test2"); + + var enumDef = defs.Single(x => x is GraphQLEnumTypeDefinition) as GraphQLEnumTypeDefinition; + enumDef.Name.Value.ShouldBe("Colors"); + enumDef.Description.Value.ShouldBe("Example enum"); + if (parseComments) + enumDef.Comment.Text.ShouldBe(" comment 17"); + enumDef.Values.Count.ShouldBe(2); + enumDef.Values[0].Name.Value.ShouldBe("RED"); + enumDef.Values[0].Description.Value.ShouldBe("Red"); + if (parseComments) + enumDef.Values[0].Comment.Text.ShouldBe(" comment 19"); + enumDef.Values[1].Name.Value.ShouldBe("BLUE"); + enumDef.Values[1].Description.Value.ShouldBe("Blue"); + if (parseComments) + enumDef.Values[1].Comment.Text.ShouldBe(" comment 21"); + + var inputDef = defs.Single(x => x is GraphQLInputObjectTypeDefinition) as GraphQLInputObjectTypeDefinition; + inputDef.Name.Value.ShouldBe("TestInputObject"); + inputDef.Description.Value.ShouldBe("This is an example input object\nLine two of the description"); + if (parseComments) + inputDef.Comment.Text.ShouldBe(" comment 23"); + inputDef.Fields.Count.ShouldBe(1); + inputDef.Fields[0].Name.Value.ShouldBe("Value"); + inputDef.Fields[0].Type.ShouldBeAssignableTo().Name.Value.ShouldBe("JSON"); + inputDef.Fields[0].Description.Value.ShouldBe("The value of the input object\n (any JSON value is accepted)"); + if (parseComments) + inputDef.Fields[0].Comment.Text.ShouldBe(" comment 25"); + + var directiveDef = defs.Single(x => x is GraphQLDirectiveDefinition) as GraphQLDirectiveDefinition; + directiveDef.Name.Value.ShouldBe("TestDirective"); + directiveDef.Description.Value.ShouldBe("Test directive"); + if (parseComments) + directiveDef.Comment.Text.ShouldBe(" comment 27"); + directiveDef.Arguments.Count.ShouldBe(1); + directiveDef.Arguments[0].Name.Value.ShouldBe("Value"); + directiveDef.Arguments[0].Type.ShouldBeAssignableTo().Name.Value.ShouldBe("Int"); + directiveDef.Arguments[0].Description.Value.ShouldBe("Example"); + if (parseComments) + directiveDef.Arguments[0].Comment.Text.ShouldBe(" comment 29"); + } } } diff --git a/src/GraphQLParser.Tests/Validation/LexerValidationTests.cs b/src/GraphQLParser.Tests/Validation/LexerValidationTests.cs index f42cba49..98ea11a2 100644 --- a/src/GraphQLParser.Tests/Validation/LexerValidationTests.cs +++ b/src/GraphQLParser.Tests/Validation/LexerValidationTests.cs @@ -364,6 +364,20 @@ public void Lex_UnescapedControlChar_ThrowsExceptionWithCorrectMessage() exception.Column.ShouldBe(21); } + [Fact] + public void Lex_UnescapedControlChar_Blockstring_ThrowsExceptionWithCorrectMessage() + { + var exception = Should.Throw(() => "\"\"\"contains unescaped \u0007 control char".Lex()); + + exception.Message.ShouldBe( + "Syntax Error GraphQL (1:23) Invalid character within BlockString: \\u0007.\n" + + "1: \"\"\"contains unescaped \\u0007 control char\n" + + " ^\n"); + exception.Description.ShouldBe("Invalid character within BlockString: \\u0007."); + exception.Line.ShouldBe(1); + exception.Column.ShouldBe(23); + } + [Fact] public void Lex_UnterminatedString_ThrowsExceptionWithCorrectMessage() { @@ -391,5 +405,33 @@ public void Lex_UnterminatedStringWithText_ThrowsExceptionWithCorrectMessage() exception.Line.ShouldBe(1); exception.Column.ShouldBe(14); } + + [Fact] + public void Lex_UnterminatedBlockString_ThrowsExceptionWithCorrectMessage() + { + var exception = Should.Throw(() => "\"\"\"".Lex()); + + exception.Message.ShouldBe( + "Syntax Error GraphQL (1:4) Unterminated string.\n" + + "1: \"\"\"\n" + + " ^\n"); + exception.Description.ShouldBe("Unterminated string."); + exception.Line.ShouldBe(1); + exception.Column.ShouldBe(4); + } + + [Fact] + public void Lex_UnterminatedBlockStringWithText_ThrowsExceptionWithCorrectMessage() + { + var exception = Should.Throw(() => "\"\"\"no end triple-quote\"\"".Lex()); + + exception.Message.ShouldBe( + "Syntax Error GraphQL (1:25) Unterminated string.\n" + + "1: \"\"\"no end triple-quote\"\"\n" + + " ^\n"); + exception.Description.ShouldBe("Unterminated string."); + exception.Line.ShouldBe(1); + exception.Column.ShouldBe(25); + } } } diff --git a/src/GraphQLParser/LexerContext.cs b/src/GraphQLParser/LexerContext.cs index 837c38cf..7e42a343 100644 --- a/src/GraphQLParser/LexerContext.cs +++ b/src/GraphQLParser/LexerContext.cs @@ -47,7 +47,16 @@ public Token GetToken() return ReadNumber(); if (code == '"') - return ReadString(); + { + if (_currentIndex + 2 < _source.Length && _source.Span[_currentIndex + 1] == '"' && _source.Span[_currentIndex + 2] == '"') + { + return ReadBlockString(); + } + else + { + return ReadString(); + } + } return Throw_From_GetToken2(code); } @@ -172,6 +181,203 @@ private Token ReadComment() ); } + private Token ReadBlockString() + { + int start = _currentIndex += 2; + char code = NextCode(); + + Span buffer = stackalloc char[4096]; + StringBuilder? sb = null; + + int index = 0; + bool escape = false; //when the last character was \ + bool lastWasCr = false; + + while (_currentIndex < _source.Length) + { + if (code < 0x0020 && code != 0x0009 && code != 0x000A && code != 0x000D) + { + Throw_From_ReadBlockString1(code); + } + + //check for """ + if (code == '"' && _currentIndex + 2 < _source.Length && _source.Span[_currentIndex + 1] == '"' && _source.Span[_currentIndex + 2] == '"') + { + //if last character was \ then go ahead and write out the """, skipping the \ + if (escape) + { + escape = false; + } + else + { + //end of blockstring + break; + } + } + else if (escape) + { + //last character was \ so write the \ and then retry this character with escaped = false + code = '\\'; + _currentIndex--; + escape = false; + } + else if (code == '\\') + { + //this character is a \ so don't write anything yet, but check the next character + escape = true; + code = NextCode(); + lastWasCr = false; + continue; + } + else + { + escape = false; + } + + + if (!(lastWasCr && code == '\n')) + { + //write code + if (index < buffer.Length) + { + buffer[index++] = code == '\r' ? '\n' : code; + } + else // fallback to StringBuilder in case of buffer overflow + { + if (sb == null) + sb = new StringBuilder(buffer.Length * 2); + + for (int i = 0; i < buffer.Length; ++i) + sb.Append(buffer[i]); + + sb.Append(code == '\r' ? '\n' : code); + index = 0; + } + } + + lastWasCr = code == '\r'; + + code = NextCode(); + } + + if (_currentIndex >= _source.Length) + { + Throw_From_ReadString2(); + } + _currentIndex += 2; + + if (sb != null) + { + for (int i = 0; i < index; ++i) + sb.Append(buffer[i]); + } + + //at this point, if sb != null, then sb has the whole string, otherwise buffer (of length index) has the whole string + //also, all line termination combinations have been replaced with LF + + ROM value; + if (sb != null) + { + var chars = new char[sb.Length]; + sb.CopyTo(0, chars, 0, sb.Length); + value = ProcessBuffer(chars); + } + else + { + value = ProcessBuffer(buffer.Slice(0, index)); + } + + return new Token + ( + TokenKind.STRING, + value, + start, + _currentIndex + 1 + ); + + static ROM ProcessBuffer(Span buffer) + { + //scan string to determine maximum valid commonIndent value, + //number of initial blank lines, and number of trailing blank lines + int commonIndent = int.MaxValue; + int initialBlankLines = 1; + int skipLinesAfter; //skip all text after line ###, as determined by the number of trailing blank lines + { + int trailingBlankLines = 0; + int line = 0; + int whitespace = 0; + bool allWhitespace = true; + bool reachedCharacter = false; + for (int index = 0; index < buffer.Length; index++) + { + char code = buffer[index]; + if (code == '\n') + { + if (allWhitespace) + trailingBlankLines += 1; + if (line != 0 && !allWhitespace && whitespace < commonIndent) + commonIndent = whitespace; + line++; + whitespace = 0; + allWhitespace = true; + if (!reachedCharacter) + initialBlankLines++; + } + else if (code == ' ' || code == '\t') + { + if (allWhitespace) + whitespace++; + } + else + { + allWhitespace = false; + if (!reachedCharacter) + initialBlankLines--; + reachedCharacter = true; + trailingBlankLines = 0; + } + } + if (allWhitespace) + trailingBlankLines += 1; + if (line != 0 && !allWhitespace && whitespace < commonIndent) + commonIndent = whitespace; + if (commonIndent == int.MaxValue) + commonIndent = 0; + int lines = line + 1; + skipLinesAfter = lines - trailingBlankLines; + } + + //step through the input, skipping the initial blank lines and the trailing blank lines, + //and skipping the initial blank characters from the start of each line + Span output = buffer.Length <= 4096 ? stackalloc char[buffer.Length] : new char[buffer.Length]; + int outputIndex = 0; + { + int line = 0; + int col = 0; + for (int index = 0; index < buffer.Length; index++) + { + char code = buffer[index]; + if (code == '\n') + { + if (++line >= skipLinesAfter) + break; + col = 0; + if (line > initialBlankLines) + output[outputIndex++] = code; + } + else + { + if (line >= initialBlankLines && (line == 0 || col++ >= commonIndent)) + output[outputIndex++] = code; + } + } + } + + //return the string value from the output buffer + return output.Slice(0, outputIndex).ToString(); + } + } + private Token ReadString() { int start = _currentIndex; @@ -245,6 +451,11 @@ private void Throw_From_ReadString2() throw new GraphQLSyntaxErrorException("Unterminated string.", _source, _currentIndex); } + private void Throw_From_ReadBlockString1(char code) + { + throw new GraphQLSyntaxErrorException($"Invalid character within BlockString: \\u{(int)code:D4}.", _source, _currentIndex); + } + // sets escaped only to true private char ReadCharacterFromString(char currentCharacter, ref bool escaped) { diff --git a/src/GraphQLParser/ParserContext.Parse.cs b/src/GraphQLParser/ParserContext.Parse.cs index 27a3b708..d17d76cd 100644 --- a/src/GraphQLParser/ParserContext.Parse.cs +++ b/src/GraphQLParser/ParserContext.Parse.cs @@ -825,7 +825,7 @@ private GraphQLName ParseName() //look-ahead to next token var token = Lexer.Lex(_source, _currentToken.End); //skip comments - while (_currentToken.Kind != TokenKind.EOF && _currentToken.Kind == TokenKind.COMMENT) + while (token.Kind != TokenKind.EOF && token.Kind == TokenKind.COMMENT) { token = Lexer.Lex(_source, token.End); } diff --git a/src/GraphQLParser/TokenKind.cs b/src/GraphQLParser/TokenKind.cs index 9c66b8ab..2f5951d1 100644 --- a/src/GraphQLParser/TokenKind.cs +++ b/src/GraphQLParser/TokenKind.cs @@ -93,9 +93,21 @@ public enum TokenKind FLOAT = 17, /// + /// A string value, encoded as either a 'string' or 'block string' + ///

/// Strings are sequences of characters wrapped in double‐quotes ("). (ex. "Hello World"). /// White space and other otherwise‐ignored characters are significant within a string value. - ///
+ ///

+ /// Block strings are sequences of characters wrapped in triple‐quotes ("""). White space, line terminators, + /// quote, and backslash characters may all be used unescaped to enable verbatim text. + /// Since block strings represent freeform text often used in indented positions, the string value semantics + /// of a block string excludes uniform indentation and blank initial and trailing lines. + /// Triple-quotes (""") may be escaped as \""" within the block string. No other escape sequences may be used + /// within a block string. + /// + /// + /// Within a block string, line termination sequences (LF, CR, or CRLF) are always replaced with a line-feed (LF) character. + /// STRING = 18, /// @@ -115,6 +127,6 @@ public enum TokenKind /// /// & /// - AMPERSAND = 21 + AMPERSAND = 21, } }