github.com/hashicorp/hcl/v2@v2.20.0/cmd/hclspecsuite/runner.go (about)

     1  // Copyright (c) HashiCorp, Inc.
     2  // SPDX-License-Identifier: MPL-2.0
     3  
     4  package main
     5  
     6  import (
     7  	"bytes"
     8  	"encoding/json"
     9  	"fmt"
    10  	"io/ioutil"
    11  	"os"
    12  	"os/exec"
    13  	"path/filepath"
    14  	"sort"
    15  	"strings"
    16  
    17  	"github.com/zclconf/go-cty-debug/ctydebug"
    18  	"github.com/zclconf/go-cty/cty"
    19  	"github.com/zclconf/go-cty/cty/convert"
    20  	ctyjson "github.com/zclconf/go-cty/cty/json"
    21  
    22  	"github.com/hashicorp/hcl/v2"
    23  	"github.com/hashicorp/hcl/v2/ext/typeexpr"
    24  	"github.com/hashicorp/hcl/v2/hclparse"
    25  )
    26  
    27  type Runner struct {
    28  	parser      *hclparse.Parser
    29  	hcldecPath  string
    30  	baseDir     string
    31  	logBegin    LogBeginCallback
    32  	logProblems LogProblemsCallback
    33  }
    34  
    35  func (r *Runner) Run() hcl.Diagnostics {
    36  	return r.runDir(r.baseDir)
    37  }
    38  
    39  func (r *Runner) runDir(dir string) hcl.Diagnostics {
    40  	var diags hcl.Diagnostics
    41  
    42  	infos, err := ioutil.ReadDir(dir)
    43  	if err != nil {
    44  		diags = append(diags, &hcl.Diagnostic{
    45  			Severity: hcl.DiagError,
    46  			Summary:  "Failed to read test directory",
    47  			Detail:   fmt.Sprintf("The directory %q could not be opened: %s.", dir, err),
    48  		})
    49  		return diags
    50  	}
    51  
    52  	var tests []string
    53  	var subDirs []string
    54  	for _, info := range infos {
    55  		name := info.Name()
    56  		if strings.HasPrefix(name, ".") {
    57  			continue
    58  		}
    59  
    60  		if info.IsDir() {
    61  			subDirs = append(subDirs, name)
    62  		}
    63  		if strings.HasSuffix(name, ".t") {
    64  			tests = append(tests, name)
    65  		}
    66  	}
    67  	sort.Strings(tests)
    68  	sort.Strings(subDirs)
    69  
    70  	for _, filename := range tests {
    71  		filename = filepath.Join(dir, filename)
    72  		testDiags := r.runTest(filename)
    73  		diags = append(diags, testDiags...)
    74  	}
    75  
    76  	for _, dirName := range subDirs {
    77  		dir := filepath.Join(dir, dirName)
    78  		dirDiags := r.runDir(dir)
    79  		diags = append(diags, dirDiags...)
    80  	}
    81  
    82  	return diags
    83  }
    84  
    85  func (r *Runner) runTest(filename string) hcl.Diagnostics {
    86  	prettyName := r.prettyTestName(filename)
    87  	tf, diags := r.LoadTestFile(filename)
    88  	if diags.HasErrors() {
    89  		// We'll still log, so it's clearer which test the diagnostics belong to.
    90  		if r.logBegin != nil {
    91  			r.logBegin(prettyName, nil)
    92  		}
    93  		if r.logProblems != nil {
    94  			r.logProblems(prettyName, nil, diags)
    95  			return nil // don't duplicate the diagnostics we already reported
    96  		}
    97  		return diags
    98  	}
    99  
   100  	if r.logBegin != nil {
   101  		r.logBegin(prettyName, tf)
   102  	}
   103  
   104  	basePath := filename[:len(filename)-2]
   105  	specFilename := basePath + ".hcldec"
   106  	nativeFilename := basePath + ".hcl"
   107  	jsonFilename := basePath + ".hcl.json"
   108  
   109  	// We'll add the source code of the spec file to our own parser, even
   110  	// though it'll actually be parsed by the hcldec child process, since that
   111  	// way we can produce nice diagnostic messages if hcldec fails to process
   112  	// the spec file.
   113  	src, err := ioutil.ReadFile(specFilename)
   114  	if err == nil {
   115  		r.parser.AddFile(specFilename, &hcl.File{
   116  			Bytes: src,
   117  		})
   118  	}
   119  
   120  	if _, err := os.Stat(specFilename); err != nil {
   121  		diags = append(diags, &hcl.Diagnostic{
   122  			Severity: hcl.DiagError,
   123  			Summary:  "Missing .hcldec file",
   124  			Detail:   fmt.Sprintf("No specification file for test %s: %s.", prettyName, err),
   125  		})
   126  		return diags
   127  	}
   128  
   129  	if _, err := os.Stat(nativeFilename); err == nil {
   130  		moreDiags := r.runTestInput(specFilename, nativeFilename, tf)
   131  		diags = append(diags, moreDiags...)
   132  	}
   133  
   134  	if _, err := os.Stat(jsonFilename); err == nil {
   135  		moreDiags := r.runTestInput(specFilename, jsonFilename, tf)
   136  		diags = append(diags, moreDiags...)
   137  	}
   138  
   139  	if r.logProblems != nil {
   140  		r.logProblems(prettyName, nil, diags)
   141  		return nil // don't duplicate the diagnostics we already reported
   142  	}
   143  
   144  	return diags
   145  }
   146  
   147  func (r *Runner) runTestInput(specFilename, inputFilename string, tf *TestFile) hcl.Diagnostics {
   148  	// We'll add the source code of the input file to our own parser, even
   149  	// though it'll actually be parsed by the hcldec child process, since that
   150  	// way we can produce nice diagnostic messages if hcldec fails to process
   151  	// the input file.
   152  	src, err := ioutil.ReadFile(inputFilename)
   153  	if err == nil {
   154  		r.parser.AddFile(inputFilename, &hcl.File{
   155  			Bytes: src,
   156  		})
   157  	}
   158  
   159  	var diags hcl.Diagnostics
   160  
   161  	if tf.ChecksTraversals {
   162  		gotTraversals, moreDiags := r.hcldecVariables(specFilename, inputFilename)
   163  		diags = append(diags, moreDiags...)
   164  		if !moreDiags.HasErrors() {
   165  			expected := tf.ExpectedTraversals
   166  			for _, got := range gotTraversals {
   167  				e := findTraversalSpec(got, expected)
   168  				rng := got.SourceRange()
   169  				if e == nil {
   170  					diags = append(diags, &hcl.Diagnostic{
   171  						Severity: hcl.DiagError,
   172  						Summary:  "Unexpected traversal",
   173  						Detail:   "Detected traversal that is not indicated as expected in the test file.",
   174  						Subject:  &rng,
   175  					})
   176  				} else {
   177  					moreDiags := checkTraversalsMatch(got, inputFilename, e)
   178  					diags = append(diags, moreDiags...)
   179  				}
   180  			}
   181  
   182  			// Look for any traversals that didn't show up at all.
   183  			for _, e := range expected {
   184  				if t := findTraversalForSpec(e, gotTraversals); t == nil {
   185  					diags = append(diags, &hcl.Diagnostic{
   186  						Severity: hcl.DiagError,
   187  						Summary:  "Missing expected traversal",
   188  						Detail:   "This expected traversal was not detected.",
   189  						Subject:  e.Traversal.SourceRange().Ptr(),
   190  					})
   191  				}
   192  			}
   193  		}
   194  
   195  	}
   196  
   197  	val, transformDiags := r.hcldecTransform(specFilename, inputFilename)
   198  	if len(tf.ExpectedDiags) == 0 {
   199  		diags = append(diags, transformDiags...)
   200  		if transformDiags.HasErrors() {
   201  			// If hcldec failed then there's no point in continuing.
   202  			return diags
   203  		}
   204  
   205  		if errs := val.Type().TestConformance(tf.ResultType); len(errs) > 0 {
   206  			diags = append(diags, &hcl.Diagnostic{
   207  				Severity: hcl.DiagError,
   208  				Summary:  "Incorrect result type",
   209  				Detail: fmt.Sprintf(
   210  					"Input file %s produced %s, but was expecting %s.",
   211  					inputFilename, typeexpr.TypeString(val.Type()), typeexpr.TypeString(tf.ResultType),
   212  				),
   213  			})
   214  		}
   215  
   216  		if tf.Result != cty.NilVal {
   217  			cmpVal, err := convert.Convert(tf.Result, tf.ResultType)
   218  			if err != nil {
   219  				diags = append(diags, &hcl.Diagnostic{
   220  					Severity: hcl.DiagError,
   221  					Summary:  "Incorrect type for result value",
   222  					Detail: fmt.Sprintf(
   223  						"Result does not conform to the given result type: %s.", err,
   224  					),
   225  					Subject: &tf.ResultRange,
   226  				})
   227  			} else {
   228  				if !val.RawEquals(cmpVal) {
   229  					diags = append(diags, &hcl.Diagnostic{
   230  						Severity: hcl.DiagError,
   231  						Summary:  "Incorrect result value",
   232  						Detail: fmt.Sprintf(
   233  							"Input file %s produced %#v, but was expecting %#v.\n\n%s",
   234  							inputFilename, val, tf.Result,
   235  							ctydebug.DiffValues(tf.Result, val),
   236  						),
   237  					})
   238  				}
   239  			}
   240  		}
   241  	} else {
   242  		// We're expecting diagnostics, and so we'll need to correlate the
   243  		// severities and source ranges of our actual diagnostics against
   244  		// what we were expecting.
   245  		type DiagnosticEntry struct {
   246  			Severity hcl.DiagnosticSeverity
   247  			Range    hcl.Range
   248  		}
   249  		got := make(map[DiagnosticEntry]*hcl.Diagnostic)
   250  		want := make(map[DiagnosticEntry]hcl.Range)
   251  		for _, diag := range transformDiags {
   252  			if diag.Subject == nil {
   253  				// Sourceless diagnostics can never be expected, so we'll just
   254  				// pass these through as-is and assume they are hcldec
   255  				// operational errors.
   256  				diags = append(diags, diag)
   257  				continue
   258  			}
   259  			if diag.Subject.Filename != inputFilename {
   260  				// If the problem is for something other than the input file
   261  				// then it can't be expected.
   262  				diags = append(diags, diag)
   263  				continue
   264  			}
   265  			entry := DiagnosticEntry{
   266  				Severity: diag.Severity,
   267  				Range:    *diag.Subject,
   268  			}
   269  			got[entry] = diag
   270  		}
   271  		for _, e := range tf.ExpectedDiags {
   272  			e.Range.Filename = inputFilename // assumed here, since we don't allow any other filename to be expected
   273  			entry := DiagnosticEntry{
   274  				Severity: e.Severity,
   275  				Range:    e.Range,
   276  			}
   277  			want[entry] = e.DeclRange
   278  		}
   279  
   280  		for gotEntry, diag := range got {
   281  			if _, wanted := want[gotEntry]; !wanted {
   282  				// Pass through the diagnostic itself so the user can see what happened
   283  				diags = append(diags, diag)
   284  				diags = append(diags, &hcl.Diagnostic{
   285  					Severity: hcl.DiagError,
   286  					Summary:  "Unexpected diagnostic",
   287  					Detail: fmt.Sprintf(
   288  						"No %s diagnostic was expected %s. The unexpected diagnostic was shown above.",
   289  						severityString(gotEntry.Severity), rangeString(gotEntry.Range),
   290  					),
   291  					Subject: gotEntry.Range.Ptr(),
   292  				})
   293  			}
   294  		}
   295  
   296  		for wantEntry, declRange := range want {
   297  			if _, gotted := got[wantEntry]; !gotted {
   298  				diags = append(diags, &hcl.Diagnostic{
   299  					Severity: hcl.DiagError,
   300  					Summary:  "Missing expected diagnostic",
   301  					Detail: fmt.Sprintf(
   302  						"No %s diagnostic was generated %s.",
   303  						severityString(wantEntry.Severity), rangeString(wantEntry.Range),
   304  					),
   305  					Subject: declRange.Ptr(),
   306  				})
   307  			}
   308  		}
   309  	}
   310  
   311  	return diags
   312  }
   313  
   314  func (r *Runner) hcldecTransform(specFile, inputFile string) (cty.Value, hcl.Diagnostics) {
   315  	var diags hcl.Diagnostics
   316  	var outBuffer bytes.Buffer
   317  	var errBuffer bytes.Buffer
   318  
   319  	cmd := &exec.Cmd{
   320  		Path: r.hcldecPath,
   321  		Args: []string{
   322  			r.hcldecPath,
   323  			"--spec=" + specFile,
   324  			"--diags=json",
   325  			"--with-type",
   326  			"--keep-nulls",
   327  			inputFile,
   328  		},
   329  		Stdout: &outBuffer,
   330  		Stderr: &errBuffer,
   331  	}
   332  	err := cmd.Run()
   333  	if err != nil {
   334  		if _, isExit := err.(*exec.ExitError); !isExit {
   335  			diags = append(diags, &hcl.Diagnostic{
   336  				Severity: hcl.DiagError,
   337  				Summary:  "Failed to run hcldec",
   338  				Detail:   fmt.Sprintf("Sub-program hcldec failed to start: %s.", err),
   339  			})
   340  			return cty.DynamicVal, diags
   341  		}
   342  
   343  		// If we exited unsuccessfully then we'll expect diagnostics on stderr
   344  		moreDiags := decodeJSONDiagnostics(errBuffer.Bytes())
   345  		diags = append(diags, moreDiags...)
   346  		return cty.DynamicVal, diags
   347  	} else {
   348  		// Otherwise, we expect a JSON result value on stdout. Since we used
   349  		// --with-type above, we can decode as DynamicPseudoType to recover
   350  		// exactly the type that was saved, without the usual JSON lossiness.
   351  		val, err := ctyjson.Unmarshal(outBuffer.Bytes(), cty.DynamicPseudoType)
   352  		if err != nil {
   353  			diags = append(diags, &hcl.Diagnostic{
   354  				Severity: hcl.DiagError,
   355  				Summary:  "Failed to parse hcldec result",
   356  				Detail:   fmt.Sprintf("Sub-program hcldec produced an invalid result: %s.", err),
   357  			})
   358  			return cty.DynamicVal, diags
   359  		}
   360  		return val, diags
   361  	}
   362  }
   363  
   364  func (r *Runner) hcldecVariables(specFile, inputFile string) ([]hcl.Traversal, hcl.Diagnostics) {
   365  	var diags hcl.Diagnostics
   366  	var outBuffer bytes.Buffer
   367  	var errBuffer bytes.Buffer
   368  
   369  	cmd := &exec.Cmd{
   370  		Path: r.hcldecPath,
   371  		Args: []string{
   372  			r.hcldecPath,
   373  			"--spec=" + specFile,
   374  			"--diags=json",
   375  			"--var-refs",
   376  			inputFile,
   377  		},
   378  		Stdout: &outBuffer,
   379  		Stderr: &errBuffer,
   380  	}
   381  	err := cmd.Run()
   382  	if err != nil {
   383  		if _, isExit := err.(*exec.ExitError); !isExit {
   384  			diags = append(diags, &hcl.Diagnostic{
   385  				Severity: hcl.DiagError,
   386  				Summary:  "Failed to run hcldec",
   387  				Detail:   fmt.Sprintf("Sub-program hcldec (evaluating input) failed to start: %s.", err),
   388  			})
   389  			return nil, diags
   390  		}
   391  
   392  		// If we exited unsuccessfully then we'll expect diagnostics on stderr
   393  		moreDiags := decodeJSONDiagnostics(errBuffer.Bytes())
   394  		diags = append(diags, moreDiags...)
   395  		return nil, diags
   396  	} else {
   397  		// Otherwise, we expect a JSON description of the traversals on stdout.
   398  		type PosJSON struct {
   399  			Line   int `json:"line"`
   400  			Column int `json:"column"`
   401  			Byte   int `json:"byte"`
   402  		}
   403  		type RangeJSON struct {
   404  			Filename string  `json:"filename"`
   405  			Start    PosJSON `json:"start"`
   406  			End      PosJSON `json:"end"`
   407  		}
   408  		type StepJSON struct {
   409  			Kind  string          `json:"kind"`
   410  			Name  string          `json:"name,omitempty"`
   411  			Key   json.RawMessage `json:"key,omitempty"`
   412  			Range RangeJSON       `json:"range"`
   413  		}
   414  		type TraversalJSON struct {
   415  			Steps []StepJSON `json:"steps"`
   416  		}
   417  
   418  		var raw []TraversalJSON
   419  		err := json.Unmarshal(outBuffer.Bytes(), &raw)
   420  		if err != nil {
   421  			diags = append(diags, &hcl.Diagnostic{
   422  				Severity: hcl.DiagError,
   423  				Summary:  "Failed to parse hcldec result",
   424  				Detail:   fmt.Sprintf("Sub-program hcldec (with --var-refs) produced an invalid result: %s.", err),
   425  			})
   426  			return nil, diags
   427  		}
   428  
   429  		var ret []hcl.Traversal
   430  		if len(raw) == 0 {
   431  			return ret, diags
   432  		}
   433  
   434  		ret = make([]hcl.Traversal, 0, len(raw))
   435  		for _, rawT := range raw {
   436  			traversal := make(hcl.Traversal, 0, len(rawT.Steps))
   437  			for _, rawS := range rawT.Steps {
   438  				rng := hcl.Range{
   439  					Filename: rawS.Range.Filename,
   440  					Start: hcl.Pos{
   441  						Line:   rawS.Range.Start.Line,
   442  						Column: rawS.Range.Start.Column,
   443  						Byte:   rawS.Range.Start.Byte,
   444  					},
   445  					End: hcl.Pos{
   446  						Line:   rawS.Range.End.Line,
   447  						Column: rawS.Range.End.Column,
   448  						Byte:   rawS.Range.End.Byte,
   449  					},
   450  				}
   451  
   452  				switch rawS.Kind {
   453  
   454  				case "root":
   455  					traversal = append(traversal, hcl.TraverseRoot{
   456  						Name:     rawS.Name,
   457  						SrcRange: rng,
   458  					})
   459  
   460  				case "attr":
   461  					traversal = append(traversal, hcl.TraverseAttr{
   462  						Name:     rawS.Name,
   463  						SrcRange: rng,
   464  					})
   465  
   466  				case "index":
   467  					ty, err := ctyjson.ImpliedType([]byte(rawS.Key))
   468  					if err != nil {
   469  						diags = append(diags, &hcl.Diagnostic{
   470  							Severity: hcl.DiagError,
   471  							Summary:  "Failed to parse hcldec result",
   472  							Detail:   fmt.Sprintf("Sub-program hcldec (with --var-refs) produced an invalid result: traversal step has invalid index key %s.", rawS.Key),
   473  						})
   474  						return nil, diags
   475  					}
   476  					keyVal, err := ctyjson.Unmarshal([]byte(rawS.Key), ty)
   477  					if err != nil {
   478  						diags = append(diags, &hcl.Diagnostic{
   479  							Severity: hcl.DiagError,
   480  							Summary:  "Failed to parse hcldec result",
   481  							Detail:   fmt.Sprintf("Sub-program hcldec (with --var-refs) produced a result with an invalid index key %s: %s.", rawS.Key, err),
   482  						})
   483  						return nil, diags
   484  					}
   485  
   486  					traversal = append(traversal, hcl.TraverseIndex{
   487  						Key:      keyVal,
   488  						SrcRange: rng,
   489  					})
   490  
   491  				default:
   492  					// Should never happen since the above cases are exhaustive,
   493  					// but we'll catch it gracefully since this is coming from
   494  					// a possibly-buggy hcldec implementation that we're testing.
   495  					diags = append(diags, &hcl.Diagnostic{
   496  						Severity: hcl.DiagError,
   497  						Summary:  "Failed to parse hcldec result",
   498  						Detail:   fmt.Sprintf("Sub-program hcldec (with --var-refs) produced an invalid result: traversal step of unsupported kind %q.", rawS.Kind),
   499  					})
   500  					return nil, diags
   501  				}
   502  			}
   503  
   504  			ret = append(ret, traversal)
   505  		}
   506  		return ret, diags
   507  	}
   508  }
   509  
   510  func (r *Runner) prettyDirName(dir string) string {
   511  	rel, err := filepath.Rel(r.baseDir, dir)
   512  	if err != nil {
   513  		return filepath.ToSlash(dir)
   514  	}
   515  	return filepath.ToSlash(rel)
   516  }
   517  
   518  func (r *Runner) prettyTestName(filename string) string {
   519  	dir := filepath.Dir(filename)
   520  	dirName := r.prettyDirName(dir)
   521  	filename = filepath.Base(filename)
   522  	testName := filename[:len(filename)-2]
   523  	if dirName == "." {
   524  		return testName
   525  	}
   526  	return fmt.Sprintf("%s/%s", dirName, testName)
   527  }