Skip to content

Commit

Permalink
Reflect 0.15 changes in diagnostics output (#29)
Browse files Browse the repository at this point in the history
* Reflect 0.15 changes in diagnostics output

* Reflect format versioning

* validate: Add test
  • Loading branch information
radeksimko authored Mar 22, 2021
1 parent 10a0ad8 commit 6e41686
Show file tree
Hide file tree
Showing 2 changed files with 223 additions and 4 deletions.
109 changes: 105 additions & 4 deletions validate.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
package tfjson

import (
"encoding/json"
"errors"
"fmt"
)

// Pos represents a position in a config file
type Pos struct {
Line int `json:"line"`
Expand All @@ -14,20 +20,115 @@ type Range struct {
End Pos `json:"end"`
}

type DiagnosticSeverity string

// These severities map to the tfdiags.Severity values, plus an explicit
// unknown in case that enum grows without us noticing here.
const (
DiagnosticSeverityUnknown DiagnosticSeverity = "unknown"
DiagnosticSeverityError DiagnosticSeverity = "error"
DiagnosticSeverityWarning DiagnosticSeverity = "warning"
)

// Diagnostic represents information to be presented to a user about an
// error or anomaly in parsing or evaluating configuration
type Diagnostic struct {
Severity string `json:"severity,omitempty"`
Summary string `json:"summary,omitempty"`
Detail string `json:"detail,omitempty"`
Range *Range `json:"range,omitempty"`
Severity DiagnosticSeverity `json:"severity,omitempty"`

Summary string `json:"summary,omitempty"`
Detail string `json:"detail,omitempty"`
Range *Range `json:"range,omitempty"`

Snippet *DiagnosticSnippet `json:"snippet,omitempty"`
}

// DiagnosticSnippet represents source code information about the diagnostic.
// It is possible for a diagnostic to have a source (and therefore a range) but
// no source code can be found. In this case, the range field will be present and
// the snippet field will not.
type DiagnosticSnippet struct {
// Context is derived from HCL's hcled.ContextString output. This gives a
// high-level summary of the root context of the diagnostic: for example,
// the resource block in which an expression causes an error.
Context *string `json:"context"`

// Code is a possibly-multi-line string of Terraform configuration, which
// includes both the diagnostic source and any relevant context as defined
// by the diagnostic.
Code string `json:"code"`

// StartLine is the line number in the source file for the first line of
// the snippet code block. This is not necessarily the same as the value of
// Range.Start.Line, as it is possible to have zero or more lines of
// context source code before the diagnostic range starts.
StartLine int `json:"start_line"`

// HighlightStartOffset is the character offset into Code at which the
// diagnostic source range starts, which ought to be highlighted as such by
// the consumer of this data.
HighlightStartOffset int `json:"highlight_start_offset"`

// HighlightEndOffset is the character offset into Code at which the
// diagnostic source range ends.
HighlightEndOffset int `json:"highlight_end_offset"`

// Values is a sorted slice of expression values which may be useful in
// understanding the source of an error in a complex expression.
Values []DiagnosticExpressionValue `json:"values"`
}

// DiagnosticExpressionValue represents an HCL traversal string (e.g.
// "var.foo") and a statement about its value while the expression was
// evaluated (e.g. "is a string", "will be known only after apply"). These are
// intended to help the consumer diagnose why an expression caused a diagnostic
// to be emitted.
type DiagnosticExpressionValue struct {
Traversal string `json:"traversal"`
Statement string `json:"statement"`
}

// ValidateOutput represents JSON output from terraform validate
// (available from 0.12 onwards)
type ValidateOutput struct {
FormatVersion string `json:"format_version"`

Valid bool `json:"valid"`
ErrorCount int `json:"error_count"`
WarningCount int `json:"warning_count"`
Diagnostics []Diagnostic `json:"diagnostics"`
}

// Validate checks to ensure that data is present, and the
// version matches the version supported by this library.
func (vo *ValidateOutput) Validate() error {
if vo == nil {
return errors.New("validation output is nil")
}

if vo.FormatVersion == "" {
// The format was not versioned in the past
return nil
}

supportedVersion := "0.1"
if vo.FormatVersion != supportedVersion {
return fmt.Errorf("unsupported validation output format version: expected %q, got %q",
supportedVersion, vo.FormatVersion)
}

return nil
}

func (vo *ValidateOutput) UnmarshalJSON(b []byte) error {
type rawOutput ValidateOutput
var schemas rawOutput

err := json.Unmarshal(b, &schemas)
if err != nil {
return err
}

*vo = *(*ValidateOutput)(&schemas)

return vo.Validate()
}
118 changes: 118 additions & 0 deletions validate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -107,3 +107,121 @@ func TestValidateOutput_basic(t *testing.T) {
t.Fatalf("output mismatch: %s", diff)
}
}

func TestValidateOutput_versioned(t *testing.T) {
errOutput := `{
"format_version": "0.1",
"valid": false,
"error_count": 1,
"warning_count": 1,
"diagnostics": [
{
"severity": "warning",
"summary": "Deprecated Attribute",
"detail": "Deprecated in favor of project_id",
"range": {
"filename": "main.tf",
"start": {
"line": 21,
"column": 25,
"byte": 408
},
"end": {
"line": 21,
"column": 42,
"byte": 425
}
},
"snippet": {
"context": "resource \"google_project_access_approval_settings\" \"project_access_approval\"",
"code": " project = \"my-project-name\"",
"start_line": 21,
"highlight_start_offset": 24,
"highlight_end_offset": 41,
"values": []
}
},
{
"severity": "error",
"summary": "Missing required argument",
"detail": "The argument \"enrolled_services\" is required, but no definition was found.",
"range": {
"filename": "main.tf",
"start": {
"line": 19,
"column": 78,
"byte": 340
},
"end": {
"line": 19,
"column": 79,
"byte": 341
}
},
"snippet": {
"context": "resource \"google_project_access_approval_settings\" \"project_access_approval\"",
"code": "resource \"google_project_access_approval_settings\" \"project_access_approval\" {",
"start_line": 19,
"highlight_start_offset": 77,
"highlight_end_offset": 78,
"values": []
}
}
]
}`
var parsed ValidateOutput
if err := json.Unmarshal([]byte(errOutput), &parsed); err != nil {
t.Fatal(err)
}

expected := &ValidateOutput{
FormatVersion: "0.1",
ErrorCount: 1,
WarningCount: 1,
Diagnostics: []Diagnostic{
{
Severity: "warning",
Summary: "Deprecated Attribute",
Detail: "Deprecated in favor of project_id",
Range: &Range{
Filename: "main.tf",
Start: Pos{Line: 21, Column: 25, Byte: 408},
End: Pos{Line: 21, Column: 42, Byte: 425},
},
Snippet: &DiagnosticSnippet{
Context: ptrToString(`resource "google_project_access_approval_settings" "project_access_approval"`),
Code: ` project = "my-project-name"`,
StartLine: 21,
HighlightStartOffset: 24,
HighlightEndOffset: 41,
Values: []DiagnosticExpressionValue{},
},
},
{
Severity: "error",
Summary: "Missing required argument",
Detail: `The argument "enrolled_services" is required, but no definition was found.`,
Range: &Range{
Filename: "main.tf",
Start: Pos{Line: 19, Column: 78, Byte: 340},
End: Pos{Line: 19, Column: 79, Byte: 341},
},
Snippet: &DiagnosticSnippet{
Context: ptrToString(`resource "google_project_access_approval_settings" "project_access_approval"`),
Code: `resource "google_project_access_approval_settings" "project_access_approval" {`,
StartLine: 19,
HighlightStartOffset: 77,
HighlightEndOffset: 78,
Values: []DiagnosticExpressionValue{},
},
},
},
}
if diff := cmp.Diff(expected, &parsed); diff != "" {
t.Fatalf("output mismatch: %s", diff)
}
}

func ptrToString(val string) *string {
return &val
}

0 comments on commit 6e41686

Please sign in to comment.