From 4bb96659fa9990991f4b09e1d049bdc66e40fde6 Mon Sep 17 00:00:00 2001 From: Hugo van Rijswijk Date: Wed, 19 Aug 2026 16:28:59 +0200 Subject: [PATCH] Accept names that start with a keyword The fragment-name parser rejected any name that starts with "on". The enum-value parser rejected any name that starts with "true", "false" or "null". Both lookaheads had no word-boundary check. Changes: - `FragmentName` and `EnumValue` now reject only the exact keyword. - `BooleanValue` requires a word boundary after "true" or "false", and backtracks so that `trueStory` can parse as an enum value. - `NullValue` backtracks for the same reason. - `InlineFragment` backtracks its type condition, so that `...onFoo` falls through to the fragment-spread branch. --- modules/core/src/main/scala/parser.scala | 14 ++-- .../src/test/scala/parser/ParserSuite.scala | 79 +++++++++++++++++++ 2 files changed, 87 insertions(+), 6 deletions(-) diff --git a/modules/core/src/main/scala/parser.scala b/modules/core/src/main/scala/parser.scala index cf9e15b3..5349bfb1 100644 --- a/modules/core/src/main/scala/parser.scala +++ b/modules/core/src/main/scala/parser.scala @@ -348,7 +348,7 @@ object GraphQLParser { } def InlineFragment(n: Int): Parser[Ast.Selection.InlineFragment] = - ((TypeCondition.? ~ Directives).with1 ~ SelectionSetN(n)).map { + ((TypeCondition.backtrack.? ~ Directives).with1 ~ SelectionSetN(n)).map { case ((cond, dirs), sel) => Ast.Selection.InlineFragment(cond, dirs, sel) } @@ -371,7 +371,7 @@ object GraphQLParser { (Name <* punctuation(":")) ~ Value lazy val FragmentName: Parser[Ast.Name] = - not(string("on")).with1 *> Name + not(string("on") <* not(charIn(nameSubsequent))).with1 *> Name lazy val FragmentDefinition: Parser[Ast.FragmentDefinition] = ((keyword("fragment") *> FragmentName) ~ TypeCondition ~ Directives ~ SelectionSet).map { @@ -382,11 +382,12 @@ object GraphQLParser { keyword("on") *> NamedType lazy val NullValue: Parser[Ast.Value.NullValue.type] = - keyword("null").as(Ast.Value.NullValue) + keyword("null").backtrack.as(Ast.Value.NullValue) lazy val EnumValue: Parser[Ast.Value.EnumValue] = - (not(string("true") | string("false") | string("null")).with1 *> Name) - .map(Ast.Value.EnumValue.apply) + (not( + (string("true") | string("false") | string("null")) <* not( + charIn(nameSubsequent))).with1 *> Name).map(Ast.Value.EnumValue.apply) def ListValue(n: Int): Parser[Ast.Value.ListValue] = token( @@ -413,7 +414,8 @@ object GraphQLParser { } lazy val BooleanValue: Parser[Ast.Value.BooleanValue] = - token(booleanLiteral).map(Ast.Value.BooleanValue.apply) + token((booleanLiteral <* not(charIn(nameSubsequent))).backtrack) + .map(Ast.Value.BooleanValue.apply) def ObjectField(n: Int): Parser[(Ast.Name, Ast.Value)] = (Name <* punctuation(":")) ~ ValueN(n) diff --git a/modules/core/src/test/scala/parser/ParserSuite.scala b/modules/core/src/test/scala/parser/ParserSuite.scala index 7697f8aa..22357520 100644 --- a/modules/core/src/test/scala/parser/ParserSuite.scala +++ b/modules/core/src/test/scala/parser/ParserSuite.scala @@ -980,6 +980,85 @@ final class ParserSuite extends CatsEffectSuite { assertEquals(parser.parseText("query { # inner\n x } # a\n# b"), Result(expected)) } + test("fragment name that starts with 'on'") { + val query = """ + query { x { ...onlyFriends } } + fragment onlyFriends on X { name } + """ + + val expected = + List( + Operation( + Query, + None, + Nil, + Nil, + List( + Field(None, Name("x"), Nil, Nil, List(FragmentSpread(Name("onlyFriends"), Nil))))), + FragmentDefinition( + Name("onlyFriends"), + Named(Name("X")), + Nil, + List(Field(None, Name("name"), Nil, Nil, Nil))) + ) + + assertEquals(parser.parseText(query), Result(expected)) + } + + test("fragment name 'on' is still rejected") { + assert(parser.parseText("fragment on on X { name }").hasValue == false) + } + + test("enum values that start with 'true', 'false' or 'null'") { + val query = "query { x(a: trueStory, b: falseAlarm, c: nullable) }" + + val expected = + Operation( + Query, + None, + Nil, + Nil, + List( + Field( + None, + Name("x"), + List( + (Name("a"), EnumValue(Name("trueStory"))), + (Name("b"), EnumValue(Name("falseAlarm"))), + (Name("c"), EnumValue(Name("nullable"))) + ), + Nil, + Nil + )) + ) + + assertEquals(parser.parseText(query), Result(List(expected))) + } + + test("bare keywords are still literals, not enum values") { + val query = "query { x(a: true, b: false, c: null) }" + + val expected = + Operation( + Query, + None, + Nil, + Nil, + List( + Field( + None, + Name("x"), + List( + (Name("a"), BooleanValue(true)), + (Name("b"), BooleanValue(false)), + (Name("c"), NullValue) + ), + Nil, + Nil))) + + assertEquals(parser.parseText(query), Result(List(expected))) + } + def mkParser( maxSelectionDepth: Int = GraphQLParser.defaultConfig.maxSelectionDepth, maxSelectionWidth: Int = GraphQLParser.defaultConfig.maxSelectionWidth,