Beginnings of automated test suite for spec compliance.

The test cases are far from complete, but the mechanism is in place.
This commit is contained in:
Martin Atkins 2018-08-12 18:23:14 -07:00
commit c79a9bd509
42 changed files with 1481 additions and 2 deletions

View File

@ -28,6 +28,7 @@ var (
outputFile = flag.StringP("out", "o", "", "write to the given file, instead of stdout")
diagsFormat = flag.StringP("diags", "", "", "format any returned diagnostics in the given format; currently only \"json\" is accepted")
showVarRefs = flag.BoolP("var-refs", "", false, "rather than decoding input, produce a JSON description of the variables referenced by it")
withType = flag.BoolP("with-type", "", false, "include an additional object level at the top describing the HCL-oriented type of the result value")
showVersion = flag.BoolP("version", "v", false, "show the version number and immediately exit")
)
@ -146,7 +147,13 @@ func realmain(args []string) error {
}
} else {
for _, filename := range args {
f, fDiags := parser.ParseHCLFile(filename)
var f *hcl.File
var fDiags hcl.Diagnostics
if strings.HasSuffix(filename, ".json") {
f, fDiags = parser.ParseJSONFile(filename)
} else {
f, fDiags = parser.ParseHCLFile(filename)
}
diags = append(diags, fDiags...)
if !fDiags.HasErrors() {
bodies = append(bodies, f.Body)
@ -185,7 +192,13 @@ func realmain(args []string) error {
os.Exit(2)
}
out, err := ctyjson.Marshal(val, val.Type())
wantType := val.Type()
if *withType {
// We'll instead ask to encode as dynamic, which will make the
// marshaler include type information.
wantType = cty.DynamicPseudoType
}
out, err := ctyjson.Marshal(val, wantType)
if err != nil {
return err
}

View File

@ -0,0 +1,4 @@
# `hclspecsuite`
`hclspecsuite` is the test harness for
[the HCL specification test suite](../../specsuite/README.md).

View File

@ -0,0 +1,108 @@
package main
import (
"encoding/json"
"fmt"
"github.com/hashicorp/hcl2/hcl"
)
func decodeJSONDiagnostics(src []byte) hcl.Diagnostics {
type PosJSON struct {
Line int `json:"line"`
Column int `json:"column"`
Byte int `json:"byte"`
}
type RangeJSON struct {
Filename string `json:"filename"`
Start PosJSON `json:"start"`
End PosJSON `json:"end"`
}
type DiagnosticJSON struct {
Severity string `json:"severity"`
Summary string `json:"summary"`
Detail string `json:"detail,omitempty"`
Subject *RangeJSON `json:"subject,omitempty"`
}
type DiagnosticsJSON struct {
Diagnostics []DiagnosticJSON `json:"diagnostics"`
}
var raw DiagnosticsJSON
var diags hcl.Diagnostics
err := json.Unmarshal(src, &raw)
if err != nil {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Failed to parse hcldec diagnostics result",
Detail: fmt.Sprintf("Sub-program hcldec produced invalid diagnostics: %s.", err),
})
return diags
}
if len(raw.Diagnostics) == 0 {
return nil
}
diags = make(hcl.Diagnostics, 0, len(raw.Diagnostics))
for _, rawDiag := range raw.Diagnostics {
var severity hcl.DiagnosticSeverity
switch rawDiag.Severity {
case "error":
severity = hcl.DiagError
case "warning":
severity = hcl.DiagWarning
default:
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Failed to parse hcldec diagnostics result",
Detail: fmt.Sprintf("Diagnostic has unsupported severity %q.", rawDiag.Severity),
})
continue
}
diag := &hcl.Diagnostic{
Severity: severity,
Summary: rawDiag.Summary,
Detail: rawDiag.Detail,
}
if rawDiag.Subject != nil {
rawRange := rawDiag.Subject
diag.Subject = &hcl.Range{
Filename: rawRange.Filename,
Start: hcl.Pos{
Line: rawRange.Start.Line,
Column: rawRange.Start.Column,
Byte: rawRange.Start.Byte,
},
End: hcl.Pos{
Line: rawRange.End.Line,
Column: rawRange.End.Column,
Byte: rawRange.End.Byte,
},
}
}
diags = append(diags, diag)
}
return diags
}
func severityString(severity hcl.DiagnosticSeverity) string {
switch severity {
case hcl.DiagError:
return "error"
case hcl.DiagWarning:
return "warning"
default:
return "unsupported-severity"
}
}
func rangeString(rng hcl.Range) string {
return fmt.Sprintf(
"from line %d column %d byte %d to line %d column %d byte %d",
rng.Start.Line, rng.Start.Column, rng.Start.Byte,
rng.End.Line, rng.End.Column, rng.End.Byte,
)
}

8
cmd/hclspecsuite/log.go Normal file
View File

@ -0,0 +1,8 @@
package main
import (
"github.com/hashicorp/hcl2/hcl"
)
type LogBeginCallback func(testName string, testFile *TestFile)
type LogProblemsCallback func(testName string, testFile *TestFile, diags hcl.Diagnostics)

71
cmd/hclspecsuite/main.go Normal file
View File

@ -0,0 +1,71 @@
package main
import (
"fmt"
"os"
"os/exec"
"golang.org/x/crypto/ssh/terminal"
"github.com/hashicorp/hcl2/hcl"
"github.com/hashicorp/hcl2/hclparse"
)
func main() {
os.Exit(realMain(os.Args[1:]))
}
func realMain(args []string) int {
if len(args) != 2 {
fmt.Fprintf(os.Stderr, "Usage: hclspecsuite <tests-dir> <hcldec-file>\n")
return 2
}
testsDir := args[0]
hcldecPath := args[1]
hcldecPath, err := exec.LookPath(hcldecPath)
if err != nil {
fmt.Fprintf(os.Stderr, "%s\n", err)
return 2
}
parser := hclparse.NewParser()
color := terminal.IsTerminal(int(os.Stderr.Fd()))
w, _, err := terminal.GetSize(int(os.Stdout.Fd()))
if err != nil {
w = 80
}
diagWr := hcl.NewDiagnosticTextWriter(os.Stderr, parser.Files(), uint(w), color)
var diagCount int
runner := &Runner{
parser: parser,
hcldecPath: hcldecPath,
baseDir: testsDir,
logBegin: func(name string, file *TestFile) {
fmt.Printf("- %s\n", name)
},
logProblems: func(name string, file *TestFile, diags hcl.Diagnostics) {
if len(diags) != 0 {
os.Stderr.WriteString("\n")
diagWr.WriteDiagnostics(diags)
diagCount += len(diags)
}
fmt.Printf("- %s\n", name)
},
}
diags := runner.Run()
if len(diags) != 0 {
os.Stderr.WriteString("\n\n\n== Test harness problems:\n\n")
diagWr.WriteDiagnostics(diags)
diagCount += len(diags)
}
if diagCount > 0 {
return 2
}
return 0
}

521
cmd/hclspecsuite/runner.go Normal file
View File

@ -0,0 +1,521 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"io/ioutil"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"github.com/zclconf/go-cty/cty"
"github.com/zclconf/go-cty/cty/convert"
ctyjson "github.com/zclconf/go-cty/cty/json"
"github.com/hashicorp/hcl2/ext/typeexpr"
"github.com/hashicorp/hcl2/hcl"
"github.com/hashicorp/hcl2/hclparse"
)
type Runner struct {
parser *hclparse.Parser
hcldecPath string
baseDir string
logBegin LogBeginCallback
logProblems LogProblemsCallback
}
func (r *Runner) Run() hcl.Diagnostics {
return r.runDir(r.baseDir)
}
func (r *Runner) runDir(dir string) hcl.Diagnostics {
var diags hcl.Diagnostics
infos, err := ioutil.ReadDir(dir)
if err != nil {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Failed to read test directory",
Detail: fmt.Sprintf("The directory %q could not be opened: %s.", dir, err),
})
return diags
}
var tests []string
var subDirs []string
for _, info := range infos {
name := info.Name()
if strings.HasPrefix(name, ".") {
continue
}
if info.IsDir() {
subDirs = append(subDirs, name)
}
if strings.HasSuffix(name, ".t") {
tests = append(tests, name)
}
}
sort.Strings(tests)
sort.Strings(subDirs)
for _, filename := range tests {
filename = filepath.Join(dir, filename)
testDiags := r.runTest(filename)
diags = append(diags, testDiags...)
}
for _, dirName := range subDirs {
dir := filepath.Join(dir, dirName)
dirDiags := r.runDir(dir)
diags = append(diags, dirDiags...)
}
return diags
}
func (r *Runner) runTest(filename string) hcl.Diagnostics {
prettyName := r.prettyTestName(filename)
tf, diags := r.LoadTestFile(filename)
if diags.HasErrors() {
// We'll still log, so it's clearer which test the diagnostics belong to.
if r.logBegin != nil {
r.logBegin(prettyName, nil)
}
if r.logProblems != nil {
r.logProblems(prettyName, nil, diags)
return nil // don't duplicate the diagnostics we already reported
}
return diags
}
if r.logBegin != nil {
r.logBegin(prettyName, tf)
}
basePath := filename[:len(filename)-2]
specFilename := basePath + ".hcldec"
nativeFilename := basePath + ".hcl"
jsonFilename := basePath + ".hcl.json"
// We'll add the source code of the spec file to our own parser, even
// though it'll actually be parsed by the hcldec child process, since that
// way we can produce nice diagnostic messages if hcldec fails to process
// the spec file.
src, err := ioutil.ReadFile(specFilename)
if err == nil {
r.parser.AddFile(specFilename, &hcl.File{
Bytes: src,
})
}
if _, err := os.Stat(specFilename); err != nil {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Missing .hcldec file",
Detail: fmt.Sprintf("No specification file for test %s: %s.", prettyName, err),
})
return diags
}
if _, err := os.Stat(nativeFilename); err == nil {
moreDiags := r.runTestInput(specFilename, nativeFilename, tf)
diags = append(diags, moreDiags...)
}
if _, err := os.Stat(jsonFilename); err == nil {
moreDiags := r.runTestInput(specFilename, jsonFilename, tf)
diags = append(diags, moreDiags...)
}
if r.logProblems != nil {
r.logProblems(prettyName, nil, diags)
return nil // don't duplicate the diagnostics we already reported
}
return diags
}
func (r *Runner) runTestInput(specFilename, inputFilename string, tf *TestFile) hcl.Diagnostics {
// We'll add the source code of the input file to our own parser, even
// though it'll actually be parsed by the hcldec child process, since that
// way we can produce nice diagnostic messages if hcldec fails to process
// the input file.
src, err := ioutil.ReadFile(inputFilename)
if err == nil {
r.parser.AddFile(inputFilename, &hcl.File{
Bytes: src,
})
}
var diags hcl.Diagnostics
if tf.ChecksTraversals {
gotTraversals, moreDiags := r.hcldecVariables(specFilename, inputFilename)
diags = append(diags, moreDiags...)
if !moreDiags.HasErrors() {
expected := tf.ExpectedTraversals
for _, got := range gotTraversals {
e := findTraversalSpec(got, expected)
rng := got.SourceRange()
if e == nil {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Unexpected traversal",
Detail: "Detected traversal that is not indicated as expected in the test file.",
Subject: &rng,
})
} else {
moreDiags := checkTraversalsMatch(got, inputFilename, e)
diags = append(diags, moreDiags...)
}
}
// Look for any traversals that didn't show up at all.
for _, e := range expected {
if t := findTraversalForSpec(e, gotTraversals); t == nil {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Missing expected traversal",
Detail: "This expected traversal was not detected.",
Subject: e.Traversal.SourceRange().Ptr(),
})
}
}
}
}
val, transformDiags := r.hcldecTransform(specFilename, inputFilename)
if len(tf.ExpectedDiags) == 0 {
diags = append(diags, transformDiags...)
if transformDiags.HasErrors() {
// If hcldec failed then there's no point in continuing.
return diags
}
if errs := val.Type().TestConformance(tf.ResultType); len(errs) > 0 {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Incorrect result type",
Detail: fmt.Sprintf(
"Input file %s produced %s, but was expecting %s.",
inputFilename, typeexpr.TypeString(val.Type()), typeexpr.TypeString(tf.ResultType),
),
})
}
if tf.Result != cty.NilVal {
cmpVal, err := convert.Convert(tf.Result, tf.ResultType)
if err != nil {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Incorrect type for result value",
Detail: fmt.Sprintf(
"Result does not conform to the given result type: %s.", err,
),
Subject: &tf.ResultRange,
})
} else {
if !val.RawEquals(cmpVal) {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Incorrect result value",
Detail: fmt.Sprintf(
"Input file %s produced %#v, but was expecting %#v.",
inputFilename, val, tf.Result,
),
})
}
}
}
} else {
// We're expecting diagnostics, and so we'll need to correlate the
// severities and source ranges of our actual diagnostics against
// what we were expecting.
type DiagnosticEntry struct {
Severity hcl.DiagnosticSeverity
Range hcl.Range
}
got := make(map[DiagnosticEntry]*hcl.Diagnostic)
want := make(map[DiagnosticEntry]hcl.Range)
for _, diag := range transformDiags {
if diag.Subject == nil {
// Sourceless diagnostics can never be expected, so we'll just
// pass these through as-is and assume they are hcldec
// operational errors.
diags = append(diags, diag)
continue
}
if diag.Subject.Filename != inputFilename {
// If the problem is for something other than the input file
// then it can't be expected.
diags = append(diags, diag)
continue
}
entry := DiagnosticEntry{
Severity: diag.Severity,
Range: *diag.Subject,
}
got[entry] = diag
}
for _, e := range tf.ExpectedDiags {
e.Range.Filename = inputFilename // assumed here, since we don't allow any other filename to be expected
entry := DiagnosticEntry{
Severity: e.Severity,
Range: e.Range,
}
want[entry] = e.DeclRange
}
for gotEntry, diag := range got {
if _, wanted := want[gotEntry]; !wanted {
// Pass through the diagnostic itself so the user can see what happened
diags = append(diags, diag)
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Unexpected diagnostic",
Detail: fmt.Sprintf(
"No %s diagnostic was expected %s. The unexpected diagnostic was shown above.",
severityString(gotEntry.Severity), rangeString(gotEntry.Range),
),
Subject: &gotEntry.Range,
})
}
}
for wantEntry, declRange := range want {
if _, gotted := got[wantEntry]; !gotted {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Missing expected diagnostic",
Detail: fmt.Sprintf(
"No %s diagnostic was generated %s.",
severityString(wantEntry.Severity), rangeString(wantEntry.Range),
),
Subject: &declRange,
})
}
}
}
return diags
}
func (r *Runner) hcldecTransform(specFile, inputFile string) (cty.Value, hcl.Diagnostics) {
var diags hcl.Diagnostics
var outBuffer bytes.Buffer
var errBuffer bytes.Buffer
cmd := &exec.Cmd{
Path: r.hcldecPath,
Args: []string{
r.hcldecPath,
"--spec=" + specFile,
"--diags=json",
"--with-type",
inputFile,
},
Stdout: &outBuffer,
Stderr: &errBuffer,
}
err := cmd.Run()
if err != nil {
if _, isExit := err.(*exec.ExitError); !isExit {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Failed to run hcldec",
Detail: fmt.Sprintf("Sub-program hcldec failed to start: %s.", err),
})
return cty.DynamicVal, diags
}
// If we exited unsuccessfully then we'll expect diagnostics on stderr
moreDiags := decodeJSONDiagnostics(errBuffer.Bytes())
diags = append(diags, moreDiags...)
return cty.DynamicVal, diags
} else {
// Otherwise, we expect a JSON result value on stdout. Since we used
// --with-type above, we can decode as DynamicPseudoType to recover
// exactly the type that was saved, without the usual JSON lossiness.
val, err := ctyjson.Unmarshal(outBuffer.Bytes(), cty.DynamicPseudoType)
if err != nil {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Failed to parse hcldec result",
Detail: fmt.Sprintf("Sub-program hcldec produced an invalid result: %s.", err),
})
return cty.DynamicVal, diags
}
return val, diags
}
}
func (r *Runner) hcldecVariables(specFile, inputFile string) ([]hcl.Traversal, hcl.Diagnostics) {
var diags hcl.Diagnostics
var outBuffer bytes.Buffer
var errBuffer bytes.Buffer
cmd := &exec.Cmd{
Path: r.hcldecPath,
Args: []string{
r.hcldecPath,
"--spec=" + specFile,
"--diags=json",
"--var-refs",
inputFile,
},
Stdout: &outBuffer,
Stderr: &errBuffer,
}
err := cmd.Run()
if err != nil {
if _, isExit := err.(*exec.ExitError); !isExit {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Failed to run hcldec",
Detail: fmt.Sprintf("Sub-program hcldec (evaluating input) failed to start: %s.", err),
})
return nil, diags
}
// If we exited unsuccessfully then we'll expect diagnostics on stderr
moreDiags := decodeJSONDiagnostics(errBuffer.Bytes())
diags = append(diags, moreDiags...)
return nil, diags
} else {
// Otherwise, we expect a JSON description of the traversals on stdout.
type PosJSON struct {
Line int `json:"line"`
Column int `json:"column"`
Byte int `json:"byte"`
}
type RangeJSON struct {
Filename string `json:"filename"`
Start PosJSON `json:"start"`
End PosJSON `json:"end"`
}
type StepJSON struct {
Kind string `json:"kind"`
Name string `json:"name,omitempty"`
Key json.RawMessage `json:"key,omitempty"`
Range RangeJSON `json:"range"`
}
type TraversalJSON struct {
Steps []StepJSON `json:"steps"`
}
var raw []TraversalJSON
err := json.Unmarshal(outBuffer.Bytes(), &raw)
if err != nil {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Failed to parse hcldec result",
Detail: fmt.Sprintf("Sub-program hcldec (with --var-refs) produced an invalid result: %s.", err),
})
return nil, diags
}
var ret []hcl.Traversal
if len(raw) == 0 {
return ret, diags
}
ret = make([]hcl.Traversal, 0, len(raw))
for _, rawT := range raw {
traversal := make(hcl.Traversal, 0, len(rawT.Steps))
for _, rawS := range rawT.Steps {
rng := hcl.Range{
Filename: rawS.Range.Filename,
Start: hcl.Pos{
Line: rawS.Range.Start.Line,
Column: rawS.Range.Start.Column,
Byte: rawS.Range.Start.Byte,
},
End: hcl.Pos{
Line: rawS.Range.End.Line,
Column: rawS.Range.End.Column,
Byte: rawS.Range.End.Byte,
},
}
switch rawS.Kind {
case "root":
traversal = append(traversal, hcl.TraverseRoot{
Name: rawS.Name,
SrcRange: rng,
})
case "attr":
traversal = append(traversal, hcl.TraverseAttr{
Name: rawS.Name,
SrcRange: rng,
})
case "index":
ty, err := ctyjson.ImpliedType([]byte(rawS.Key))
if err != nil {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Failed to parse hcldec result",
Detail: fmt.Sprintf("Sub-program hcldec (with --var-refs) produced an invalid result: traversal step has invalid index key %s.", rawS.Key),
})
return nil, diags
}
keyVal, err := ctyjson.Unmarshal([]byte(rawS.Key), ty)
if err != nil {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Failed to parse hcldec result",
Detail: fmt.Sprintf("Sub-program hcldec (with --var-refs) produced a result with an invalid index key %s: %s.", rawS.Key, err),
})
return nil, diags
}
traversal = append(traversal, hcl.TraverseIndex{
Key: keyVal,
SrcRange: rng,
})
default:
// Should never happen since the above cases are exhaustive,
// but we'll catch it gracefully since this is coming from
// a possibly-buggy hcldec implementation that we're testing.
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Failed to parse hcldec result",
Detail: fmt.Sprintf("Sub-program hcldec (with --var-refs) produced an invalid result: traversal step of unsupported kind %q.", rawS.Kind),
})
return nil, diags
}
}
ret = append(ret, traversal)
}
return ret, diags
}
}
func (r *Runner) prettyDirName(dir string) string {
rel, err := filepath.Rel(r.baseDir, dir)
if err != nil {
return filepath.ToSlash(dir)
}
return filepath.ToSlash(rel)
}
func (r *Runner) prettyTestName(filename string) string {
dir := filepath.Dir(filename)
dirName := r.prettyDirName(dir)
filename = filepath.Base(filename)
testName := filename[:len(filename)-2]
if dirName == "." {
return testName
}
return fmt.Sprintf("%s/%s", dirName, testName)
}

View File

@ -0,0 +1,350 @@
package main
import (
"fmt"
"github.com/zclconf/go-cty/cty"
"github.com/zclconf/go-cty/cty/convert"
"github.com/hashicorp/hcl2/ext/typeexpr"
"github.com/hashicorp/hcl2/gohcl"
"github.com/hashicorp/hcl2/hcl"
)
type TestFile struct {
Result cty.Value
ResultType cty.Type
ChecksTraversals bool
ExpectedTraversals []*TestFileExpectTraversal
ExpectedDiags []*TestFileExpectDiag
ResultRange hcl.Range
ResultTypeRange hcl.Range
}
type TestFileExpectTraversal struct {
Traversal hcl.Traversal
Range hcl.Range
DeclRange hcl.Range
}
type TestFileExpectDiag struct {
Severity hcl.DiagnosticSeverity
Range hcl.Range
DeclRange hcl.Range
}
func (r *Runner) LoadTestFile(filename string) (*TestFile, hcl.Diagnostics) {
f, diags := r.parser.ParseHCLFile(filename)
if diags.HasErrors() {
return nil, diags
}
content, moreDiags := f.Body.Content(testFileSchema)
diags = append(diags, moreDiags...)
if moreDiags.HasErrors() {
return nil, diags
}
ret := &TestFile{
ResultType: cty.DynamicPseudoType,
}
if typeAttr, exists := content.Attributes["result_type"]; exists {
ty, moreDiags := typeexpr.TypeConstraint(typeAttr.Expr)
diags = append(diags, moreDiags...)
if !moreDiags.HasErrors() {
ret.ResultType = ty
}
ret.ResultTypeRange = typeAttr.Expr.Range()
}
if resultAttr, exists := content.Attributes["result"]; exists {
resultVal, moreDiags := resultAttr.Expr.Value(nil)
diags = append(diags, moreDiags...)
if !moreDiags.HasErrors() {
resultVal, err := convert.Convert(resultVal, ret.ResultType)
if err != nil {
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Invalid result value",
Detail: fmt.Sprintf("The result value does not conform to the given result type: %s.", err),
Subject: resultAttr.Expr.Range().Ptr(),
})
} else {
ret.Result = resultVal
}
}
ret.ResultRange = resultAttr.Expr.Range()
}
for _, block := range content.Blocks {
switch block.Type {
case "traversals":
if ret.ChecksTraversals {
// Indicates a duplicate traversals block
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Duplicate \"traversals\" block",
Detail: fmt.Sprintf("Only one traversals block is expected."),
Subject: &block.TypeRange,
})
continue
}
expectTraversals, moreDiags := r.decodeTraversalsBlock(block)
diags = append(diags, moreDiags...)
if !moreDiags.HasErrors() {
ret.ChecksTraversals = true
ret.ExpectedTraversals = expectTraversals
}
case "diagnostics":
if len(ret.ExpectedDiags) > 0 {
// Indicates a duplicate diagnostics block
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Duplicate \"diagnostics\" block",
Detail: fmt.Sprintf("Only one diagnostics block is expected."),
Subject: &block.TypeRange,
})
continue
}
expectDiags, moreDiags := r.decodeDiagnosticsBlock(block)
diags = append(diags, moreDiags...)
ret.ExpectedDiags = expectDiags
default:
// Shouldn't get here, because the above cases are exhaustive for
// our test file schema.
panic(fmt.Sprintf("unsupported block type %q", block.Type))
}
}
if ret.Result != cty.NilVal && len(ret.ExpectedDiags) > 0 {
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Conflicting spec expectations",
Detail: "This test spec includes expected diagnostics, so it may not also include an expected result.",
Subject: &content.Attributes["result"].Range,
})
}
return ret, diags
}
func (r *Runner) decodeTraversalsBlock(block *hcl.Block) ([]*TestFileExpectTraversal, hcl.Diagnostics) {
var diags hcl.Diagnostics
content, moreDiags := block.Body.Content(testFileTraversalsSchema)
diags = append(diags, moreDiags...)
if moreDiags.HasErrors() {
return nil, diags
}
var ret []*TestFileExpectTraversal
for _, block := range content.Blocks {
// There's only one block type in our schema, so we can assume all
// blocks are of that type.
expectTraversal, moreDiags := r.decodeTraversalExpectBlock(block)
diags = append(diags, moreDiags...)
if expectTraversal != nil {
ret = append(ret, expectTraversal)
}
}
return ret, diags
}
func (r *Runner) decodeTraversalExpectBlock(block *hcl.Block) (*TestFileExpectTraversal, hcl.Diagnostics) {
var diags hcl.Diagnostics
rng, body, moreDiags := r.decodeRangeFromBody(block.Body)
diags = append(diags, moreDiags...)
content, moreDiags := body.Content(testFileTraversalExpectSchema)
diags = append(diags, moreDiags...)
if moreDiags.HasErrors() {
return nil, diags
}
var traversal hcl.Traversal
{
refAttr := content.Attributes["ref"]
traversal, moreDiags = hcl.AbsTraversalForExpr(refAttr.Expr)
diags = append(diags, moreDiags...)
if moreDiags.HasErrors() {
return nil, diags
}
}
return &TestFileExpectTraversal{
Traversal: traversal,
Range: rng,
DeclRange: block.DefRange,
}, diags
}
func (r *Runner) decodeDiagnosticsBlock(block *hcl.Block) ([]*TestFileExpectDiag, hcl.Diagnostics) {
var diags hcl.Diagnostics
content, moreDiags := block.Body.Content(testFileDiagnosticsSchema)
diags = append(diags, moreDiags...)
if moreDiags.HasErrors() {
return nil, diags
}
if len(content.Blocks) == 0 {
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Empty diagnostics block",
Detail: "If a diagnostics block is present, at least one expectation statement (\"error\" or \"warning\" block) must be included.",
Subject: &block.TypeRange,
})
return nil, diags
}
ret := make([]*TestFileExpectDiag, 0, len(content.Blocks))
for _, block := range content.Blocks {
rng, remain, moreDiags := r.decodeRangeFromBody(block.Body)
diags = append(diags, moreDiags...)
if diags.HasErrors() {
continue
}
// Should have nothing else in the block aside from the range definition.
_, moreDiags = remain.Content(&hcl.BodySchema{})
diags = append(diags, moreDiags...)
var severity hcl.DiagnosticSeverity
switch block.Type {
case "error":
severity = hcl.DiagError
case "warning":
severity = hcl.DiagWarning
default:
panic(fmt.Sprintf("unsupported block type %q", block.Type))
}
ret = append(ret, &TestFileExpectDiag{
Severity: severity,
Range: rng,
DeclRange: block.TypeRange,
})
}
return ret, diags
}
func (r *Runner) decodeRangeFromBody(body hcl.Body) (hcl.Range, hcl.Body, hcl.Diagnostics) {
type RawPos struct {
Line int `hcl:"line"`
Column int `hcl:"column"`
Byte int `hcl:"byte"`
}
type RawRange struct {
From RawPos `hcl:"from,block"`
To RawPos `hcl:"to,block"`
Remain hcl.Body `hcl:",remain"`
}
var raw RawRange
diags := gohcl.DecodeBody(body, nil, &raw)
return hcl.Range{
// We intentionally omit Filename here, because the test spec doesn't
// need to specify that explicitly: we can infer it to be the file
// path we pass to hcldec.
Start: hcl.Pos{
Line: raw.From.Line,
Column: raw.From.Column,
Byte: raw.From.Byte,
},
End: hcl.Pos{
Line: raw.To.Line,
Column: raw.To.Column,
Byte: raw.To.Byte,
},
}, raw.Remain, diags
}
var testFileSchema = &hcl.BodySchema{
Attributes: []hcl.AttributeSchema{
{
Name: "result",
},
{
Name: "result_type",
},
},
Blocks: []hcl.BlockHeaderSchema{
{
Type: "traversals",
},
{
Type: "diagnostics",
},
},
}
var testFileTraversalsSchema = &hcl.BodySchema{
Blocks: []hcl.BlockHeaderSchema{
{
Type: "expect",
},
},
}
var testFileTraversalExpectSchema = &hcl.BodySchema{
Attributes: []hcl.AttributeSchema{
{
Name: "ref",
Required: true,
},
},
Blocks: []hcl.BlockHeaderSchema{
{
Type: "range",
},
},
}
var testFileDiagnosticsSchema = &hcl.BodySchema{
Blocks: []hcl.BlockHeaderSchema{
{
Type: "error",
},
{
Type: "warning",
},
},
}
var testFileRangeSchema = &hcl.BodySchema{
Blocks: []hcl.BlockHeaderSchema{
{
Type: "from",
},
{
Type: "to",
},
},
}
var testFilePosSchema = &hcl.BodySchema{
Attributes: []hcl.AttributeSchema{
{
Name: "line",
Required: true,
},
{
Name: "column",
Required: true,
},
{
Name: "byte",
Required: true,
},
},
}

View File

@ -0,0 +1,117 @@
package main
import (
"fmt"
"reflect"
"github.com/hashicorp/hcl2/hcl"
)
func findTraversalSpec(got hcl.Traversal, candidates []*TestFileExpectTraversal) *TestFileExpectTraversal {
for _, candidate := range candidates {
if traversalsAreEquivalent(candidate.Traversal, got) {
return candidate
}
}
return nil
}
func findTraversalForSpec(want *TestFileExpectTraversal, have []hcl.Traversal) hcl.Traversal {
for _, candidate := range have {
if traversalsAreEquivalent(candidate, want.Traversal) {
return candidate
}
}
return nil
}
func traversalsAreEquivalent(a, b hcl.Traversal) bool {
if len(a) != len(b) {
return false
}
for i := range a {
aStep := a[i]
bStep := b[i]
if reflect.TypeOf(aStep) != reflect.TypeOf(bStep) {
return false
}
// We can now assume that both are of the same type.
switch ts := aStep.(type) {
case hcl.TraverseRoot:
if bStep.(hcl.TraverseRoot).Name != ts.Name {
return false
}
case hcl.TraverseAttr:
if bStep.(hcl.TraverseAttr).Name != ts.Name {
return false
}
case hcl.TraverseIndex:
if !bStep.(hcl.TraverseIndex).Key.RawEquals(ts.Key) {
return false
}
default:
return false
}
}
return true
}
// checkTraversalsMatch determines if a given traversal matches the given
// expectation, which must've been produced by an earlier call to
// findTraversalSpec for the same traversal.
func checkTraversalsMatch(got hcl.Traversal, filename string, match *TestFileExpectTraversal) hcl.Diagnostics {
var diags hcl.Diagnostics
gotRng := got.SourceRange()
wantRng := match.Range
if got, want := gotRng.Filename, filename; got != want {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Incorrect filename in detected traversal",
Detail: fmt.Sprintf(
"Filename was reported as %q, but was expecting %q.",
got, want,
),
Subject: match.Traversal.SourceRange().Ptr(),
})
return diags
}
// If we have the expected filename then we'll use that to construct the
// full "want range" here so that we can use it to point to the appropriate
// location in the remaining diagnostics.
wantRng.Filename = filename
if got, want := gotRng.Start, wantRng.Start; got != want {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Incorrect start position in detected traversal",
Detail: fmt.Sprintf(
"Start position was reported as line %d column %d byte %d, but was expecting line %d column %d byte %d.",
got.Line, got.Column, got.Byte,
want.Line, want.Column, want.Byte,
),
Subject: &wantRng,
})
}
if got, want := gotRng.End, wantRng.End; got != want {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Incorrect end position in detected traversal",
Detail: fmt.Sprintf(
"End position was reported as line %d column %d byte %d, but was expecting line %d column %d byte %d.",
got.Line, got.Column, got.Byte,
want.Line, want.Column, want.Byte,
),
Subject: &wantRng,
})
}
return diags
}

1
hcl/spectests/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
tmp_*

100
hcl/spectests/spec_test.go Normal file
View File

@ -0,0 +1,100 @@
package spectests
import (
"bufio"
"bytes"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
)
func TestMain(m *testing.M) {
// The test harness is an external program that also expects to have
// hcldec built as an external program, so we'll build both into
// temporary files in our working directory before running our tests
// here, to ensure that we're always running a build of the latest code.
err := build()
if err != nil {
fmt.Fprintf(os.Stderr, "%s\n", err.Error())
os.Exit(1)
}
// Now we can run the tests
os.Exit(m.Run())
}
func build() error {
err := goBuild("github.com/hashicorp/hcl2/cmd/hcldec", "tmp_hcldec")
if err != nil {
return fmt.Errorf("error building hcldec: %s", err)
}
err = goBuild("github.com/hashicorp/hcl2/cmd/hclspecsuite", "tmp_hclspecsuite")
if err != nil {
return fmt.Errorf("error building hcldec: %s", err)
}
return nil
}
func TestSpec(t *testing.T) {
suiteDir := filepath.Clean("../../specsuite/tests")
harness := "./tmp_hclspecsuite"
hcldec := "./tmp_hcldec"
cmd := exec.Command(harness, suiteDir, hcldec)
out, err := cmd.CombinedOutput()
if _, isExit := err.(*exec.ExitError); err != nil && !isExit {
t.Errorf("failed to run harness: %s", err)
}
failed := err != nil
sc := bufio.NewScanner(bytes.NewReader(out))
var lines []string
for sc.Scan() {
lines = append(lines, sc.Text())
}
i := 0
for i < len(lines) {
cur := lines[i]
if strings.HasPrefix(cur, "- ") {
testName := cur[2:]
t.Run(testName, func(t *testing.T) {
i++
for i < len(lines) {
cur := lines[i]
if strings.HasPrefix(cur, "- ") || strings.HasPrefix(cur, "==") {
return
}
t.Error(cur)
i++
}
})
} else {
if !strings.HasPrefix(cur, "==") { // not the "test harness problems" report, then
t.Log(cur)
}
i++
}
}
if failed {
t.Error("specsuite failed")
}
}
func goBuild(pkg, outFile string) error {
if runtime.GOOS == "windows" {
outFile += ".exe"
}
cmd := exec.Command("go", "build", "-i", "-o", outFile, pkg)
cmd.Stderr = os.Stderr
cmd.Stdout = os.Stdout
return cmd.Run()
}

37
specsuite/README.md Normal file
View File

@ -0,0 +1,37 @@
# HCL Language Test Suite
This directory contains an implementation-agnostic test suite that can be used
to verify the correct behavior not only of the HCL implementation in _this_
repository but also of possible other implementations.
The harness for running this suite -- a Go program in this directory -- uses
the `hcldec` program as a level of abstraction to avoid depending directly on
the Go implementation. As a result, other HCL implementations must also
include a version of `hcldec` in order to run this spec.
The tests defined in this suite each correspond to a detail of
[the HCL spec](../hcl/spec.md). This suite is separate from and not a
substitute for direct unit tests of a given implementation that would presumably
also exercise that implementation's own programmatic API.
To run the suite, first build the harness using Go:
```
go install github.com/hashicorp/hcl2/cmd/hclspecsuite
```
Then run it, passing it the directory containing the test definitions (the
"tests" subdirectory of this directory) and the path to the `hcldec` executable
to use.
For example, if working in the root of this repository and using the `hcldec`
implementation from here:
```
go install ./cmd/hcldec
hclspecsuite ./specsuite/tests $GOPATH/bin/hcldec
```
For developers working on the Go implementation of HCL from this repository,
please note that this spec suite is run as part of a normal `go test ./...`
execution for this whole repository and so does not need to be run separately.

View File

@ -0,0 +1 @@
# Hash comment

View File

@ -0,0 +1,3 @@
literal {
value = "ok"
}

View File

@ -0,0 +1,4 @@
# This test parses a file containing only a comment. It is a parsing-only test,
# so the hcldec spec for this test is just a literal value given below.
result = "ok"

View File

@ -0,0 +1,3 @@
/*
Multi-line comment
*/

View File

@ -0,0 +1,3 @@
literal {
value = "ok"
}

View File

@ -0,0 +1,4 @@
# This test parses a file containing only a comment. It is a parsing-only test,
# so the hcldec spec for this test is just a literal value given below.
result = "ok"

View File

@ -0,0 +1 @@
// Slash comment

View File

@ -0,0 +1,3 @@
literal {
value = "ok"
}

View File

@ -0,0 +1,4 @@
# This test parses a file containing only a comment. It is a parsing-only test,
# so the hcldec spec for this test is just a literal value given below.
result = "ok"

View File

View File

@ -0,0 +1 @@
{}

View File

@ -0,0 +1,3 @@
literal {
value = "ok"
}

9
specsuite/tests/empty.t Normal file
View File

@ -0,0 +1,9 @@
# This test ensures that we can successfully parse an empty file.
# Since an empty file has no content, the hcldec spec for this test is
# just a literal value, which we test below.
result = "ok"
traversals {
# Explicitly no traversals
}

View File

@ -0,0 +1,3 @@
a = "a value"
b = "b value"
c = "c value"

View File

@ -0,0 +1,11 @@
object {
attr "a" {
type = string
}
attr "b" {
type = string
}
attr "c" {
type = string
}
}

View File

@ -0,0 +1,10 @@
result_type = object({
a = string
b = string
c = string
})
result = {
a = "a value"
b = "b value"
c = "c value"
}

View File

@ -0,0 +1 @@
a = "a value", b = "b value"

View File

@ -0,0 +1,3 @@
literal {
value = null
}

View File

@ -0,0 +1,19 @@
# This test verifies that comma-separated attributes on the same line are
# reported as an error, rather than being parsed like an object constructor
# expression.
diagnostics {
error {
# Message like "missing newline after argument" or "each argument must be on its own line"
from {
line = 1
column = 14
byte = 13
}
to {
line = 1
column = 15
byte = 14
}
}
}

View File

@ -0,0 +1,4 @@
a = "a value"
b = "b value"
c = "c value"
d = "d value"

View File

@ -0,0 +1,11 @@
object {
attr "a" {
type = string
}
attr "b" {
type = string
}
attr "d" {
type = string
}
}

View File

@ -0,0 +1,15 @@
diagnostics {
error {
# An argument named "c" is not expected here.
from {
line = 3
column = 1
byte = 28
}
to {
line = 3
column = 2
byte = 29
}
}
}

View File

@ -0,0 +1 @@
a {}

View File

@ -0,0 +1,4 @@
block {
block_type = "a"
object {}
}

View File

@ -0,0 +1 @@
result_type = object({})

View File

@ -0,0 +1,3 @@
a {
}

View File

@ -0,0 +1,4 @@
block {
block_type = "a"
object {}
}

View File

@ -0,0 +1 @@
result_type = object({})

View File

@ -0,0 +1 @@
a {

View File

@ -0,0 +1,4 @@
block {
block_type = "a"
object {}
}

View File

@ -0,0 +1,14 @@
diagnostics {
error {
from {
line = 2
column = 1
byte = 4
}
to {
line = 2
column = 1
byte = 4
}
}
}