github.com/terraform-linters/tflint-plugin-sdk@v0.22.0/internal/fixer.go (about)

     1  package internal
     2  
     3  import (
     4  	"bufio"
     5  	"bytes"
     6  	"fmt"
     7  	"strings"
     8  	"unicode"
     9  	"unicode/utf8"
    10  
    11  	"github.com/hashicorp/hcl/v2"
    12  	"github.com/hashicorp/hcl/v2/hclsyntax"
    13  	"github.com/hashicorp/hcl/v2/hclwrite"
    14  	"github.com/terraform-linters/tflint-plugin-sdk/hclext"
    15  	"github.com/terraform-linters/tflint-plugin-sdk/terraform"
    16  	"github.com/terraform-linters/tflint-plugin-sdk/tflint"
    17  	"github.com/zclconf/go-cty/cty"
    18  )
    19  
    20  // Fixer is a tool to rewrite HCL source code.
    21  type Fixer struct {
    22  	sources map[string][]byte
    23  	changes map[string][]byte
    24  	shifts  []shift
    25  
    26  	stashedChanges map[string][]byte
    27  	stashedShifts  []shift
    28  }
    29  
    30  type shift struct {
    31  	target hcl.Range // rewrite target range caused by the shift
    32  	start  int       // start byte index of the shift
    33  	offset int       // shift offset
    34  }
    35  
    36  // NewFixer creates a new Fixer instance.
    37  func NewFixer(sources map[string][]byte) *Fixer {
    38  	return &Fixer{
    39  		sources: sources,
    40  		changes: map[string][]byte{},
    41  		shifts:  []shift{},
    42  
    43  		stashedChanges: map[string][]byte{},
    44  		stashedShifts:  []shift{},
    45  	}
    46  }
    47  
    48  // ReplaceText rewrites the given range of source code to a new text.
    49  // If the range is overlapped with a previous rewrite range, it returns an error.
    50  //
    51  // Either string or tflint.TextNode is valid as an argument.
    52  // TextNode can be obtained with fixer.TextAt(range).
    53  // If the argument is a TextNode, and the range is contained in the replacement range,
    54  // this function automatically minimizes the replacement range as much as possible.
    55  //
    56  // For example, if the source code is "(foo)", ReplaceText(range, "[foo]")
    57  // rewrites the whole "(foo)". But ReplaceText(range, "[", TextAt(fooRange), "]")
    58  // rewrites only "(" and ")". This is useful to avoid unintended conflicts.
    59  func (f *Fixer) ReplaceText(rng hcl.Range, texts ...any) error {
    60  	if len(texts) == 0 {
    61  		return fmt.Errorf("no text to replace")
    62  	}
    63  
    64  	var start hcl.Pos = rng.Start
    65  	var new string
    66  
    67  	for _, text := range texts {
    68  		switch text := text.(type) {
    69  		case string:
    70  			new += text
    71  		case tflint.TextNode:
    72  			if rng.Filename == text.Range.Filename && start.Byte <= text.Range.Start.Byte {
    73  				if err := f.replaceText(hcl.Range{Filename: rng.Filename, Start: start, End: text.Range.Start}, new); err != nil {
    74  					return err
    75  				}
    76  				start = text.Range.End
    77  				new = ""
    78  			} else {
    79  				// If the text node is not contained in the replacement range, just append the text.
    80  				new += string(text.Bytes)
    81  			}
    82  		default:
    83  			return fmt.Errorf("ReplaceText only accepts string or textNode, but got %T", text)
    84  		}
    85  	}
    86  	return f.replaceText(hcl.Range{Filename: rng.Filename, Start: start, End: rng.End}, new)
    87  }
    88  
    89  func (f *Fixer) replaceText(rng hcl.Range, new string) error {
    90  	// If there are already changes, overwrite the changed content.
    91  	var file []byte
    92  	if change, exists := f.changes[rng.Filename]; exists {
    93  		file = change
    94  	} else if source, exists := f.sources[rng.Filename]; exists {
    95  		file = source
    96  	} else {
    97  		return fmt.Errorf("file not found: %s", rng.Filename)
    98  	}
    99  
   100  	// Apply rewrite gaps so that you can chain rewrites using pre-change ranges.
   101  	for _, shift := range f.shifts {
   102  		if shift.target.Filename != rng.Filename {
   103  			continue
   104  		}
   105  		if !shift.target.Overlap(rng).Empty() {
   106  			// If the range is the same as before, just update the content.
   107  			// Note that only the end byte index should reflect the shift.
   108  			if shift.target.Start.Byte == rng.Start.Byte && shift.target.End.Byte == rng.End.Byte {
   109  				rng.End.Byte += shift.offset
   110  				continue
   111  			}
   112  			return fmt.Errorf("range overlaps with a previous rewrite range: %s", shift.target.String())
   113  		}
   114  		// Apply shift to the range if the shift is before the range.
   115  		if shift.start <= rng.Start.Byte {
   116  			rng.Start.Byte += shift.offset
   117  			rng.End.Byte += shift.offset
   118  		}
   119  	}
   120  
   121  	buf := bytes.NewBuffer(bytes.Clone(file[:rng.Start.Byte]))
   122  	buf.WriteString(new)
   123  	buf.Write(file[rng.End.Byte:])
   124  
   125  	// If the new content is the same as the before, do nothing.
   126  	if bytes.Equal(file, buf.Bytes()) {
   127  		return nil
   128  	}
   129  
   130  	// Tracks rewrite gaps
   131  	oldBytes := rng.End.Byte - rng.Start.Byte
   132  	newBytes := len(new)
   133  	if oldBytes == newBytes {
   134  		// no shift: foo -> bar
   135  	} else if oldBytes < newBytes {
   136  		// shift right: foo -> foooo
   137  		//                        |-| shift
   138  		f.shifts = append(f.shifts, shift{
   139  			target: rng,
   140  			start:  rng.End.Byte,
   141  			offset: newBytes - oldBytes,
   142  		})
   143  	} else {
   144  		// shift left: foooo -> foo
   145  		//                         |-| shift
   146  		f.shifts = append(f.shifts, shift{
   147  			target: rng,
   148  			start:  rng.End.Byte - (oldBytes - newBytes),
   149  			offset: -(oldBytes - newBytes),
   150  		})
   151  	}
   152  
   153  	f.changes[rng.Filename] = buf.Bytes()
   154  	return nil
   155  }
   156  
   157  // InsertTextBefore inserts the given text before the given range.
   158  func (f *Fixer) InsertTextBefore(rng hcl.Range, text string) error {
   159  	return f.ReplaceText(hcl.Range{Filename: rng.Filename, Start: rng.Start, End: rng.Start}, text)
   160  }
   161  
   162  // InsertTextAfter inserts the given text after the given range.
   163  func (f *Fixer) InsertTextAfter(rng hcl.Range, text string) error {
   164  	return f.ReplaceText(hcl.Range{Filename: rng.Filename, Start: rng.End, End: rng.End}, text)
   165  }
   166  
   167  // Remove removes the given range of source code.
   168  func (f *Fixer) Remove(rng hcl.Range) error {
   169  	return f.ReplaceText(rng, "")
   170  }
   171  
   172  // RemoveAttribute removes the given attribute from the source code.
   173  // The difference from Remove is that it removes the attribute
   174  // and the associated newlines, indentations, and comments.
   175  // This only works for HCL native syntax. JSON syntax is not supported
   176  // and returns tflint.ErrFixNotSupported.
   177  func (f *Fixer) RemoveAttribute(attr *hcl.Attribute) error {
   178  	if terraform.IsJSONFilename(attr.Range.Filename) {
   179  		return tflint.ErrFixNotSupported
   180  	}
   181  
   182  	rng, err := f.expandRangeToTrivialTokens(attr.Range)
   183  	if err != nil {
   184  		return err
   185  	}
   186  	return f.Remove(rng)
   187  }
   188  
   189  // RemoveBlock removes the given block from the source code.
   190  // The difference from Remove is that it removes the block
   191  // and the associated newlines, indentations, and comments.
   192  // This only works for HCL native syntax. JSON syntax is not supported
   193  // and returns tflint.ErrFixNotSupported.
   194  func (f *Fixer) RemoveBlock(block *hcl.Block) error {
   195  	if terraform.IsJSONFilename(block.DefRange.Filename) {
   196  		return tflint.ErrFixNotSupported
   197  	}
   198  
   199  	source, exists := f.sources[block.DefRange.Filename]
   200  	if !exists {
   201  		return fmt.Errorf("file not found: %s", block.DefRange.Filename)
   202  	}
   203  	// Parse the source code to get the whole block range.
   204  	// Notice that hcl.Block does not have the whole range, but hclsyntax.Block does.
   205  	file, diags := hclsyntax.ParseConfig(source, block.DefRange.Filename, hcl.InitialPos)
   206  	if diags.HasErrors() {
   207  		return diags
   208  	}
   209  
   210  	var blockRange hcl.Range
   211  	diags = hclsyntax.VisitAll(file.Body.(*hclsyntax.Body), func(node hclsyntax.Node) hcl.Diagnostics {
   212  		if nativeBlock, ok := node.(*hclsyntax.Block); ok {
   213  			if nativeBlock.TypeRange.Start.Byte == block.TypeRange.Start.Byte {
   214  				blockRange = hcl.RangeBetween(block.DefRange, nativeBlock.CloseBraceRange)
   215  				return nil
   216  			}
   217  		}
   218  		return nil
   219  	})
   220  	if diags.HasErrors() {
   221  		return diags
   222  	}
   223  	if blockRange.Empty() {
   224  		return fmt.Errorf("block not found at %s:%d,%d", block.DefRange.Filename, block.DefRange.Start.Line, block.DefRange.Start.Column)
   225  	}
   226  
   227  	rng, err := f.expandRangeToTrivialTokens(blockRange)
   228  	if err != nil {
   229  		return err
   230  	}
   231  
   232  	return f.Remove(rng)
   233  }
   234  
   235  // RemoveExtBlock removes the given block from the source code.
   236  // This is similar to RemoveBlock, but it works for *hclext.Block.
   237  func (f *Fixer) RemoveExtBlock(block *hclext.Block) error {
   238  	// In RemoveBlock, body is not important, so convert the given block
   239  	// to a native block without the body.
   240  	return f.RemoveBlock(&hcl.Block{
   241  		Type:   block.Type,
   242  		Labels: block.Labels,
   243  
   244  		DefRange:    block.DefRange,
   245  		TypeRange:   block.TypeRange,
   246  		LabelRanges: block.LabelRanges,
   247  	})
   248  }
   249  
   250  // expandRangeToTrivialTokens expands the given range to include comments, newlines, and indentations.
   251  func (f *Fixer) expandRangeToTrivialTokens(rng hcl.Range) (hcl.Range, error) {
   252  	source, exists := f.sources[rng.Filename]
   253  	if !exists {
   254  		return rng, fmt.Errorf("file not found: %s", rng.Filename)
   255  	}
   256  	// Use tokenScanner to find tokens before and after the attribute/block range,
   257  	// in order to remove comments, newlines, and indentations.
   258  	scanner, diags := newTokenScanner(source, rng.Filename)
   259  	if diags.HasErrors() {
   260  		return rng, diags
   261  	}
   262  
   263  	var expanded = rng
   264  
   265  	// Scan backward until a newline is found, and expand the start position.
   266  	//
   267  	//   <-- start
   268  	//         |
   269  	//         foo = 1
   270  	if err := scanner.seek(rng.Start, tokenStart); err != nil {
   271  		return rng, err
   272  	}
   273  endScanBackward:
   274  	for scanner.scanBackward() {
   275  		switch scanner.token().Type {
   276  		case hclsyntax.TokenNewline:
   277  			// Seek to the end of the token to keep the newline.
   278  			scanner.seekTokenEnd()
   279  			break endScanBackward
   280  
   281  		case hclsyntax.TokenComment:
   282  			// For a trailing single-line comment, determines whether the comment is associated with itself.
   283  			// For example, the following comment is associated with the "foo" attribute and should be removed.
   284  			//
   285  			// # comment
   286  			// foo = 1
   287  			//
   288  			// On the other hand, the following comment is associated with the "bar" attribute and should not be removed.
   289  			//
   290  			// bar = 2 # comment
   291  			// foo = 1
   292  			//
   293  			// To determine these, we need to look at the tokens before the comment token.
   294  			if strings.HasPrefix(string(scanner.token().Bytes), "#") || strings.HasPrefix(string(scanner.token().Bytes), "//") {
   295  				trailingCommentIndex := scanner.index
   296  
   297  				for scanner.scanBackward() {
   298  					switch scanner.token().Type {
   299  					case hclsyntax.TokenComment:
   300  						// Ignore comment tokens in case there are multiple comments.
   301  						//
   302  						// # comment1
   303  						// # comment2
   304  						// foo = 1
   305  						continue
   306  
   307  					case hclsyntax.TokenNewline:
   308  						// If there is only a comment after the newline, the line can be deleted.
   309  						scanner.seekTokenEnd()
   310  						break endScanBackward
   311  
   312  					default:
   313  						// If there is a token other than comment or newline, seek to the ending position of the trailing comment.
   314  						if err := scanner.seekByIndex(trailingCommentIndex, tokenEnd); err != nil {
   315  							return rng, err
   316  						}
   317  						break endScanBackward
   318  					}
   319  				}
   320  			}
   321  
   322  		// For an inline block, use an opening brace instead.
   323  		//
   324  		// block { foo = 1 }   => TokenOBrace + Attribute + TokenCBrace
   325  		case hclsyntax.TokenOBrace:
   326  			// Seek to the end of the token to keep the brace.
   327  			scanner.seekTokenEnd()
   328  			break endScanBackward
   329  		}
   330  	}
   331  	expanded.Start = scanner.pos
   332  
   333  	// Count the number of newlines before the range.
   334  	// This is because it doesn't leave a nonsense newline after deletion
   335  	newlineCountInBackward := 0
   336  	for scanner.scanBackwardIf(hclsyntax.TokenNewline) {
   337  		newlineCountInBackward++
   338  	}
   339  
   340  	// Scan forward until a newline is found, and expand the end position.
   341  	//
   342  	//              end -->
   343  	//               |
   344  	//         foo = 1
   345  	if err := scanner.seek(rng.End, tokenEnd); err != nil {
   346  		return rng, err
   347  	}
   348  endScan:
   349  	for scanner.scan() {
   350  		switch scanner.token().Type {
   351  		case hclsyntax.TokenNewline:
   352  			// Remove newline
   353  			break endScan
   354  
   355  		case hclsyntax.TokenComment:
   356  			// For a trailing single-line comment, use a comment token instead because it does not produce a newline token.
   357  			//
   358  			// foo = 1                 => Attribute + TokenNewline
   359  			// foo = 1 # comment       => Attribute + TokenComment
   360  			// foo = 1 /* comment */   => Attribute + TokenComment + TokenNewline
   361  			if strings.HasPrefix(string(scanner.token().Bytes), "#") || strings.HasPrefix(string(scanner.token().Bytes), "//") {
   362  				break endScan
   363  			}
   364  
   365  		// For an inline block, use an closing brace instead.
   366  		//
   367  		// block { foo = 1 }   => TokenOBrace + Attribute + TokenCBrace
   368  		case hclsyntax.TokenCBrace:
   369  			// Seek to the start of the token to keep the brace.
   370  			scanner.seekTokenStart()
   371  			break endScan
   372  		}
   373  	}
   374  	expanded.End = scanner.pos
   375  
   376  	// Count the number of newlines after the range.
   377  	newlineCountInForward := 0
   378  	for scanner.scanIf(hclsyntax.TokenNewline) {
   379  		newlineCountInForward++
   380  	}
   381  	// If the number of newlines before and after the range is the same,
   382  	// expand the end position to delete nonsense newlines.
   383  	//
   384  	// foo = 1
   385  	//
   386  	// bar = 2   <-- delete this attribute
   387  	//
   388  	// baz = 3
   389  	//
   390  	// Newlines are removed like this:
   391  	//
   392  	// foo = 1
   393  	//
   394  	// baz = 3
   395  	//
   396  	if newlineCountInForward > 0 && newlineCountInBackward == newlineCountInForward {
   397  		expanded.End = scanner.pos
   398  	}
   399  
   400  	return expanded, nil
   401  }
   402  
   403  // TextAt returns a text node at the given range.
   404  // This is expected to be passed as an argument to ReplaceText.
   405  // Note this doesn't take into account the changes made by the fixer in a rule.
   406  func (f *Fixer) TextAt(rng hcl.Range) tflint.TextNode {
   407  	source := f.sources[rng.Filename]
   408  	if !rng.CanSliceBytes(source) {
   409  		return tflint.TextNode{Range: rng}
   410  	}
   411  	return tflint.TextNode{Bytes: rng.SliceBytes(source), Range: rng}
   412  }
   413  
   414  // ValueText returns a text representation of the given cty.Value.
   415  // Values are always converted to a single line. For more pretty-printing,
   416  // implement your own conversion function.
   417  //
   418  // This function is inspired by hclwrite.TokensForValue.
   419  // https://github.com/hashicorp/hcl/blob/v2.16.2/hclwrite/generate.go#L26
   420  func (f *Fixer) ValueText(val cty.Value) string {
   421  	switch {
   422  	case !val.IsKnown():
   423  		panic("cannot produce text for unknown value")
   424  
   425  	case val.IsNull():
   426  		return "null"
   427  
   428  	case val.Type() == cty.Bool:
   429  		if val.True() {
   430  			return "true"
   431  		}
   432  		return "false"
   433  
   434  	case val.Type() == cty.Number:
   435  		return val.AsBigFloat().Text('f', -1)
   436  
   437  	case val.Type() == cty.String:
   438  		return fmt.Sprintf(`"%s"`, escapeQuotedStringLit(val.AsString()))
   439  
   440  	case val.Type().IsListType() || val.Type().IsSetType() || val.Type().IsTupleType():
   441  		items := make([]string, 0, val.LengthInt())
   442  		for it := val.ElementIterator(); it.Next(); {
   443  			_, v := it.Element()
   444  			items = append(items, f.ValueText(v))
   445  		}
   446  		return fmt.Sprintf("[%s]", strings.Join(items, ", "))
   447  
   448  	case val.Type().IsMapType() || val.Type().IsObjectType():
   449  		if val.LengthInt() == 0 {
   450  			return "{}"
   451  		}
   452  		items := make([]string, 0, val.LengthInt())
   453  		for it := val.ElementIterator(); it.Next(); {
   454  			k, v := it.Element()
   455  			if hclsyntax.ValidIdentifier(k.AsString()) {
   456  				items = append(items, fmt.Sprintf("%s = %s", k.AsString(), f.ValueText(v)))
   457  			} else {
   458  				items = append(items, fmt.Sprintf("%s = %s", f.ValueText(k), f.ValueText(v)))
   459  			}
   460  		}
   461  		return fmt.Sprintf("{ %s }", strings.Join(items, ", "))
   462  
   463  	default:
   464  		panic(fmt.Sprintf("cannot produce text for %s", val.Type().FriendlyName()))
   465  	}
   466  }
   467  
   468  func escapeQuotedStringLit(s string) []byte {
   469  	if len(s) == 0 {
   470  		return nil
   471  	}
   472  	buf := make([]byte, 0, len(s))
   473  	for i, r := range s {
   474  		switch r {
   475  		case '\n':
   476  			buf = append(buf, '\\', 'n')
   477  		case '\r':
   478  			buf = append(buf, '\\', 'r')
   479  		case '\t':
   480  			buf = append(buf, '\\', 't')
   481  		case '"':
   482  			buf = append(buf, '\\', '"')
   483  		case '\\':
   484  			buf = append(buf, '\\', '\\')
   485  		case '$', '%':
   486  			buf = appendRune(buf, r)
   487  			remain := s[i+1:]
   488  			if len(remain) > 0 && remain[0] == '{' {
   489  				// Double up our template introducer symbol to escape it.
   490  				buf = appendRune(buf, r)
   491  			}
   492  		default:
   493  			if !unicode.IsPrint(r) {
   494  				var fmted string
   495  				if r < 65536 {
   496  					fmted = fmt.Sprintf("\\u%04x", r)
   497  				} else {
   498  					fmted = fmt.Sprintf("\\U%08x", r)
   499  				}
   500  				buf = append(buf, fmted...)
   501  			} else {
   502  				buf = appendRune(buf, r)
   503  			}
   504  		}
   505  	}
   506  	return buf
   507  }
   508  
   509  func appendRune(b []byte, r rune) []byte {
   510  	l := utf8.RuneLen(r)
   511  	for i := 0; i < l; i++ {
   512  		b = append(b, 0) // make room at the end of our buffer
   513  	}
   514  	ch := b[len(b)-l:]
   515  	utf8.EncodeRune(ch, r)
   516  	return b
   517  }
   518  
   519  // RangeTo returns a range from the given start position to the given text.
   520  // Note that it doesn't check if the text is actually in the range.
   521  func (f *Fixer) RangeTo(to string, filename string, start hcl.Pos) hcl.Range {
   522  	end := start
   523  	if to == "" {
   524  		return hcl.Range{Filename: filename, Start: start, End: end}
   525  	}
   526  
   527  	scanner := hcl.NewRangeScanner([]byte(to), filename, bufio.ScanLines)
   528  	for scanner.Scan() {
   529  		end = scanner.Range().End
   530  	}
   531  	if scanner.Err() != nil {
   532  		// never happen
   533  		panic(scanner.Err())
   534  	}
   535  
   536  	var line, column, bytes int
   537  	line = start.Line + end.Line - 1
   538  	if end.Line == 1 {
   539  		column = start.Column + end.Column - 1
   540  	} else {
   541  		column = end.Column
   542  	}
   543  	bytes = start.Byte + end.Byte
   544  
   545  	return hcl.Range{
   546  		Filename: filename,
   547  		Start:    start,
   548  		End:      hcl.Pos{Line: line, Column: column, Byte: bytes},
   549  	}
   550  }
   551  
   552  // Changes returns the changes made by the fixer.
   553  // Note this API is not intended to be used by plugins.
   554  func (f *Fixer) Changes() map[string][]byte {
   555  	return f.changes
   556  }
   557  
   558  // HasChanges returns true if the fixer has changes.
   559  // Note this API is not intended to be used by plugins.
   560  func (f *Fixer) HasChanges() bool {
   561  	return len(f.changes) > 0
   562  }
   563  
   564  // FormatChanges formats the changes made by the fixer.
   565  // Note this API is not intended to be used by plugins.
   566  func (f *Fixer) FormatChanges() {
   567  	for filename, content := range f.changes {
   568  		if terraform.IsJSONFilename(filename) {
   569  			continue
   570  		}
   571  		f.changes[filename] = hclwrite.Format(content)
   572  	}
   573  }
   574  
   575  // ApplyChanges applies the changes made by the fixer.
   576  // Note this API is not intended to be used by plugins.
   577  func (f *Fixer) ApplyChanges() {
   578  	for filename, content := range f.changes {
   579  		f.sources[filename] = content
   580  	}
   581  	f.changes = map[string][]byte{}
   582  	f.shifts = []shift{}
   583  }
   584  
   585  // StashChanges stashes the current changes.
   586  // Note this API is not intended to be used by plugins.
   587  func (f *Fixer) StashChanges() {
   588  	f.stashedChanges = map[string][]byte{}
   589  	for k, v := range f.changes {
   590  		f.stashedChanges[k] = v
   591  	}
   592  	f.stashedShifts = make([]shift, len(f.shifts))
   593  	copy(f.stashedShifts, f.shifts)
   594  }
   595  
   596  // PopChangesFromStash pops changes from the stash.
   597  // Note this API is not intended to be used by plugins.
   598  func (f *Fixer) PopChangesFromStash() {
   599  	f.changes = map[string][]byte{}
   600  	for k, v := range f.stashedChanges {
   601  		f.changes[k] = v
   602  	}
   603  	f.shifts = make([]shift, len(f.stashedShifts))
   604  	copy(f.shifts, f.stashedShifts)
   605  }