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 }