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 }