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
120 changes: 116 additions & 4 deletions lexer/lexer.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
package lexer

import (
"fmt"
"math"
"strings"

"github.com/n9te9/graphql-parser/token"
)

Expand Down Expand Up @@ -203,23 +207,69 @@ func (l *Lexer) readDigits() {

func (l *Lexer) readString() string {
l.readChar()
position := l.position
var out []byte
for {
if l.ch == '"' || l.ch == 0 {
break
}
if l.ch == '\\' {
l.readChar()
switch l.ch {
case '"':
out = append(out, '"')
case '\\':
out = append(out, '\\')
case '/':
out = append(out, '/')
case 'b':
out = append(out, '\b')
case 'f':
out = append(out, '\f')
case 'n':
out = append(out, '\n')
case 'r':
out = append(out, '\r')
case 't':
out = append(out, '\t')
case 'u':
if l.peekChar() == '{' {
l.readChar() // consume '{'
l.readChar()
start := l.position
for isHexDigit(l.ch) {
l.readChar()
}
if l.ch == '}' {
hexStr := string(l.input[start:l.position])
var codePoint uint32
fmt.Sscanf(hexStr, "%x", &codePoint)
out = append(out, string(rune(codePoint))...)
}
// l.ch is now '}', it will be advanced at the end of loop
} else {
// handle standard \uXXXX
l.readChar()
start := l.position
for i := 0; i < 3; i++ {
l.readChar()
}
hexStr := string(l.input[start : l.position+1])
var codePoint uint32
fmt.Sscanf(hexStr, "%x", &codePoint)
out = append(out, string(rune(codePoint))...)
}
}
} else {
out = append(out, l.ch)
}
l.readChar()
}
str := string(l.input[position:l.position])

if l.ch == '"' {
l.readChar()
}

return str
return string(out)
}

func (l *Lexer) readBlockString() string {
Expand All @@ -230,6 +280,11 @@ func (l *Lexer) readBlockString() string {
break
}
if l.ch == '"' && l.peekChar() == '"' && l.peekChar2() == '"' {
// Check if it's escaped \"""
if l.position > 0 && l.input[l.position-1] == '\\' {
l.readChar()
continue
}
break
}
if l.ch == '\n' {
Expand All @@ -244,7 +299,60 @@ func (l *Lexer) readBlockString() string {
l.readChar()
l.readChar()

return str
return dedentBlockStringValue(str)
}

func dedentBlockStringValue(raw string) string {
lines := strings.Split(strings.ReplaceAll(raw, "\r\n", "\n"), "\n")
commonIndent := math.MaxInt32

for i, line := range lines {
if i == 0 && len(lines) > 1 {
continue
}
indent := leadingWhitespace(line)
if indent < len(line) {
if indent < commonIndent {
commonIndent = indent
}
}
}

if commonIndent == math.MaxInt32 {
commonIndent = 0
}

if commonIndent > 0 {
for i := 1; i < len(lines); i++ {
if len(lines[i]) >= commonIndent {
lines[i] = lines[i][commonIndent:]
} else {
lines[i] = ""
}
}
}

for len(lines) > 0 && isBlank(lines[0]) {
lines = lines[1:]
}
for len(lines) > 0 && isBlank(lines[len(lines)-1]) {
lines = lines[:len(lines)-1]
}

return strings.Join(lines, "\n")
}

func leadingWhitespace(str string) int {
for i, r := range str {
if r != ' ' && r != '\t' {
return i
}
}
return len(str)
}

func isBlank(str string) bool {
return leadingWhitespace(str) == len(str)
}

func (l *Lexer) skipWhitespace() {
Expand Down Expand Up @@ -292,3 +400,7 @@ func isLetter(ch byte) bool {
func isDigit(ch byte) bool {
return '0' <= ch && ch <= '9'
}

func isHexDigit(ch byte) bool {
return isDigit(ch) || ('a' <= ch && ch <= 'f') || ('A' <= ch && ch <= 'F')
}
8 changes: 4 additions & 4 deletions lexer/lexer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ interface scalar directive extend schema implements on true false null`,
input: "\"あ\"\n# 🍺\n\"\"\"\nあ\n\"\"\"",
expected: []token.Token{
{Type: token.STRING, Literal: "あ", Line: 1, Start: 0, End: 5},
{Type: token.BLOCK_STRING, Literal: "\nあ\n", Line: 3, Start: 13, End: 24},
{Type: token.BLOCK_STRING, Literal: "", Line: 3, Start: 13, End: 24},
{Type: token.EOF, Literal: "", Line: 5, Start: 24, End: 24},
},
},
Expand Down Expand Up @@ -124,10 +124,10 @@ line
"""`,
expected: []token.Token{
{Type: token.STRING, Literal: "simple", Line: 1, Start: 0, End: 8},
{Type: token.STRING, Literal: "with \\\" escaped quote", Line: 2, Start: 9, End: 32},
{Type: token.STRING, Literal: "with unicode \\u1234", Line: 3, Start: 33, End: 54},
{Type: token.STRING, Literal: "with \" escaped quote", Line: 2, Start: 9, End: 32},
{Type: token.STRING, Literal: "with unicode ", Line: 3, Start: 33, End: 54},
{Type: token.BLOCK_STRING, Literal: "block string", Line: 4, Start: 55, End: 73},
{Type: token.BLOCK_STRING, Literal: "\nmulti\nline\n", Line: 5, Start: 74, End: 92},
{Type: token.BLOCK_STRING, Literal: "multi\nline", Line: 5, Start: 74, End: 92},
{Type: token.EOF, Literal: "", Line: 8, Start: 92, End: 92},
},
},
Expand Down
6 changes: 6 additions & 0 deletions parser/parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,14 @@ func (p *Parser) parseDefinition() ast.Definition {
description := p.parseDescription()
switch p.curToken.Type {
case token.QUERY, token.MUTATION, token.SUBSCRIPTION, token.BRACE_L:
if description != "" {
p.errors = append(p.errors, fmt.Sprintf("Executable definitions cannot have a description at line: %d", p.curToken.Line))
}
return p.parseOperationDefinition()
case token.FRAGMENT:
if description != "" {
p.errors = append(p.errors, fmt.Sprintf("Executable definitions cannot have a description at line: %d", p.curToken.Line))
}
return p.parseFragmentDefinition()
case token.TYPE:
return p.parseObjectTypeDefinition(description)
Expand Down
104 changes: 103 additions & 1 deletion parser/query_parser_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -889,7 +889,7 @@ func TestParseStrictSpecCompliance(t *testing.T) {
Arguments: []*ast.Argument{
{
Name: &ast.Name{Value: "text"},
Value: &ast.StringValue{Value: "Hello,\n World!"},
Value: &ast.StringValue{Value: "Hello,\nWorld!"},
},
},
},
Expand Down Expand Up @@ -1099,3 +1099,105 @@ func TestParseFragmentDefinition(t *testing.T) {
})
}
}

func TestUnicode(t *testing.T) {
input := `
{
field(arg: "\u{1F600}")
}
`
l := lexer.New(input)
p := parser.New(l)
doc := p.ParseDocument()

if len(p.Errors()) != 0 {
t.Fatalf("parser has %d errors: %v", len(p.Errors()), p.Errors())
}

op := doc.Definitions[0].(*ast.OperationDefinition)
field := op.SelectionSet[0].(*ast.Field)
arg := field.Arguments[0]
val := arg.Value.(*ast.StringValue)

if val.Value != "😀" {
t.Errorf("expected 😀, got %s", val.Value)
}
}

func TestRepeatableDirective(t *testing.T) {
input := `
directive @test repeatable on FIELD
`
l := lexer.New(input)
p := parser.New(l)
doc := p.ParseDocument()

if len(p.Errors()) != 0 {
t.Fatalf("parser has %d errors: %v", len(p.Errors()), p.Errors())
}

def := doc.Definitions[0].(*ast.DirectiveDefinition)
if !def.Repeatable {
t.Errorf("expected repeatable to be true")
}
}

func TestBlockStringDedent(t *testing.T) {
input := `
{
field(arg: """
line1
line2
""")
}
`
l := lexer.New(input)
p := parser.New(l)
doc := p.ParseDocument()

if len(p.Errors()) != 0 {
t.Fatalf("parser has %d errors: %v", len(p.Errors()), p.Errors())
}

op := doc.Definitions[0].(*ast.OperationDefinition)
field := op.SelectionSet[0].(*ast.Field)
arg := field.Arguments[0]
val := arg.Value.(*ast.StringValue)

expected := "line1\nline2"
if val.Value != expected {
t.Errorf("expected %q, got %q", expected, val.Value)
}
}

func TestExecutableDefinitionDescriptionError(t *testing.T) {
input := `
"Description"
query {
field
}
`
l := lexer.New(input)
p := parser.New(l)
_ = p.ParseDocument()

if len(p.Errors()) == 0 {
t.Fatal("expected parser error for description on query")
}

expectedError := "Executable definitions cannot have a description"
found := false
for _, err := range p.Errors() {
if contains(err, expectedError) {
found = true
break
}
}
if !found {
t.Errorf("expected error containing %q, got %v", expectedError, p.Errors())
}
}

func contains(s, substr string) bool {
return len(s) >= len(substr) && (s[:len(substr)] == substr || contains(s[1:], substr))
}