diff --git a/lexer/lexer.go b/lexer/lexer.go index 6b0176c..a407959 100644 --- a/lexer/lexer.go +++ b/lexer/lexer.go @@ -1,6 +1,10 @@ package lexer import ( + "fmt" + "math" + "strings" + "github.com/n9te9/graphql-parser/token" ) @@ -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 { @@ -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' { @@ -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() { @@ -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') +} diff --git a/lexer/lexer_test.go b/lexer/lexer_test.go index e25eb1b..3087316 100644 --- a/lexer/lexer_test.go +++ b/lexer/lexer_test.go @@ -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}, }, }, @@ -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}, }, }, diff --git a/parser/parser.go b/parser/parser.go index 6203f91..a99e199 100644 --- a/parser/parser.go +++ b/parser/parser.go @@ -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) diff --git a/parser/query_parser_test.go b/parser/query_parser_test.go index 620e9e8..5e807e4 100644 --- a/parser/query_parser_test.go +++ b/parser/query_parser_test.go @@ -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!"}, }, }, }, @@ -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)) +}