From 6e41686fda294cbd724848a8b3e9e2712bece814 Mon Sep 17 00:00:00 2001 From: Radek Simko Date: Mon, 22 Mar 2021 19:31:44 +0000 Subject: [PATCH] Reflect 0.15 changes in diagnostics output (#29) * Reflect 0.15 changes in diagnostics output * Reflect format versioning * validate: Add test --- validate.go | 109 +++++++++++++++++++++++++++++++++++++++++-- validate_test.go | 118 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 223 insertions(+), 4 deletions(-) diff --git a/validate.go b/validate.go index 7de13ad..db9db19 100644 --- a/validate.go +++ b/validate.go @@ -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"` @@ -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() +} diff --git a/validate_test.go b/validate_test.go index 2ddb59c..463b9a2 100644 --- a/validate_test.go +++ b/validate_test.go @@ -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 +}