From 2b64976e6bcfa6ef7ae105a27aaafa4b8ebff589 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 20:22:51 +0000 Subject: [PATCH] refactor(terraform): replace HCL parser with minimal version scanner The hashicorp/hcl/v2 + zclconf/go-cty stack existed to extract a single attribute: required_version from a top-level terraform block. Replace it with a small scanner that tracks just enough HCL structure (comments, strings, interpolation, heredocs, block nesting) to locate that attribute, and fails closed to the state-file fallback on anything it cannot make sense of - the same path a full parser failure took. Shrinks the stripped linux/amd64 binary by 1.80 MB (16.93 MB -> 15.13 MB). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Qiyvpiy5jR2tyzwZ3zUki5 --- src/go.mod | 8 - src/go.sum | 18 --- src/segments/terraform.go | 21 +-- src/segments/terraform_hcl.go | 250 +++++++++++++++++++++++++++++ src/segments/terraform_hcl_test.go | 193 ++++++++++++++++++++++ 5 files changed, 447 insertions(+), 43 deletions(-) create mode 100644 src/segments/terraform_hcl.go create mode 100644 src/segments/terraform_hcl_test.go diff --git a/src/go.mod b/src/go.mod index 36e19d7e5..6f2eddfed 100644 --- a/src/go.mod +++ b/src/go.mod @@ -25,7 +25,6 @@ require ( require ( github.com/ConradIrwin/font v0.2.1 github.com/gookit/goutil v0.8.0 - github.com/hashicorp/hcl/v2 v2.24.0 github.com/invopop/jsonschema v0.14.0 github.com/lucasb-eyer/go-colorful v1.4.0 github.com/pelletier/go-toml/v2 v2.4.3 @@ -54,23 +53,16 @@ require ( require ( dario.cat/mergo v1.0.2 // indirect dmitri.shuralyov.com/font/woff2 v0.0.0-20180220214647-957792cbbdab // indirect - github.com/agext/levenshtein v1.2.3 // indirect - github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/buger/jsonparser v1.1.2 // indirect github.com/dsnet/compress v0.0.1 // indirect github.com/ebitengine/purego v0.10.0 // indirect - github.com/google/go-cmp v0.7.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/mitchellh/go-wordwrap v1.0.1 // indirect github.com/pb33f/ordered-map/v2 v2.3.1 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/shopspring/decimal v1.4.0 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect - github.com/zclconf/go-cty v1.17.0 // indirect go.yaml.in/yaml/v4 v4.0.0-rc.2 // indirect golang.org/x/exp v0.0.0-20241217172543-b2144cdd0a67 // indirect - golang.org/x/sync v0.22.0 // indirect - golang.org/x/tools v0.47.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/src/go.sum b/src/go.sum index 73556909f..567fde3bb 100644 --- a/src/go.sum +++ b/src/go.sum @@ -12,16 +12,12 @@ github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1 github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs= github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0= -github.com/agext/levenshtein v1.2.3 h1:YB2fHEn0UJagG8T1rrWknE3ZQzWM06O8AMAatNn7lmo= -github.com/agext/levenshtein v1.2.3/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= github.com/alecthomas/assert v1.0.0 h1:3XmGh/PSuLzDbK3W2gUbRXwgW5lqPkuqvRgeQ30FI5o= github.com/alecthomas/assert v1.0.0/go.mod h1:va/d2JC+M7F6s+80kl/R3G7FUiW6JzUO+hPhLyJ36ZY= github.com/alecthomas/colour v0.1.0 h1:nOE9rJm6dsZ66RGWYSFrXw461ZIt9A6+nHgL7FRrDUk= github.com/alecthomas/colour v0.1.0/go.mod h1:QO9JBoKquHd+jz9nshCh40fOfO+JzsoXy8qTHF68zU0= github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= -github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY= -github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4= github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJk= @@ -40,8 +36,6 @@ github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7z github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= -github.com/go-test/deep v1.0.3 h1:ZrJSEWsXzPOxaZnFteGEfooLba+ju3FYIbOrS+rQd68= -github.com/go-test/deep v1.0.3/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -52,8 +46,6 @@ github.com/gookit/color v1.6.1 h1:KoTnDxJPRgrL0SoX0f8rCFg2zI0t4E3GZZBMo2nN8LU= github.com/gookit/color v1.6.1/go.mod h1:9ACFc7/1IpHGBW8RwuDm/0YEnhg3dwwXpoMsmtyHfjs= github.com/gookit/goutil v0.8.0 h1:efZWxfesXw8+5tQfTfRMSIC6A0ax527/H+A/aIiaSrw= github.com/gookit/goutil v0.8.0/go.mod h1:vJS9HXctYTCLtCsZot5L5xF+O1oR17cDYO9R0HxBmnU= -github.com/hashicorp/hcl/v2 v2.24.0 h1:2QJdZ454DSsYGoaE6QheQZjtKZSUs9Nh2izTWiwQxvE= -github.com/hashicorp/hcl/v2 v2.24.0/go.mod h1:oGoO1FIQYfn/AgyOhlg9qLC6/nOJPX3qGbkZpYAcqfM= github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= @@ -77,8 +69,6 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= -github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= -github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/pb33f/ordered-map/v2 v2.3.1 h1:5319HDO0aw4DA4gzi+zv4FXU9UlSs3xGZ40wcP1nBjY= @@ -130,10 +120,6 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= -github.com/zclconf/go-cty v1.17.0 h1:seZvECve6XX4tmnvRzWtJNHdscMtYEx5R7bnnVyd/d0= -github.com/zclconf/go-cty v1.17.0/go.mod h1:wqFzcImaLTI6A5HfsRwB0nj5n0MRZFwmey8YoFPPs3U= -github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6ZMSMNJFMOjqrGHynW3DIBuR2H9j0ug+Mo= -github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940/go.mod h1:CmBdvvj3nqzfzJ6nTCIwDTPZ56aVGvDrmztiO5g3qrM= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= @@ -145,8 +131,6 @@ golang.org/x/exp v0.0.0-20241217172543-b2144cdd0a67 h1:1UoZQm6f0P/ZO0w1Ri+f+ifG/ golang.org/x/exp v0.0.0-20241217172543-b2144cdd0a67/go.mod h1:qj5a5QZpwLU2NLQudwIN5koi3beDhSAlJwa67PuM98c= golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= -golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= -golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -160,8 +144,6 @@ golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= -golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/src/segments/terraform.go b/src/segments/terraform.go index 3c24ab039..7426d1101 100644 --- a/src/segments/terraform.go +++ b/src/segments/terraform.go @@ -7,8 +7,6 @@ import ( "slices" "strings" - "github.com/hashicorp/hcl/v2/gohcl" - "github.com/hashicorp/hcl/v2/hclparse" "github.com/jandedobbeleer/oh-my-posh/src/segments/options" ) @@ -27,12 +25,8 @@ func (tf *Terraform) Template() string { return " {{ .WorkspaceName }}{{ if .Version }} {{ .Version }}{{ end }} " } -type TerraFormConfig struct { - Terraform *TerraformBlock `hcl:"terraform,block"` -} - type TerraformBlock struct { - Version *string `hcl:"required_version" json:"terraform_version"` + Version *string `json:"terraform_version"` } func (tf *Terraform) Enabled() bool { @@ -127,20 +121,13 @@ func (tf *Terraform) setVersionFromTfFiles() error { continue } - parser := hclparse.NewParser() content := tf.env.FileContent(file) - hclFile, diags := parser.ParseHCL([]byte(content), file) - if diags != nil { + version, ok := extractRequiredVersion(content) + if !ok { continue } - var config TerraFormConfig - diags = gohcl.DecodeBody(hclFile.Body, nil, &config) - if diags != nil || config.Terraform == nil { - continue - } - - tf.TerraformBlock = *config.Terraform + tf.Version = &version return nil } return errors.New("no valid terraform files found") diff --git a/src/segments/terraform_hcl.go b/src/segments/terraform_hcl.go new file mode 100644 index 000000000..2aef2f464 --- /dev/null +++ b/src/segments/terraform_hcl.go @@ -0,0 +1,250 @@ +package segments + +import "strings" + +// extractRequiredVersion scans HCL source for a top-level terraform block and +// returns the value of its required_version attribute. It is not an HCL +// parser: it only tracks the structure needed to locate that one attribute - +// comments, strings (including template interpolation), heredocs and block +// nesting. Whenever the source doesn't match what it expects, it reports +// false and the segment falls back to the state file, the same path a full +// parser failure took before. +func extractRequiredVersion(src string) (string, bool) { + s := &hclScanner{src: src, terraformDepth: -1} + + for !s.eof() { + s.skipTrivia() + if s.eof() { + break + } + + switch c := s.src[s.pos]; { + case c == '"': + s.skipString() + case c == '<' && s.peek() == '<': + s.skipHeredoc() + case c == '{': + s.depth++ + s.pos++ + case c == '}': + s.depth-- + if s.depth < s.terraformDepth { + s.terraformDepth = -1 + } + s.pos++ + case isHCLIdentStart(c): + ident := s.readIdent() + if version, ok := s.handleIdent(ident); ok { + return version, true + } + default: + s.pos++ + } + } + + return "", false +} + +type hclScanner struct { + src string + pos int + depth int + terraformDepth int +} + +// handleIdent inspects an identifier just read at the current depth. A +// top-level terraform identifier followed by { opens the block we're after; +// required_version directly inside it yields the result. +func (s *hclScanner) handleIdent(ident string) (string, bool) { + if s.depth == 0 && ident == "terraform" { + s.skipTrivia() + if !s.eof() && s.src[s.pos] == '{' { + s.depth++ + s.terraformDepth = s.depth + s.pos++ + } + return "", false + } + + if ident != "required_version" || s.terraformDepth == -1 || s.depth != s.terraformDepth { + return "", false + } + + s.skipTrivia() + if s.eof() || s.src[s.pos] != '=' { + return "", false + } + + s.pos++ + s.skipTrivia() + if s.eof() || s.src[s.pos] != '"' { + return "", false + } + + return s.readString() +} + +func (s *hclScanner) eof() bool { + return s.pos >= len(s.src) +} + +func (s *hclScanner) peek() byte { + if s.pos+1 >= len(s.src) { + return 0 + } + + return s.src[s.pos+1] +} + +// skipTrivia advances past whitespace and comments (#, // and /* */). +func (s *hclScanner) skipTrivia() { + for !s.eof() { + switch c := s.src[s.pos]; { + case c == ' ' || c == '\t' || c == '\r' || c == '\n': + s.pos++ + case c == '#' || (c == '/' && s.peek() == '/'): + for !s.eof() && s.src[s.pos] != '\n' { + s.pos++ + } + case c == '/' && s.peek() == '*': + s.pos += 2 + for !s.eof() && (s.src[s.pos] != '*' || s.peek() != '/') { + s.pos++ + } + s.pos += 2 + default: + return + } + } +} + +// skipString advances past a quoted string, honoring escapes and ${ } +// template interpolation, which may itself contain strings and braces. +func (s *hclScanner) skipString() { + _, _ = s.readString() +} + +// readString consumes a quoted string starting at the opening quote and +// returns its content with common escapes resolved. Interpolation is copied +// verbatim; required_version must be a static string, so a value containing +// ${ } simply won't match a version constraint downstream. +func (s *hclScanner) readString() (string, bool) { + var sb strings.Builder + s.pos++ // opening quote + + for !s.eof() { + switch c := s.src[s.pos]; { + case c == '"': + s.pos++ + return sb.String(), true + case c == '\\': + escaped, ok := hclEscape(s.peek()) + if ok { + sb.WriteByte(escaped) + } + + if !ok { + sb.WriteByte(c) + sb.WriteByte(s.peek()) + } + + s.pos += 2 + case c == '$' && s.peek() == '{': + start := s.pos + s.skipInterpolation() + sb.WriteString(s.src[start:s.pos]) + default: + sb.WriteByte(c) + s.pos++ + } + } + + return "", false +} + +// skipInterpolation advances past a ${ } template expression, tracking brace +// nesting and skipping over nested strings so their braces don't miscount. +func (s *hclScanner) skipInterpolation() { + s.pos += 2 + braces := 1 + + for !s.eof() && braces > 0 { + switch s.src[s.pos] { + case '{': + braces++ + s.pos++ + case '}': + braces-- + s.pos++ + case '"': + s.skipString() + default: + s.pos++ + } + } +} + +// skipHeredoc advances past a << or <<- heredoc, which runs until a line +// containing only the delimiter label. +func (s *hclScanner) skipHeredoc() { + s.pos += 2 + if !s.eof() && s.src[s.pos] == '-' { + s.pos++ + } + + start := s.pos + for !s.eof() && s.src[s.pos] != '\n' { + s.pos++ + } + + label := strings.TrimSpace(s.src[start:s.pos]) + if label == "" { + return + } + + for !s.eof() { + s.pos++ // the newline + lineStart := s.pos + for !s.eof() && s.src[s.pos] != '\n' { + s.pos++ + } + + if strings.TrimSpace(s.src[lineStart:s.pos]) == label { + return + } + } +} + +func (s *hclScanner) readIdent() string { + start := s.pos + for !s.eof() && isHCLIdentPart(s.src[s.pos]) { + s.pos++ + } + + return s.src[start:s.pos] +} + +func hclEscape(c byte) (byte, bool) { + switch c { + case 'n': + return '\n', true + case 't': + return '\t', true + case 'r': + return '\r', true + case '"': + return '"', true + case '\\': + return '\\', true + default: + return 0, false + } +} + +func isHCLIdentStart(c byte) bool { + return c == '_' || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') +} + +func isHCLIdentPart(c byte) bool { + return isHCLIdentStart(c) || c == '-' || (c >= '0' && c <= '9') +} diff --git a/src/segments/terraform_hcl_test.go b/src/segments/terraform_hcl_test.go new file mode 100644 index 000000000..3c8baecb3 --- /dev/null +++ b/src/segments/terraform_hcl_test.go @@ -0,0 +1,193 @@ +package segments + +import ( + "testing" + + "github.com/alecthomas/assert" +) + +func TestExtractRequiredVersion(t *testing.T) { + cases := []struct { + Case string + Source string + Expected string + OK bool + }{ + { + Case: "simple block", + Source: "terraform {\n required_version = \">= 1.0.10\"\n}\n", + Expected: ">= 1.0.10", + OK: true, + }, + { + Case: "nested required_providers before the attribute", + Source: `terraform { + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 4.0" + } + } + required_version = ">= 1.3.0, < 2.0.0" +}`, + Expected: ">= 1.3.0, < 2.0.0", + OK: true, + }, + { + Case: "required_version inside required_providers is ignored", + Source: `terraform { + required_providers { + fake = { + required_version = "9.9.9" + } + } +}`, + OK: false, + }, + { + Case: "other top-level blocks before terraform", + Source: `provider "aws" { + region = "eu-west-1" +} + +resource "aws_instance" "web" { + tags = { + Name = "terraform" + } +} + +terraform { + required_version = "~> 1.5" +}`, + Expected: "~> 1.5", + OK: true, + }, + { + Case: "comments of all flavors", + Source: `# leading comment with terraform { +// another one with required_version = "0.0.0" +/* block comment + terraform { required_version = "0.0.1" } +*/ +terraform { # trailing + /* inline */ required_version = "1.6.2" // done +}`, + Expected: "1.6.2", + OK: true, + }, + { + Case: "string containing braces does not break depth tracking", + Source: `locals { + tpl = "prefix { nested " +} + +terraform { + required_version = ">= 1.1" +}`, + Expected: ">= 1.1", + OK: true, + }, + { + Case: "interpolation with nested string and braces", + Source: `locals { + greeting = "${var.enabled ? "y{es" : "no}"} done" +} + +terraform { + required_version = "1.7.0" +}`, + Expected: "1.7.0", + OK: true, + }, + { + Case: "heredoc containing a fake terraform block", + Source: `resource "local_file" "f" { + content = <= 1.0", + OK: false, + }, + { + Case: "escaped quote in value", + Source: `terraform { required_version = "a\"b" }`, + Expected: `a"b`, + OK: true, + }, + { + Case: "windows line endings", + Source: "terraform {\r\n required_version = \">= 1.2.3\"\r\n}\r\n", + Expected: ">= 1.2.3", + OK: true, + }, + { + Case: "identifier merely containing terraform is not a block", + Source: `terraform_version = "0.0.0" + +terraform { + required_version = "1.9.9" +}`, + Expected: "1.9.9", + OK: true, + }, + { + Case: "second terraform block wins when first has no version", + Source: `terraform { + backend "s3" {} +} + +terraform { + required_version = "1.4.4" +}`, + Expected: "1.4.4", + OK: true, + }, + } + + for _, tc := range cases { + version, ok := extractRequiredVersion(tc.Source) + assert.Equal(t, tc.OK, ok, tc.Case) + assert.Equal(t, tc.Expected, version, tc.Case) + } +}