Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

hclsyntax: Introduce token-based parse methods #383

Closed
wants to merge 1 commit into from
Closed
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
6 changes: 5 additions & 1 deletion hclsyntax/peeker.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,11 @@ func (p *peeker) nextToken() (Token, int) {
// if we fall out here then we'll return the EOF token, and leave
// our index pointed off the end of the array so we'll keep
// returning EOF in future too.
return p.Tokens[len(p.Tokens)-1], len(p.Tokens)
return p.lastToken(), len(p.Tokens)
}

func (p *peeker) lastToken() Token {
return p.Tokens[len(p.Tokens)-1]
}

func (p *peeker) includingNewlines() bool {
Expand Down
94 changes: 94 additions & 0 deletions hclsyntax/public.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package hclsyntax

import (
"fmt"

"github.com/hashicorp/hcl/v2"
)

Expand Down Expand Up @@ -36,6 +38,98 @@ func ParseConfig(src []byte, filename string, start hcl.Pos) (*hcl.File, hcl.Dia
}, diags
}

// ParseBodyFromTokens parses given tokens as a body of a whole HCL config file,
// returning a *Body representing its contents.
func ParseBodyFromTokens(tokens Tokens, end TokenType) (*Body, hcl.Diagnostics) {
peeker := newPeeker(tokens, false)
parser := &parser{peeker: peeker}
return parser.ParseBody(end)
}

// ParseBodyItemFromTokens parses given tokens as a body item
// such as an attribute or a block, returning such item as Node
func ParseBodyItemFromTokens(tokens Tokens) (Node, hcl.Diagnostics) {
if len(tokens) == 0 {
return nil, nil
}

peeker := newPeeker(tokens, false)

// Sanity checks to avoid surprises
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's say "Initial checks" instead here, for inclusiveness. ❤️

firstToken := peeker.Peek()
if firstToken.Type != TokenIdent {
return nil, hcl.Diagnostics{
&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Identifier not found",
Detail: fmt.Sprintf("Expected definition to start with an identifier, %s found",
firstToken.Type),
Subject: &firstToken.Range,
},
}
}
lastToken := peeker.lastToken()
if lastToken.Type != TokenEOF &&
lastToken.Type != TokenNewline {
return nil, hcl.Diagnostics{
&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Unterminated definition",
Detail: fmt.Sprintf("Expected definition terminated either by a newline or EOF, %s found",
lastToken.Type),
Subject: &lastToken.Range,
},
}
}

parser := &parser{peeker: peeker}
return parser.ParseBodyItem()
}

// ParseBlockFromTokens parses given tokens as a block, returning
// diagnostic error in case the body item isn't a block
func ParseBlockFromTokens(tokens Tokens) (*Block, hcl.Diagnostics) {
bi, diags := ParseBodyItemFromTokens(tokens)
if bi == nil {
return nil, diags
}

block, ok := bi.(*Block)
if !ok {
rng := bi.Range()
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: fmt.Sprintf("Unexpected definition (%T)", bi),
Detail: fmt.Sprintf("Expected a block definition, but found %T instead", bi),
Subject: &rng,
})
}

return block, diags
}

// ParseAttributeFromTokens parses given tokens as an attribute
// diagnostic error in case the body item isn't an attribute
func ParseAttributeFromTokens(tokens Tokens) (*Attribute, hcl.Diagnostics) {
bi, diags := ParseBodyItemFromTokens(tokens)
if bi == nil {
return nil, diags
}

block, ok := bi.(*Attribute)
if !ok {
rng := bi.Range()
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: fmt.Sprintf("Unexpected definition (%T)", bi),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Usually we've written errors like this with an end-user target audience in mind, so that a caller can use a call to this function to represent the assertion "the user should have written an attribute" and automatically get a good error message if the user didn't provide one.

With that said, I'm not totally sure about that framing for these new functions. It could well be that we consider it a programming error on the part of the caller to pass in tokens representing a block here, in which case I suppose this could be okay although in cases like that HCL has typically used panic rather than error diagnostics so far. 🤔

As a compromise, what do you think about taking the text of the message HCL would normally return if the schema calls for attribute syntax but the user wrote a block, and reducing it to fit what we can determine here without a schema? For example:

Error: Unsupported block type

Blocks of type "example" are not expected here.

(To do this would, I realize, require type-asserting the bi to (*Block) first, so I guess there would still need to be a generic fallback for the short-never-happen case of it not being a block, but that would could presumably be a panic because it would only ever happen if there were a bug inside Parser.ParseBodyItem.)

(I have similar feedback for the opposite case of ParseBlockFromTokens above, but I won't write it all out again. 😄 )

Detail: fmt.Sprintf("Expected an attribute, but found %T instead", bi),
Subject: &rng,
})
}

return block, diags
}

// ParseExpression parses the given buffer as a standalone HCL expression,
// returning it as an instance of Expression.
func ParseExpression(src []byte, filename string, start hcl.Pos) (Expression, hcl.Diagnostics) {
Expand Down
138 changes: 138 additions & 0 deletions hclsyntax/public_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ package hclsyntax

import (
"testing"

"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"github.com/zclconf/go-cty/cty"
)

func TestValidIdentifier(t *testing.T) {
Expand Down Expand Up @@ -44,3 +48,137 @@ func TestValidIdentifier(t *testing.T) {
})
}
}

func TestParseBlockFromTokens_withoutNewline(t *testing.T) {
_, diags := ParseBlockFromTokens(testBlockTokensWithoutNewline)
if len(diags) != 1 {
t.Fatalf("Expected exactly 1 diagnostic, %d given", len(diags))
}
}

func TestParseBlockFromTokens_block(t *testing.T) {
b, diags := ParseBlockFromTokens(testBlockTokensWithNewline)
if len(diags) > 0 {
t.Fatal(diags)
}
expectedBlock := &Block{
Type: "blocktype",
Labels: []string{"onelabel"},
Body: &Body{
Attributes: Attributes{
"attr": &Attribute{
Name: "attr",
Expr: &LiteralValueExpr{
Val: cty.NumberIntVal(42),
},
},
},
Blocks: Blocks{},
},
}
opts := cmp.Options{
cmpopts.IgnoreUnexported(Body{}),
cmpopts.IgnoreUnexported(cty.Value{}),
}
opts = append(opts, optsIgnoreRanges...)
if diff := cmp.Diff(expectedBlock, b, opts); diff != "" {
t.Fatalf("Blocks don't match:\n%s", diff)
}
}

func TestParseBlockFromTokens_invalid(t *testing.T) {
_, diags := ParseBlockFromTokens(invalidTokens)
if len(diags) != 1 {
t.Fatalf("Expected exactly 1 diagnostic, %d given", len(diags))
}
}

func TestParseBlockFromTokens_attr(t *testing.T) {
_, diags := ParseBlockFromTokens(testAttributeTokensValid)
if len(diags) != 1 {
t.Fatalf("Expected exactly 1 diagnostic, given:\n%#v", diags)
}
}

func TestParseAttributeFromTokens_attr(t *testing.T) {
b, diags := ParseAttributeFromTokens(testAttributeTokensValid)
if len(diags) > 0 {
t.Fatal(diags)
}
expectedAttribute := &Attribute{
Name: "attr",
Expr: &LiteralValueExpr{
Val: cty.NumberIntVal(79),
},
}
opts := cmp.Options{
cmpopts.IgnoreFields(Token{}, "Range"),
cmpopts.IgnoreUnexported(Attribute{}),
cmpopts.IgnoreUnexported(cty.Value{}),
}
if diff := cmp.Diff(expectedAttribute, b, opts); diff != "" {
t.Fatalf("Blocks don't match:\n%s", diff)
}
}

func TestParseAttributeFromTokens_invalid(t *testing.T) {
_, diags := ParseAttributeFromTokens(invalidTokens)
if len(diags) != 1 {
t.Fatalf("Expected exactly 1 diagnostic, %d given", len(diags))
}
}

func TestParseAttributeFromTokens_block(t *testing.T) {
_, diags := ParseAttributeFromTokens(testBlockTokensWithNewline)
if len(diags) != 1 {
t.Fatalf("Expected exactly 1 diagnostic, given:\n%#v", diags)
}
}

var optsIgnoreRanges = []cmp.Option{
cmpopts.IgnoreFields(Token{}, "Range"),
cmpopts.IgnoreFields(Attribute{}, "SrcRange", "NameRange", "EqualsRange"),
cmpopts.IgnoreFields(Block{}, "TypeRange", "LabelRanges", "OpenBraceRange", "CloseBraceRange"),
cmpopts.IgnoreFields(LiteralValueExpr{}, "SrcRange"),
cmpopts.IgnoreFields(Body{}, "SrcRange", "EndRange"),
}

var testAttributeTokensValid = Tokens{
{Type: TokenIdent, Bytes: []byte("attr")},
{Type: TokenEqual, Bytes: []byte("=")},
{Type: TokenNumberLit, Bytes: []byte("79")},
{Type: TokenNewline, Bytes: []byte("\n")},
}

var testBlockTokensWithNewline = Tokens{
{Type: TokenIdent, Bytes: []byte("blocktype")},
{Type: TokenOQuote, Bytes: []byte(`"`)},
{Type: TokenQuotedLit, Bytes: []byte("onelabel")},
{Type: TokenCQuote, Bytes: []byte(`"`)},
{Type: TokenOBrace, Bytes: []byte("{")},
{Type: TokenNewline, Bytes: []byte("\n")},
{Type: TokenIdent, Bytes: []byte("attr")},
{Type: TokenEqual, Bytes: []byte("=")},
{Type: TokenNumberLit, Bytes: []byte("42")},
{Type: TokenNewline, Bytes: []byte("\n")},
{Type: TokenCBrace, Bytes: []byte("}")},
{Type: TokenNewline, Bytes: []byte("\n")},
}

var testBlockTokensWithoutNewline = Tokens{
{Type: TokenIdent, Bytes: []byte("blocktype")},
{Type: TokenOQuote, Bytes: []byte(`"`)},
{Type: TokenQuotedLit, Bytes: []byte("onelabel")},
{Type: TokenCQuote, Bytes: []byte(`"`)},
{Type: TokenOBrace, Bytes: []byte("{")},
{Type: TokenNewline, Bytes: []byte("\n")},
{Type: TokenIdent, Bytes: []byte("attr")},
{Type: TokenEqual, Bytes: []byte("=")},
{Type: TokenNumberLit, Bytes: []byte("42")},
{Type: TokenNewline, Bytes: []byte("\n")},
{Type: TokenCBrace, Bytes: []byte("}")},
}

var invalidTokens = Tokens{
{Type: TokenNewline, Bytes: []byte("\n")},
}