hcl-checker 1.6.3 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 44527a5a8434256fcd8047949c77e74a00f19ddc13ae277a211bd644ddc3d33f
4
- data.tar.gz: b815cfa83eb6fce5cece1896b403584c7e61b97f293002d6b45c289d33a1d69f
3
+ metadata.gz: 377ff190a52bcc216535c048cab19155c0f07c22a246740678f58171d4b17d6e
4
+ data.tar.gz: a9b1b18a57d077470ebfd8c27629a7dbd5769d2eafb2525ce809613f78f0b351
5
5
  SHA512:
6
- metadata.gz: 6b3195d31385fa8df29acf196c77865f93a0fb9c219899701f9037298896aaa1d5a76bb973e8937ea28001ea765b0ea0abde59d700053d85d4aff0fc6eb3ee86
7
- data.tar.gz: 1f08787b7f08e6d9d1e560acaf42cf0f5592a3bc4c8dc3001eace7ad253a1564b83c8b2ce6907376b786f3918337e303faabcdff60409ee20ea04c86d999e87a
6
+ metadata.gz: 5dcdcff0e7169f375e045a9e27eb5fd7f0f98046b57f021884eff0dd70b8afaf0be22c6d34d0f460374efcfce766f91cfa4aec7fe85baa7d7c345bf297d0a6b7
7
+ data.tar.gz: 63cefafcb6395a016c5403f8ea9a639d3a6f25311a2de5349547aa15a2a010337f730ed9d164c8eb57331cd33231552484fbbba422594c5c7bd723fb44279bec
@@ -0,0 +1,27 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+
9
+ jobs:
10
+ test:
11
+ runs-on: ubuntu-latest
12
+ strategy:
13
+ fail-fast: false
14
+ matrix:
15
+ ruby: ['3.3', '3.4', '4.0']
16
+
17
+ steps:
18
+ - uses: actions/checkout@v4
19
+
20
+ - name: Set up Ruby
21
+ uses: ruby/setup-ruby@v1
22
+ with:
23
+ ruby-version: ${{ matrix.ruby }}
24
+ bundler-cache: true
25
+
26
+ - name: Run tests
27
+ run: bundle exec rake spec
data/.gitignore CHANGED
@@ -1,4 +1,5 @@
1
1
  /.bundle/
2
+ /vendor/
2
3
  /.yardoc
3
4
  /_yardoc/
4
5
  /coverage/
data/.ruby-version CHANGED
@@ -1 +1 @@
1
- 2.7.0
1
+ 3.4.10
data/Gemfile CHANGED
@@ -1,14 +1,5 @@
1
1
  source 'https://rubygems.org'
2
2
 
3
- # This is needed due https://nvd.nist.gov/vuln/detail/CVE-2018-14404
4
- # A NULL pointer dereference vulnerability exists in the xpath.c:xmlXPathCompOpEval()
5
- # function of libxml2 through 2.9.8 when parsing an invalid XPath expression in the
6
- # XPATH_OP_AND or XPATH_OP_OR case. Applications processing untrusted XSL format inputs
7
- # with the use of the libxml2 library may be vulnerable to a denial of service attack due
8
- # to a crash of the application.
9
- # Nokogiri >= 1.8.5 solves this problem
10
- gem 'nokogiri', '>= 1.14.2'
11
-
12
3
  git_source(:github) { |repo_name| "https://github.com/#{repo_name}" }
13
4
 
14
5
  # Specify your gem's dependencies in hcl-checker.gemspec
data/Gemfile.lock CHANGED
@@ -1,60 +1,44 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- hcl-checker (1.6.3)
4
+ hcl-checker (2.0.0)
5
5
 
6
6
  GEM
7
7
  remote: https://rubygems.org/
8
8
  specs:
9
- diff-lcs (1.5.0)
10
- filesize (0.2.0)
11
- jsobfu (0.4.2)
12
- rkelly-remix
13
- json (2.6.1)
14
- metasm (1.0.5)
15
- nokogiri (1.14.2-arm64-darwin)
16
- racc (~> 1.4)
17
- nokogiri (1.14.2-x86_64-darwin)
18
- racc (~> 1.4)
19
- racc (1.5.0)
20
- rake (12.3.3)
21
- rb-readline (0.5.5)
22
- rex (2.0.12)
23
- filesize (~> 0)
24
- jsobfu (~> 0)
25
- json (~> 2)
26
- metasm (~> 1)
27
- nokogiri (~> 1)
28
- rb-readline (~> 0)
29
- rexical (1.0.7)
30
- rkelly-remix (0.0.7)
31
- rspec (3.11.0)
32
- rspec-core (~> 3.11.0)
33
- rspec-expectations (~> 3.11.0)
34
- rspec-mocks (~> 3.11.0)
35
- rspec-core (3.11.0)
36
- rspec-support (~> 3.11.0)
37
- rspec-expectations (3.11.0)
9
+ diff-lcs (1.6.2)
10
+ getoptlong (0.2.1)
11
+ racc (1.8.1)
12
+ rake (13.4.2)
13
+ rexical (1.0.8)
14
+ getoptlong
15
+ rspec (3.13.2)
16
+ rspec-core (~> 3.13.0)
17
+ rspec-expectations (~> 3.13.0)
18
+ rspec-mocks (~> 3.13.0)
19
+ rspec-core (3.13.6)
20
+ rspec-support (~> 3.13.0)
21
+ rspec-expectations (3.13.5)
38
22
  diff-lcs (>= 1.2.0, < 2.0)
39
- rspec-support (~> 3.11.0)
40
- rspec-mocks (3.11.1)
23
+ rspec-support (~> 3.13.0)
24
+ rspec-mocks (3.13.8)
41
25
  diff-lcs (>= 1.2.0, < 2.0)
42
- rspec-support (~> 3.11.0)
43
- rspec-support (3.11.0)
26
+ rspec-support (~> 3.13.0)
27
+ rspec-support (3.13.7)
44
28
 
45
29
  PLATFORMS
46
- arm64-darwin-21
47
- x86_64-darwin-21
30
+ aarch64-linux-gnu
31
+ arm64-darwin
32
+ x86_64-darwin
33
+ x86_64-linux-gnu
48
34
 
49
35
  DEPENDENCIES
50
- bundler (~> 2.4.7)
36
+ bundler (~> 2.4)
51
37
  hcl-checker!
52
- nokogiri (>= 1.14.2)
53
- racc (= 1.5.0)
54
- rake (~> 12.3.3)
55
- rex (= 2.0.12)
56
- rexical (>= 1.0.7)
38
+ racc (~> 1.8)
39
+ rake (~> 13.0)
40
+ rexical (>= 1.0.8)
57
41
  rspec (~> 3.0)
58
42
 
59
43
  BUNDLED WITH
60
- 2.4.7
44
+ 2.4.22
data/README.md CHANGED
@@ -2,10 +2,6 @@
2
2
 
3
3
  **Hashicorp Configuration Language** syntax checker and parser.
4
4
 
5
- Parser originally created by [Sikula](https://github.com/sikula) and available
6
- at [Ruby HCL Repository](https://github.com/sikula/ruby-hcl). Only works with
7
- [HCL Version 1](https://github.com/hashicorp/hcl).
8
-
9
5
  ## Installation
10
6
 
11
7
  Add this line to your application's Gemfile:
@@ -26,7 +22,7 @@ Or install it yourself as:
26
22
 
27
23
  Load HCL string:
28
24
 
29
- ```
25
+ ```ruby
30
26
  hcl_string = 'provider "aws" {
31
27
  region = "${var.aws_region}"
32
28
  access_key = "${var.aws_access_key}"
@@ -46,45 +42,153 @@ resource "aws_vpc" "default" {
46
42
  You can validate the `hcl_string` contents with `valid?` method. This will
47
43
  return `true` if is a valid HCL or `false` if not.
48
44
 
49
- ```
50
- 2.3.2 :014 > HCL::Checker.valid? hcl_string
51
- => true
45
+ ```ruby
46
+ HCL::Checker.valid? hcl_string
47
+ # => true
52
48
  ```
53
49
 
54
50
  You can parse the `hcl_string` into a `Hash` with `parse` method.
55
51
 
56
- ```
57
- 2.3.2 :015 > HCL::Checker.parse(hcl_string)
58
- => {"provider"=>{"aws"=>{"region"=>"${var.aws_region}", "access_key"=>"${var.aws_access_key}", "secret_key"=>"${var.aws_secret_key}"}}, "resource"=>{"aws_vpc"=>{"default"=>{"cidr_block"=>"10.0.0.0/16", "enable_dns_hostnames"=>true, "tags"=>{"Name"=>"Event Store VPC"}}}}}
52
+ ```ruby
53
+ HCL::Checker.parse(hcl_string)
54
+ # => {"provider"=>{"aws"=>{"region"=>"${var.aws_region}", "access_key"=>"${var.aws_access_key}", "secret_key"=>"${var.aws_secret_key}"}}, "resource"=>{"aws_vpc"=>{"default"=>{"cidr_block"=>"10.0.0.0/16", "enable_dns_hostnames"=>true, "tags"=>{"Name"=>"Event Store VPC"}}}}}
59
55
  ```
60
56
 
61
- If after a `parse` you got `false` you can check `last_error` with:
57
+ If after a `parse` you got a `String` back instead of a `Hash`, that string
58
+ is the parse error message, and it is also available afterwards via
59
+ `last_error`:
62
60
 
63
- ```
64
- 2.4.2 :063 > HCL::Checker.last_error
65
- => "Parse error at \"eec8b16c-ee89-4ea0-bdcc-d094300a42e8\" , (invalid token: ,)"
61
+ ```ruby
62
+ HCL::Checker.last_error
63
+ # => "Parse error on line 7, column 18: unexpected \",\" (after \"instance_type\")"
66
64
  ```
67
65
 
66
+ ## Compatibility
67
+
68
+ This gem parses full [HCL1](https://github.com/hashicorp/hcl) syntax, plus a
69
+ substantial subset of HCL2 expression syntax: arithmetic/comparison/logical
70
+ operators, ternary expressions, `for` expressions (list and object
71
+ comprehensions), splat expressions (`list[*]` and the legacy `list.*`),
72
+ chained indexing/attribute access, the `null` literal, scientific notation,
73
+ string escape sequences, `<<-` heredoc dedenting, and `%{ if }`/`%{ for }`
74
+ string template directives.
75
+
76
+ Not supported:
77
+ - The JSON variant of HCL2 (`.tf.json` files) — a fully separate parsing mode.
78
+ - Expression evaluation — this is a structural parser/checker, not an
79
+ interpreter. `${...}` interpolations and bare expressions (function calls,
80
+ arithmetic, `for`/ternary, etc.) are preserved as opaque text, never
81
+ evaluated.
82
+
83
+ Known caveats:
84
+ - A quoted string used as an operand inside a larger expression loses its
85
+ surrounding quotes in the reconstructed text (e.g. `var.env == "prod"`
86
+ becomes the text `var.env == prod`). A string assigned directly as a value
87
+ is unaffected (`region = "us-east-1"` stays exactly `"us-east-1"`).
88
+ - A list/object literal used as an operand inside a larger expression is
89
+ rendered with Ruby's own `Array`/`Hash` text representation, not HCL
90
+ syntax (e.g. `merge({a = 1}, {b = 2})` becomes the text
91
+ `merge({"a"=>1}, {"b"=>2})`).
92
+
93
+ ### Upgrading from 1.x
94
+
95
+ Version 2.0 adds the HCL2 support described above and fixes several parsing
96
+ bugs (including two that could hang or silently return wrong output). Most
97
+ HCL1 configs continue to parse identically, but a few things changed:
98
+
99
+ - **Ruby 3.3 or newer is now required** (previously `>= 2.7.0`).
100
+ - **`null` now parses as a real `nil`**, not the string `"null"`.
101
+ - **Parse error messages changed format** and now include a line and column
102
+ number, e.g. `"Parse error on line 7, column 18: unexpected \",\" (after
103
+ \"instance_type\")"`. Code that pattern-matches the exact old error text
104
+ will need updating; code that only checks whether `parse` returned a
105
+ `Hash` or a `String` (the documented, recommended pattern above) is
106
+ unaffected.
107
+ - `for`, `in`, `if` and `null` are now reserved words when they appear as a
108
+ standalone token, needed for HCL2's `for` expressions and the `null`
109
+ literal. They still work as plain, unquoted attribute names and block
110
+ labels (`for = 1`, `resource for "x" {}`), but not in every conceivable
111
+ position — a nested block literally *labeled* `for` immediately followed
112
+ by another bare identifier no longer parses, an extremely unusual
113
+ construct in practice.
114
+
68
115
  ## Development
69
116
 
70
117
  After checking out the repo, run `bin/setup` to install dependencies. Then, run
71
118
  `rake spec` to run the tests. You can also run `bin/console` for an interactive
72
119
  prompt that will allow you to experiment.
73
120
 
74
- To install this gem onto your local machine, run `bundle exec rake install`. To
75
- release a new version, update the version number in `version.rb`, and then run
76
- `bundle exec rake release`, which will create a git tag for the version, push
77
- git commits and tags, and push the `.gem` file to
78
- [rubygems.org](https://rubygems.org).
121
+ To install this gem onto your local machine, run `bundle exec rake install`.
122
+
123
+ > **Note:** if you run `bundle install` directly instead of `bin/setup` and
124
+ > get a `Bundler::PermissionError` trying to write to something like
125
+ > `/var/lib/gems/3.3.0/cache`, your system Ruby's gem directory is
126
+ > root-owned (common when Ruby was installed via a distro package, e.g.
127
+ > apt's `ruby3.3`). Fix it by installing into a local, writable path
128
+ > instead: `bundle config set --local path 'vendor/bundle' && bundle
129
+ > install` — this is exactly what `bin/setup` already does for you.
79
130
 
80
131
  ### Building grammar
81
132
 
133
+ If you change `assets/lexer.rex` or `assets/parse.y`, regenerate the lexer
134
+ and parser with:
135
+
82
136
  ```
83
137
  $ bundle exec rake build_grammar
84
138
  Building Lexer....done
85
139
  Building Parser....done
86
140
  ```
87
141
 
142
+ This overwrites `lib/hcl/checker/lexer.rb` and `lib/hcl/checker/parser.rb` —
143
+ never edit those two files directly, they're marked `DO NOT MODIFY` and any
144
+ changes will be lost the next time this task runs.
145
+
146
+ ## Publishing to RubyGems.org
147
+
148
+ This project uses Bundler's standard gem release workflow (`rake release`,
149
+ provided by `bundler/gem_tasks` in the `Rakefile`).
150
+
151
+ 1. **Bump the version** in `lib/hcl/checker/version.rb`.
152
+ 2. **Run the test suite** and make sure it's green: `bundle exec rake spec`.
153
+ 3. **Commit the version bump**, e.g. `git commit -am "Bump version to X.Y.Z"`.
154
+ 4. **Release**:
155
+
156
+ ```
157
+ $ bundle exec rake release
158
+ ```
159
+
160
+ This single command builds the `.gem` file from `hcl-checker.gemspec`,
161
+ creates a git tag `vX.Y.Z`, pushes the tag and any pending commits to the
162
+ configured remote, and pushes the built gem to
163
+ [rubygems.org](https://rubygems.org).
164
+
165
+ If you've never pushed to rubygems.org from this machine before, `gem push`
166
+ will prompt you to sign in (or run `gem signin` beforehand to store an API
167
+ key). This gem has enough downloads that RubyGems requires multi-factor
168
+ authentication to push a new version — have your TOTP code ready, or pass it
169
+ directly with `gem push --otp <code> pkg/hcl-checker-X.Y.Z.gem` if you need
170
+ to push the built `.gem` file manually instead of via `rake release`.
171
+
172
+ To build the `.gem` file without publishing it — useful to double check its
173
+ contents first — run `bundle exec rake build`; the file is written to `pkg/`.
174
+
175
+ ## Roadmap / Future work
176
+
177
+ - [ ] `HCL::Checker.last_error` is mutable class-level state (not thread-safe).
178
+ - [ ] `parse` returns a `Hash` on success and a `String` (error message) on
179
+ failure — an ambiguous return type that forces callers to duck-type
180
+ the result instead of, say, raising a dedicated exception class.
181
+ - [ ] No JSON variant of HCL2 (`.tf.json`) support.
182
+ - [ ] No expression evaluation — by design (see Compatibility above), but
183
+ worth revisiting if there's ever demand for it as a separate,
184
+ opt-in mode.
185
+
186
+ ## Implementation details
187
+
188
+ Initial parser version with partial HCL 1.0 suppport was created and developed
189
+ by [Sikula](https://github.com/sikula)
190
+ and available at [Ruby HCL Repository](https://github.com/sikula/ruby-hcl).
191
+
88
192
  ## Contributing
89
193
 
90
194
  Bug reports and pull requests are welcome on GitHub at
data/assets/lexer.rex CHANGED
@@ -8,13 +8,34 @@ macro
8
8
  COMMENT \#.*|\/\/.*$
9
9
  MCOMMENTIN \/\*
10
10
  BOOL true|false
11
- NUMBER -?\d+
12
- FLOAT \-?\d+\.\d+
11
+ NULL null(?![a-zA-Z0-9_\-])
12
+ FOR for(?![a-zA-Z0-9_\-])
13
+ IN in(?![a-zA-Z0-9_\-])
14
+ IF if(?![a-zA-Z0-9_\-])
15
+ NUMBER \d+
16
+ FLOAT \d+(\.\d+)?[eE][-+]?\d+|\d+\.\d+
13
17
  COMMA \,
14
- IDENTIFIER [a-zA-Z_][a-zA-Z0-9_\-\.]*
18
+ IDENTIFIER [a-zA-Z_][a-zA-Z0-9_\-]*
15
19
  EQUAL \=
16
20
  QUOTE \"
17
21
  MINUS \-
22
+ PLUS \+
23
+ STAR \*
24
+ SLASH \/
25
+ PERCENT \%
26
+ ANDAND \&\&
27
+ OROR \|\|
28
+ EQEQ \=\=
29
+ NEQ \!\=
30
+ LE \<\=
31
+ GE \>\=
32
+ LT \<
33
+ GT \>
34
+ ARROW \=\>
35
+ QUESTION \?
36
+ COLON \:
37
+ PERIOD \.
38
+ BANG \!
18
39
  LEFTBRACE \{
19
40
  RIGHTBRACE \}
20
41
  LEFTBRACKET \[
@@ -32,10 +53,14 @@ rule
32
53
  #-------------------------------------------------------------------------------
33
54
  {MCOMMENTIN} { consume_comment(text) }
34
55
  {BOOL} { [:BOOL, to_boolean(text)]}
56
+ {NULL} { [:NULL, text]}
57
+ {FOR} { [:FOR, text]}
58
+ {IN} { [:IN, text]}
59
+ {IF} { [:IF, text]}
35
60
  {FLOAT} { [:FLOAT, text.to_f] }
36
61
  {NUMBER} { [:NUMBER, text.to_i] }
37
62
  {QUOTE} { [:STRING, consume_string(text)] }
38
- {HEREDOCUMENT} { [:STRING, consume_heredoc] }
63
+ {HEREDOCUMENT} { [:STRING, consume_heredoc(text.end_with?('-'))] }
39
64
  #-------------------------------------------------------------------------------
40
65
  {LEFTBRACE} { [:LEFTBRACE, text]}
41
66
  {RIGHTBRACE} { [:RIGHTBRACE, text]}
@@ -46,30 +71,62 @@ rule
46
71
  #-------------------------------------------------------------------------------
47
72
  {COMMA} { [:COMMA, text]}
48
73
  {IDENTIFIER} { [:IDENTIFIER, text]}
74
+ #-- two-character operators must come before their one-character prefixes,
75
+ #-- since this scanner uses first-match-wins (not longest-match-wins).
76
+ {EQEQ} { [:EQEQ, text]}
77
+ {NEQ} { [:NEQ, text]}
78
+ {LE} { [:LE, text]}
79
+ {GE} { [:GE, text]}
80
+ {ARROW} { [:ARROW, text]}
81
+ {LT} { [:LT, text]}
82
+ {GT} { [:GT, text]}
49
83
  {EQUAL} { [:EQUAL, text]}
50
84
  {MINUS} { [:MINUS, text]}
85
+ {PLUS} { [:PLUS, text]}
86
+ {STAR} { [:STAR, text]}
87
+ {SLASH} { [:SLASH, text]}
88
+ {PERCENT} { [:PERCENT, text]}
89
+ {ANDAND} { [:ANDAND, text]}
90
+ {OROR} { [:OROR, text]}
91
+ {QUESTION} { [:QUESTION, text]}
92
+ {COLON} { [:COLON, text]}
93
+ {PERIOD} { [:PERIOD, text]}
94
+ {BANG} { [:BANG, text]}
51
95
 
52
96
  inner
97
+ attr_reader :token_positions
98
+
53
99
  def lex(input)
54
100
  scan_setup(input)
55
101
  tokens = []
102
+ @token_positions = []
103
+
56
104
  while token = next_token
57
105
  tokens << token
106
+ @token_positions << current_position
58
107
  end
59
108
 
60
109
  tokens
61
110
  end
62
111
 
112
+ # Position (line, column) right after the token that was just consumed.
113
+ # Good enough for error reporting: for the overwhelming majority of
114
+ # tokens (identifiers, punctuation, keywords, numbers) which never span
115
+ # multiple lines, this is exactly where the token is. It only becomes an
116
+ # approximation (pointing at the end rather than the start) for
117
+ # constructs that can span several lines, such as heredocs, multi-line
118
+ # strings or block comments.
119
+ def current_position
120
+ consumed = @ss.string[0...@ss.pos]
121
+ line = consumed.count("\n") + 1
122
+ column = consumed.length - (consumed.rindex("\n") || -1)
123
+
124
+ [line, column]
125
+ end
126
+
63
127
 
64
128
  def to_boolean(input)
65
- case input
66
- when /true/
67
- true
68
- when /false/
69
- false
70
- else
71
- raise "Invalid value for `to_boolean`, expected true/false got #{input}"
72
- end
129
+ input == 'true'
73
130
  end
74
131
 
75
132
 
@@ -79,11 +136,11 @@ inner
79
136
  until nested.zero?
80
137
  case(text = @ss.scan_until(%r{/\*|\*/|\z}) )
81
138
  when %r{/\*\z}
82
- nested =+ 1
139
+ nested += 1
83
140
  when %r{\*/\z}
84
141
  nested -= 1
85
142
  else
86
- break
143
+ raise ScanError, 'unterminated block comment'
87
144
  end
88
145
  end
89
146
  end
@@ -94,13 +151,18 @@ inner
94
151
  nested = 0
95
152
 
96
153
  loop do
97
- case(text = @ss.scan_until(%r{\"|\$\{|\}|\\}))
154
+ text = @ss.scan_until(%r{\"|\$\{|%\{|\}|\\})
155
+ raise ScanError, 'unterminated string' if text.nil?
156
+
157
+ case text
98
158
  when %r{\$\{\z}
99
159
  nested += 1
160
+ when %r{%\{\z}
161
+ nested += 1
100
162
  when %r{\}\z}
101
163
  nested -= 1 if nested.positive?
102
164
  when %r{\\\z}
103
- result += text.chop + @ss.getch
165
+ result += text.chop + decode_escape
104
166
  next
105
167
  end
106
168
 
@@ -112,10 +174,48 @@ inner
112
174
  result.chop
113
175
  end
114
176
 
115
- def consume_heredoc
116
- token = Regexp.new @ss.scan_until(%r{\n})
117
- document = @ss.scan_until(token)
177
+ def decode_escape
178
+ char = @ss.getch
179
+
180
+ case char
181
+ when 'n' then "\n"
182
+ when 't' then "\t"
183
+ when 'r' then "\r"
184
+ when '"' then '"'
185
+ when '\\' then '\\'
186
+ when 'u'
187
+ hex = @ss.scan(/[0-9a-fA-F]{4}/)
188
+ hex ? [hex.to_i(16)].pack('U') : 'u'
189
+ when 'U'
190
+ hex = @ss.scan(/[0-9a-fA-F]{8}/)
191
+ hex ? [hex.to_i(16)].pack('U') : 'U'
192
+ else
193
+ char.to_s
194
+ end
195
+ end
196
+
197
+ def consume_heredoc(indented)
198
+ marker_line = @ss.scan_until(%r{\n})
199
+ raise ScanError, 'unterminated heredoc: missing marker' if marker_line.nil?
200
+
201
+ marker = marker_line.strip
202
+ closing = /^[ \t]*#{Regexp.escape(marker)}[ \t]*(?:\n|\z)/
203
+ raw = @ss.scan_until(closing)
204
+ raise ScanError, "unterminated heredoc: missing closing marker '#{marker}'" if raw.nil?
205
+
206
+ content = raw.sub(closing, '')
207
+
208
+ indented ? dedent(content) : content
209
+ end
210
+
211
+ # Strips the common leading whitespace shared by every non-blank line, as
212
+ # required for the `<<-` heredoc form. Blank (or whitespace-only) lines
213
+ # are ignored when computing how much to strip, and are left untouched.
214
+ def dedent(text)
215
+ lines = text.split("\n", -1)
216
+ indents = lines.reject { |line| line.strip.empty? }.map { |line| line[/\A[ \t]*/].length }
217
+ indent = indents.min || 0
118
218
 
119
- document.chop
219
+ lines.map { |line| line.strip.empty? ? line : line[indent..] }.join("\n")
120
220
  end
121
221
  end