github.com/hashicorp/hcl/v2@v2.20.0/diagnostic_text.go (about)

     1  // Copyright (c) HashiCorp, Inc.
     2  // SPDX-License-Identifier: MPL-2.0
     3  
     4  package hcl
     5  
     6  import (
     7  	"bufio"
     8  	"bytes"
     9  	"errors"
    10  	"fmt"
    11  	"io"
    12  	"sort"
    13  
    14  	wordwrap "github.com/mitchellh/go-wordwrap"
    15  	"github.com/zclconf/go-cty/cty"
    16  )
    17  
    18  type diagnosticTextWriter struct {
    19  	files map[string]*File
    20  	wr    io.Writer
    21  	width uint
    22  	color bool
    23  }
    24  
    25  // NewDiagnosticTextWriter creates a DiagnosticWriter that writes diagnostics
    26  // to the given writer as formatted text.
    27  //
    28  // It is designed to produce text appropriate to print in a monospaced font
    29  // in a terminal of a particular width, or optionally with no width limit.
    30  //
    31  // The given width may be zero to disable word-wrapping of the detail text
    32  // and truncation of source code snippets.
    33  //
    34  // If color is set to true, the output will include VT100 escape sequences to
    35  // color-code the severity indicators. It is suggested to turn this off if
    36  // the target writer is not a terminal.
    37  func NewDiagnosticTextWriter(wr io.Writer, files map[string]*File, width uint, color bool) DiagnosticWriter {
    38  	return &diagnosticTextWriter{
    39  		files: files,
    40  		wr:    wr,
    41  		width: width,
    42  		color: color,
    43  	}
    44  }
    45  
    46  func (w *diagnosticTextWriter) WriteDiagnostic(diag *Diagnostic) error {
    47  	if diag == nil {
    48  		return errors.New("nil diagnostic")
    49  	}
    50  
    51  	var colorCode, highlightCode, resetCode string
    52  	if w.color {
    53  		switch diag.Severity {
    54  		case DiagError:
    55  			colorCode = "\x1b[31m"
    56  		case DiagWarning:
    57  			colorCode = "\x1b[33m"
    58  		}
    59  		resetCode = "\x1b[0m"
    60  		highlightCode = "\x1b[1;4m"
    61  	}
    62  
    63  	var severityStr string
    64  	switch diag.Severity {
    65  	case DiagError:
    66  		severityStr = "Error"
    67  	case DiagWarning:
    68  		severityStr = "Warning"
    69  	default:
    70  		// should never happen
    71  		severityStr = "???????"
    72  	}
    73  
    74  	fmt.Fprintf(w.wr, "%s%s%s: %s\n\n", colorCode, severityStr, resetCode, diag.Summary)
    75  
    76  	if diag.Subject != nil {
    77  		snipRange := *diag.Subject
    78  		highlightRange := snipRange
    79  		if diag.Context != nil {
    80  			// Show enough of the source code to include both the subject
    81  			// and context ranges, which overlap in all reasonable
    82  			// situations.
    83  			snipRange = RangeOver(snipRange, *diag.Context)
    84  		}
    85  		// We can't illustrate an empty range, so we'll turn such ranges into
    86  		// single-character ranges, which might not be totally valid (may point
    87  		// off the end of a line, or off the end of the file) but are good
    88  		// enough for the bounds checks we do below.
    89  		if snipRange.Empty() {
    90  			snipRange.End.Byte++
    91  			snipRange.End.Column++
    92  		}
    93  		if highlightRange.Empty() {
    94  			highlightRange.End.Byte++
    95  			highlightRange.End.Column++
    96  		}
    97  
    98  		file := w.files[diag.Subject.Filename]
    99  		if file == nil || file.Bytes == nil {
   100  			fmt.Fprintf(w.wr, "  on %s line %d:\n  (source code not available)\n\n", diag.Subject.Filename, diag.Subject.Start.Line)
   101  		} else {
   102  
   103  			var contextLine string
   104  			if diag.Subject != nil {
   105  				contextLine = contextString(file, diag.Subject.Start.Byte)
   106  				if contextLine != "" {
   107  					contextLine = ", in " + contextLine
   108  				}
   109  			}
   110  
   111  			fmt.Fprintf(w.wr, "  on %s line %d%s:\n", diag.Subject.Filename, diag.Subject.Start.Line, contextLine)
   112  
   113  			src := file.Bytes
   114  			sc := NewRangeScanner(src, diag.Subject.Filename, bufio.ScanLines)
   115  
   116  			for sc.Scan() {
   117  				lineRange := sc.Range()
   118  				if !lineRange.Overlaps(snipRange) {
   119  					continue
   120  				}
   121  
   122  				beforeRange, highlightedRange, afterRange := lineRange.PartitionAround(highlightRange)
   123  				if highlightedRange.Empty() {
   124  					fmt.Fprintf(w.wr, "%4d: %s\n", lineRange.Start.Line, sc.Bytes())
   125  				} else {
   126  					before := beforeRange.SliceBytes(src)
   127  					highlighted := highlightedRange.SliceBytes(src)
   128  					after := afterRange.SliceBytes(src)
   129  					fmt.Fprintf(
   130  						w.wr, "%4d: %s%s%s%s%s\n",
   131  						lineRange.Start.Line,
   132  						before,
   133  						highlightCode, highlighted, resetCode,
   134  						after,
   135  					)
   136  				}
   137  
   138  			}
   139  
   140  			w.wr.Write([]byte{'\n'})
   141  		}
   142  
   143  		if diag.Expression != nil && diag.EvalContext != nil {
   144  			// We will attempt to render the values for any variables
   145  			// referenced in the given expression as additional context, for
   146  			// situations where the same expression is evaluated multiple
   147  			// times in different scopes.
   148  			expr := diag.Expression
   149  			ctx := diag.EvalContext
   150  
   151  			vars := expr.Variables()
   152  			stmts := make([]string, 0, len(vars))
   153  			seen := make(map[string]struct{}, len(vars))
   154  			for _, traversal := range vars {
   155  				val, diags := traversal.TraverseAbs(ctx)
   156  				if diags.HasErrors() {
   157  					// Skip anything that generates errors, since we probably
   158  					// already have the same error in our diagnostics set
   159  					// already.
   160  					continue
   161  				}
   162  
   163  				traversalStr := w.traversalStr(traversal)
   164  				if _, exists := seen[traversalStr]; exists {
   165  					continue // don't show duplicates when the same variable is referenced multiple times
   166  				}
   167  				switch {
   168  				case !val.IsKnown():
   169  					// Can't say anything about this yet, then.
   170  					continue
   171  				case val.IsNull():
   172  					stmts = append(stmts, fmt.Sprintf("%s set to null", traversalStr))
   173  				default:
   174  					stmts = append(stmts, fmt.Sprintf("%s as %s", traversalStr, w.valueStr(val)))
   175  				}
   176  				seen[traversalStr] = struct{}{}
   177  			}
   178  
   179  			sort.Strings(stmts) // FIXME: Should maybe use a traversal-aware sort that can sort numeric indexes properly?
   180  			last := len(stmts) - 1
   181  
   182  			for i, stmt := range stmts {
   183  				switch i {
   184  				case 0:
   185  					w.wr.Write([]byte{'w', 'i', 't', 'h', ' '})
   186  				default:
   187  					w.wr.Write([]byte{' ', ' ', ' ', ' ', ' '})
   188  				}
   189  				w.wr.Write([]byte(stmt))
   190  				switch i {
   191  				case last:
   192  					w.wr.Write([]byte{'.', '\n', '\n'})
   193  				default:
   194  					w.wr.Write([]byte{',', '\n'})
   195  				}
   196  			}
   197  		}
   198  	}
   199  
   200  	if diag.Detail != "" {
   201  		detail := diag.Detail
   202  		if w.width != 0 {
   203  			detail = wordwrap.WrapString(detail, w.width)
   204  		}
   205  		fmt.Fprintf(w.wr, "%s\n\n", detail)
   206  	}
   207  
   208  	return nil
   209  }
   210  
   211  func (w *diagnosticTextWriter) WriteDiagnostics(diags Diagnostics) error {
   212  	for _, diag := range diags {
   213  		err := w.WriteDiagnostic(diag)
   214  		if err != nil {
   215  			return err
   216  		}
   217  	}
   218  	return nil
   219  }
   220  
   221  func (w *diagnosticTextWriter) traversalStr(traversal Traversal) string {
   222  	// This is a specialized subset of traversal rendering tailored to
   223  	// producing helpful contextual messages in diagnostics. It is not
   224  	// comprehensive nor intended to be used for other purposes.
   225  
   226  	var buf bytes.Buffer
   227  	for _, step := range traversal {
   228  		switch tStep := step.(type) {
   229  		case TraverseRoot:
   230  			buf.WriteString(tStep.Name)
   231  		case TraverseAttr:
   232  			buf.WriteByte('.')
   233  			buf.WriteString(tStep.Name)
   234  		case TraverseIndex:
   235  			buf.WriteByte('[')
   236  			if keyTy := tStep.Key.Type(); keyTy.IsPrimitiveType() {
   237  				buf.WriteString(w.valueStr(tStep.Key))
   238  			} else {
   239  				// We'll just use a placeholder for more complex values,
   240  				// since otherwise our result could grow ridiculously long.
   241  				buf.WriteString("...")
   242  			}
   243  			buf.WriteByte(']')
   244  		}
   245  	}
   246  	return buf.String()
   247  }
   248  
   249  func (w *diagnosticTextWriter) valueStr(val cty.Value) string {
   250  	// This is a specialized subset of value rendering tailored to producing
   251  	// helpful but concise messages in diagnostics. It is not comprehensive
   252  	// nor intended to be used for other purposes.
   253  
   254  	ty := val.Type()
   255  	switch {
   256  	case val.IsNull():
   257  		return "null"
   258  	case !val.IsKnown():
   259  		// Should never happen here because we should filter before we get
   260  		// in here, but we'll do something reasonable rather than panic.
   261  		return "(not yet known)"
   262  	case ty == cty.Bool:
   263  		if val.True() {
   264  			return "true"
   265  		}
   266  		return "false"
   267  	case ty == cty.Number:
   268  		bf := val.AsBigFloat()
   269  		return bf.Text('g', 10)
   270  	case ty == cty.String:
   271  		// Go string syntax is not exactly the same as HCL native string syntax,
   272  		// but we'll accept the minor edge-cases where this is different here
   273  		// for now, just to get something reasonable here.
   274  		return fmt.Sprintf("%q", val.AsString())
   275  	case ty.IsCollectionType() || ty.IsTupleType():
   276  		l := val.LengthInt()
   277  		switch l {
   278  		case 0:
   279  			return "empty " + ty.FriendlyName()
   280  		case 1:
   281  			return ty.FriendlyName() + " with 1 element"
   282  		default:
   283  			return fmt.Sprintf("%s with %d elements", ty.FriendlyName(), l)
   284  		}
   285  	case ty.IsObjectType():
   286  		atys := ty.AttributeTypes()
   287  		l := len(atys)
   288  		switch l {
   289  		case 0:
   290  			return "object with no attributes"
   291  		case 1:
   292  			var name string
   293  			for k := range atys {
   294  				name = k
   295  			}
   296  			return fmt.Sprintf("object with 1 attribute %q", name)
   297  		default:
   298  			return fmt.Sprintf("object with %d attributes", l)
   299  		}
   300  	default:
   301  		return ty.FriendlyName()
   302  	}
   303  }
   304  
   305  func contextString(file *File, offset int) string {
   306  	type contextStringer interface {
   307  		ContextString(offset int) string
   308  	}
   309  
   310  	if cser, ok := file.Nav.(contextStringer); ok {
   311  		return cser.ContextString(offset)
   312  	}
   313  	return ""
   314  }