specguard-ruby 0.3.1

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.
@@ -0,0 +1,363 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Answers one question, for one example at a time: *is this test annotated, and
4
+ # with what?*
5
+ #
6
+ # It is the formatter's half of the annotation story. The linter's half already
7
+ # exists and is not duplicated here — {SpecGuard::RSpec::Scanner} finds every
8
+ # `@intent:` in a file, captures its payload string-aware and parses it, and
9
+ # the `validate-intent` binary decides whether the result is valid. This class
10
+ # consumes both. Writing a second extractor would guarantee that the tool
11
+ # telling an author their annotation is wrong and the tool reporting it to the
12
+ # platform eventually disagree about what an annotation *is*.
13
+ #
14
+ # Requiring the linter's chain from here is safe in the direction that matters:
15
+ # `lib/specguard/rspec.rb` does not require `rspec/core`, so pulling it in from
16
+ # the formatter's side keeps `bin/specguard-lint` loadable on a machine with no
17
+ # RSpec installed. spec/specguard/rspec/formatter_loading_spec.rb pins that in
18
+ # both directions.
19
+ require_relative "../rspec"
20
+
21
+ module SpecGuard
22
+ module RSpec
23
+ # == The lookback rule (SPGD-12 §2)
24
+ #
25
+ # `example.metadata[:line_number]` is the line the `it` is on. An annotation
26
+ # counts as this example's when it sits on that line (the trailing form) or
27
+ # on the line immediately above it (the preceding-comment form):
28
+ #
29
+ # # @intent: { entity: "Order", ... } <- line above
30
+ # it "restores stock on refund" do <- metadata[:line_number]
31
+ #
32
+ # it "surfaces the decline reason" do # @intent: { ... } <- same line
33
+ #
34
+ # One line of lookback, no more. Multi-line annotations are unsupported in
35
+ # v1 (PROTOCOL.md §1 requires an annotation to fit on one line), so a wider
36
+ # window could only ever attach an annotation to the wrong example.
37
+ #
38
+ # == Only the comment form is inherited by the line below
39
+ #
40
+ # The two forms are not symmetric, and the difference is not cosmetic. A
41
+ # comment-only line hosts no example, so an annotation written there has
42
+ # exactly one possible claimant — the line under it. An annotation written
43
+ # in the trailing form is on a line that *is* an example's, so it has two,
44
+ # and one-liner specs make the second one ordinary rather than exotic:
45
+ #
46
+ # it { is_expected.to eq(1) } # @intent: { entity: "Order", ... }
47
+ # it { is_expected.to be_positive }
48
+ #
49
+ # Lookback that did not distinguish the forms would report **both** of those
50
+ # as annotated, carrying the same intent — so the platform would store the
51
+ # second example's declared purpose as something nobody declared, and count
52
+ # it in `annotated_specs_count`. That is worse than reporting it
53
+ # unannotated: a missing annotation is a visible gap, an invented one is
54
+ # confident false telemetry, and it inflates the very ratio `status` exists
55
+ # to keep honest.
56
+ #
57
+ # So a Finding is claimable by the line below it only when its own line is
58
+ # comment-only ({COMMENT_LINE}). Deciding that needs the source line, which
59
+ # is why {#build_index} reads the file itself and calls {Scanner.scan_text}
60
+ # rather than {Scanner.scan_file} — the read is the same single read either
61
+ # way, and the alternative was a second one per file purely to re-derive
62
+ # what the first one already had in hand.
63
+ #
64
+ # == Why a malformed annotation is reported as *unannotated*
65
+ #
66
+ # {AnnotationScanner} states, correctly, that a typo'd annotation "must fail
67
+ # loudly rather than silently counting as unannotated". That is the
68
+ # **linter's** stance, and the linter already acts on it: it prints the
69
+ # file:line and exits 1. It cannot bind the formatter, which is under the
70
+ # never-block-CI contract and has no way to be loud that is not also a way
71
+ # to be in someone's build output.
72
+ #
73
+ # The alternative is worse than it looks. `Ingest::Payload` is
74
+ # all-or-nothing — it collects errors globally and `valid?` requires the
75
+ # list to be empty — so shipping one schema-invalid intent does not lose
76
+ # that annotation, it loses **the whole run**: a 400, and no telemetry for
77
+ # any of the other twenty thousand examples. Downgrading one annotation to
78
+ # `unannotated` costs one row's metadata. The example's name, duration and
79
+ # outcome still ship either way.
80
+ #
81
+ # So every one of these produces the same answer — `nil`, meaning
82
+ # unannotated:
83
+ #
84
+ # * no `@intent:` on either candidate line;
85
+ # * an `@intent:` on the line above that is *not* a comment-only line —
86
+ # the trailing form belongs to its own example and lends itself to
87
+ # nobody;
88
+ # * an `@intent:` whose payload could not be captured or parsed
89
+ # ({Finding#problem?} — `KIND_EXTRACTION` / `KIND_PARSE`);
90
+ # * a file that could not be read at all (`KIND_READ`);
91
+ # * a syntactically fine annotation the schema rejects;
92
+ # * the schema itself failing to load.
93
+ #
94
+ # == Cost
95
+ #
96
+ # {AnnotationScanner.each_intent} walks every line of the file it is handed.
97
+ # Scanning per *example* would make a 20,000-example suite read and walk its
98
+ # spec files 20,000 times, on the critical path of somebody's test run. So
99
+ # each file is read and scanned at most once and reduced to an {Index}:
100
+ # O(files), not O(examples). Schema validation is memoized on the same
101
+ # principle — per distinct annotation, not per example carrying it.
102
+ class AnnotationLookup
103
+ # Findings at line 0 are {Finding::KIND_READ} — "this file was never
104
+ # read", not "there is an annotation on line 0". Keeping the sentinel out
105
+ # of the index is what makes {Index#finding_for}'s unconditional
106
+ # `line - 1` lookback safe for an example on line 1.
107
+ FIRST_REAL_LINE = 1
108
+
109
+ # A line whose only content is a comment: the preceding-comment form, and
110
+ # the only form an example on the next line may claim. Leading whitespace
111
+ # is allowed because annotations are indented with the examples they
112
+ # describe.
113
+ COMMENT_LINE = /\A\s*#/
114
+
115
+ # One file's annotations, split by who is allowed to claim them.
116
+ #
117
+ # own every annotation, by the line it was written on
118
+ # inheritable only those in the comment form, by that same line
119
+ #
120
+ # The values are the INTENT TO SHIP or nil — the verdict already applied —
121
+ # rather than a Finding, because the two paths that build this index reach
122
+ # a verdict by different routes (the local Scanner for the LINES, or the
123
+ # port's report for the verdicts) and only agree on the answer. Storing the answer is what
124
+ # keeps `#intent_for` from having to know which one ran.
125
+ #
126
+ # A line's own annotation always wins: the trailing form is written *on*
127
+ # the example, so when a file somehow carries both it is the more specific
128
+ # of the two, and a malformed trailing annotation is not quietly
129
+ # backfilled from the comment above it — that would attach an intent its
130
+ # author had already stopped meaning. Hence `key?` rather than a truthy
131
+ # test: a line whose own annotation was rejected answers nil, and must not
132
+ # fall through to the comment above it.
133
+ #
134
+ # `line - 1` is 0 for an example on line 1, and {AnnotationLookup#build_index}
135
+ # is what guarantees that reads back as "no annotation": line 0 is never a
136
+ # key, because the only finding produced there is the KIND_READ sentinel.
137
+ Index = Struct.new(:own, :inheritable) do
138
+ # @return [Hash, nil]
139
+ def intent_for(line)
140
+ return own[line] if own.key?(line)
141
+
142
+ inheritable[line - 1]
143
+ end
144
+ end
145
+
146
+ # The answer for a file nothing could be read out of. Its own constant so
147
+ # the pessimistic pre-scan cache in {#index_for} and the unreadable-file
148
+ # path cannot drift apart.
149
+ EMPTY_INDEX = Index.new({}.freeze, {}.freeze).freeze
150
+
151
+ # @param env [Hash, ENV] where `SPECGUARD_VALIDATE_INTENT` is read from.
152
+ # Injected for testing, and read LAZILY — see {#backend}.
153
+ def initialize(env: ENV)
154
+ @env = env
155
+ @indexes = {}
156
+ end
157
+
158
+ # The intent to attach to one example, or nil when it is unannotated.
159
+ #
160
+ # Nothing here is rescued: a caller that cannot survive an exception must
161
+ # say so itself, and the formatter does — it wraps this in the same
162
+ # `never_fail_the_run` envelope as everything else, so a blow-up costs the
163
+ # example its annotation and not the suite its exit code. The memoization
164
+ # below is deliberately written so that a failure is cached too, which is
165
+ # what keeps "warns once" from becoming "warns once but rescans the file
166
+ # for every one of the remaining examples".
167
+ #
168
+ # @param file [String, nil] the example's file, as the payload records it
169
+ # @param line [Integer, nil] `example.metadata[:line_number]`
170
+ # @return [Hash, nil] the parsed, schema-valid annotation
171
+ def intent_for(file:, line:)
172
+ return nil unless file.is_a?(String) && !file.empty?
173
+ return nil unless line.is_a?(Integer) && line >= FIRST_REAL_LINE
174
+
175
+ index_for(file).intent_for(line)
176
+ end
177
+
178
+ private
179
+
180
+ # The validator backend, or nil — memoized, including when resolving
181
+ # RAISES.
182
+ #
183
+ # WHY THIS CLASS RESOLVES ONE AT ALL. `CLI` routes the linter through the
184
+ # backend, and since the SPGD-867 cutover the backend is the ONLY
185
+ # validator: the gem no longer carries a Ruby validation arm. Whatever
186
+ # the backend accepts is what the platform must receive, so this asks
187
+ # the same binary the same question rather than a second parser that
188
+ # could disagree with it.
189
+ #
190
+ # Lazily, not in the constructor: {Formatter} builds this at suite start,
191
+ # and `resolve` verifies the binary (two probes, and a raise when it is
192
+ # unusable). Doing that during construction would move a failure out of
193
+ # the formatter's `never_fail_the_run` envelope and into the point where
194
+ # RSpec is still wiring itself up.
195
+ #
196
+ # A {ValidatorError} HERE IS NOT FATAL AND IS NOT nil-THE-ANNOTATION. An
197
+ # unusable binary means this class cannot ask the backend what an
198
+ # annotation says; it does NOT mean the annotation is bad. Since the
199
+ # cutover there is no Ruby arm to fall back to, so probe failure is
200
+ # memoized as nil and `#verdicts_for` answers `unverified_verdicts` for
201
+ # every file: local line discovery keeps working and every annotation is
202
+ # reported as `unannotated` — the honest answer under the formatter's
203
+ # never-fail-the-run contract, while the linter half makes the failure
204
+ # loud (it exits 2 when no binary can be resolved, naming the
205
+ # remediations).
206
+ #
207
+ # The alternative was letting the raise reach {Formatter}'s
208
+ # `never_fail_the_run` envelope. That envelope keeps the RUN alive, but
209
+ # it does so by abandoning the example's annotation. Degrading HERE
210
+ # instead means ONE swallowed probe per suite rather than one per
211
+ # example, with `unverified_verdicts` as the single consistent answer
212
+ # for every file.
213
+ #
214
+ # Cached, including on failure, for the reason {#schema} is: the probe is
215
+ # a subprocess, and re-running it per example is the O(examples) cost this
216
+ # class exists to refuse — on exactly the unhappy path where somebody's CI
217
+ # is already having a bad day.
218
+ def backend
219
+ return @backend if defined?(@backend)
220
+
221
+ @backend = nil
222
+ @backend = ValidatorBackend.resolve(env: @env)
223
+ rescue ValidatorError
224
+ # Deliberately swallowed rather than re-raised: @backend is already nil,
225
+ # so #verdicts_for takes the unverified_verdicts arm and the memo keeps
226
+ # this from being re-probed.
227
+ @backend = nil
228
+ end
229
+
230
+ # @return [Index]
231
+ def index_for(file)
232
+ return @indexes[file] if @indexes.key?(file)
233
+
234
+ # Cache the pessimistic answer *before* the scan, not after. If the scan
235
+ # raises, the raise still reaches the formatter (which warns once and
236
+ # moves on) but the empty index stays behind, so the next example in the
237
+ # same file is answered from the cache instead of re-raising and
238
+ # re-reading. Without this, "warns once" would still mean "re-reads a
239
+ # broken file once per example" — the O(examples) cost this class exists
240
+ # to remove, reappearing on exactly the unhappy path where somebody's CI
241
+ # is already having a bad day.
242
+ @indexes[file] = EMPTY_INDEX
243
+ @indexes[file] = build_index(file)
244
+ end
245
+
246
+ # @return [Index]
247
+ def build_index(file)
248
+ # The read happens on BOTH paths and first on both. The backend does not
249
+ # need it to find annotations — it reads the file itself — but the
250
+ # comment-form rule below is a property of the LINE an annotation sits
251
+ # on, which no report carries, so the text is required either way. Doing
252
+ # it first also keeps the two paths agreeing about an unreadable file
253
+ # without a subprocess being started to rediscover it.
254
+ text = read(file)
255
+ return EMPTY_INDEX if text.nil?
256
+
257
+ index_from(verdicts_for(file, text), text)
258
+ end
259
+
260
+ # One shell-out per FILE, which is the cost model this class already
261
+ # commits to: {#index_for} is O(files), `--source` takes files, and the
262
+ # port reports every annotation in one pass with the `file:line` scoping
263
+ # {Index} needs. Per-example would be O(examples) subprocesses, which is
264
+ # the cost this class exists to refuse.
265
+ #
266
+ # `#check` returns one {Linter::Result} per finding. A result that is not
267
+ # `ok?` is an annotation the linter fails the build over, so it ships
268
+ # nothing. Read failures and no-matches arrive line-scoped to 0 and are
269
+ # filtered by {#index_from}.
270
+ #
271
+ # A {ValidatorError} here means the backend could not answer about THIS
272
+ # file's annotations. Since the cutover there is no Ruby arm to fall back
273
+ # to, the honest answer is the one this class already gives for anything
274
+ # it could not verify: every annotation in the file ships `unannotated`.
275
+ # The never-block-CI contract forbids anything louder from the formatter,
276
+ # and the linter — which CAN be loud — is the tool that catches it.
277
+ def verdicts_for(file, text)
278
+ resolved = backend
279
+ return unverified_verdicts(file, text) if resolved.nil?
280
+
281
+ resolved.check([file]).map do |result|
282
+ [result.line, result.ok? ? result.representable_intent : nil]
283
+ end
284
+ rescue ValidatorError
285
+ unverified_verdicts(file, text)
286
+ end
287
+
288
+ # The lines the scanner still finds, every one answered `nil`. Discovery
289
+ # is a property of the FILE (which lines carry annotations, which are
290
+ # comment-form) and stays local — it is validation that needed the
291
+ # backend. Keeping the lines lets {#index_from} apply the comment-form
292
+ # rule exactly as it does for verdicts the backend did answer.
293
+ def unverified_verdicts(file, text)
294
+ Scanner.scan_text(text, file: file).map { |finding| [finding.line, nil] }
295
+ end
296
+
297
+ # @return [Index]
298
+ def index_from(verdicts, text)
299
+ lines = nil
300
+ own = {}
301
+ inheritable = {}
302
+
303
+ verdicts.each do |line, intent|
304
+ # Findings at line 0 are {Finding::KIND_READ} — "this file was never
305
+ # read", not "there is an annotation on line 0".
306
+ next unless line.is_a?(Integer) && line >= FIRST_REAL_LINE
307
+
308
+ # First wins. `each_intent` resumes scanning after each captured
309
+ # payload, so trailing prose containing a second `@intent:` yields a
310
+ # second finding on the same line. The annotation is the one the
311
+ # author wrote first; the rest of the line is commentary. Stated here
312
+ # rather than left to Hash-insertion order.
313
+ next if own.key?(line)
314
+
315
+ own[line] = intent
316
+ # Materialized only once a real annotation has been found, and never
317
+ # for a file `scan_text` refused: its invalid-UTF-8 answer is a single
318
+ # line-0 finding, filtered out above, and splitting such a string is
319
+ # itself an ArgumentError waiting to happen.
320
+ lines ||= text.lines
321
+ inheritable[line] = intent if COMMENT_LINE.match?(lines[line - 1].to_s)
322
+ end
323
+
324
+ Index.new(own, inheritable)
325
+ end
326
+
327
+ # {Scanner.scan_file} would do this read, but it hands back only Findings
328
+ # and the form of an annotation is a property of the *line* it sits on. So
329
+ # the read happens here and {Scanner.scan_text} — the same pipeline, one
330
+ # stage lower — does the extracting. Nothing about what an annotation *is*
331
+ # is re-implemented; a second extractor is how the tool that tells an
332
+ # author their annotation is wrong and the tool that reports it to the
333
+ # platform end up disagreeing.
334
+ #
335
+ # The rescue mirrors `scan_file`'s own, and the two agree on the answer: it
336
+ # turns an unreadable file into a line-0 KIND_READ Finding, which this
337
+ # class filters out, and nil here becomes {EMPTY_INDEX}. Either way, every
338
+ # example in a file that could not be read is unannotated.
339
+ #
340
+ # @return [String, nil]
341
+ def read(file)
342
+ File.read(file, encoding: "UTF-8")
343
+ rescue SystemCallError, IOError
344
+ nil
345
+ end
346
+
347
+ # Keyed by the intent itself, so an annotation repeated across a suite is
348
+ # checked once rather than once per example that carries it. The
349
+ # shell-out is the expensive stage, and this class is explicitly about
350
+ # not paying O(examples) for work that is O(files).
351
+ #
352
+ # The verdict is now reached while the index is BUILT rather than when an
353
+ # example first claims the line, which makes the cost O(annotations in the
354
+ # file) instead of O(annotations examples ask about) — still bounded by
355
+ # the file, still memoized across files, and it is what lets the index
356
+ # hold one shape whichever path filled it. The visible consequence is that
357
+ # an unloadable schema raises on the first lookup into a file with any
358
+ # annotation rather than the first lookup that lands on one; both are
359
+ # inside the formatter's envelope, and {#index_for}'s pessimistic
360
+ # pre-caching already guarantees the second example does not raise again.
361
+ end
362
+ end
363
+ end
@@ -0,0 +1,190 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpecGuard
4
+ module RSpec
5
+ # Finds `@intent:` annotations in test source and captures the object
6
+ # literal that follows each one.
7
+ #
8
+ # This is a *syntactic* pass only: it hands back the payload exactly as
9
+ # written (still in PROTOCOL.md §1's permissive syntax — see
10
+ # {PayloadNormalizer}) and never inspects its contents. Schema validation is
11
+ # a later stage entirely.
12
+ #
13
+ # The scan is **string-aware**: quoted content is skipped over wholesale, so
14
+ # a `behavior` sentence containing an apostrophe or an unbalanced `{` cannot
15
+ # terminate the capture early. Plain brace-counting gets that case wrong.
16
+ #
17
+ # The algorithm is PROTOCOL.md §1's: find each `@intent:` token and capture
18
+ # the object literal after it, string-aware and bracket-balanced.
19
+ # open-test-intent's `validate-intent` implements the same rules; where the
20
+ # two disagree, PROTOCOL.md decides.
21
+ module AnnotationScanner
22
+ INTENT_TOKEN = "@intent:"
23
+
24
+ # Closing bracket => the opener it must match.
25
+ OPENERS = { "}" => "{", "]" => "[" }.freeze
26
+
27
+ # The `{` at `start` was never closed on this line.
28
+ UNTERMINATED_OBJECT = "unterminated object literal (an annotation must fit on one line)"
29
+ UNBALANCED_BRACKETS = "unbalanced brackets in the annotation payload"
30
+ NO_PAYLOAD = "no '{...}' object literal follows the @intent: token"
31
+
32
+ module_function
33
+
34
+ # Yields `[line_no, raw_payload, problem]` for every `@intent:` token in
35
+ # `text`, in source order.
36
+ #
37
+ # `raw_payload` is the object literal as written; it is `nil` when
38
+ # `problem` explains why the annotation could not be captured.
39
+ #
40
+ # An `@intent:` token carrying no extractable payload is **reported, not
41
+ # skipped** — a typo'd annotation must fail loudly rather than silently
42
+ # counting as "this example is unannotated".
43
+ #
44
+ # @param text [String] the full source of one file
45
+ # @yieldparam line_no [Integer] 1-based line number
46
+ # @yieldparam raw_payload [String, nil]
47
+ # @yieldparam problem [String, nil]
48
+ # @return [Enumerator] when no block is given
49
+ def each_intent(text)
50
+ return enum_for(:each_intent, text) unless block_given?
51
+
52
+ text.each_line.with_index(1) do |raw_line, line_no|
53
+ line = raw_line.chomp
54
+ pos = 0
55
+
56
+ loop do
57
+ token_at = line.index(INTENT_TOKEN, pos)
58
+ break if token_at.nil?
59
+
60
+ brace_at = payload_brace(line, token_at)
61
+ if brace_at.nil?
62
+ yield line_no, nil, NO_PAYLOAD
63
+ break
64
+ end
65
+
66
+ begin
67
+ finish = scan_object(line, brace_at)
68
+ rescue ScanError => e
69
+ yield line_no, nil, e.message
70
+ break
71
+ end
72
+
73
+ yield line_no, line[brace_at...finish], nil
74
+
75
+ # Resume *after* the captured payload so trailing prose containing
76
+ # another `@intent:` is still seen, but the payload's own contents
77
+ # are never rescanned.
78
+ pos = finish
79
+ end
80
+ end
81
+ end
82
+
83
+ # Returns the index of the `{` opening the payload of the `@intent:`
84
+ # token at `token_at`, or nil when that token has no payload.
85
+ #
86
+ # The search is **bounded by the next `@intent:` token on the line**. An
87
+ # unbounded search reads to end of line, so a malformed token followed by
88
+ # a well-formed one adopts its neighbour's object literal and is yielded
89
+ # with `problem: nil` — a typo'd annotation reported as valid, carrying
90
+ # somebody else's intent, and (because the caller resumes past the
91
+ # captured payload) swallowing the well-formed token on the way. That is
92
+ # the exact opposite of {each_intent}'s "reported, not skipped" contract,
93
+ # and PROTOCOL.md §1 supports the bounded reading: a payload is the object
94
+ # *following* its own token, which a literal on the far side of a second
95
+ # token is not.
96
+ #
97
+ # The bound can only ever *shrink* the search — it never picks a
98
+ # different brace, only declines one — so a token whose payload lies
99
+ # beyond the next token takes the {NO_PAYLOAD} path instead of a false
100
+ # pass.
101
+ #
102
+ # `line.index` is a naive string search, so it also finds an `@intent:`
103
+ # written inside a quoted string. That is harmless for the case it looks
104
+ # like it would break — a token quoted *inside a payload* is by definition
105
+ # after that payload's `{`, so the bound is inert and the payload is
106
+ # captured as before. The occurrence has to sit between the token and its
107
+ # `{` to matter at all, which means it is in the prose separating them:
108
+ #
109
+ # # @intent: like the "@intent:" above { entity: "Order", ... }
110
+ #
111
+ # That line now reports NO_PAYLOAD where it previously captured the
112
+ # literal. It is the one shape this bound makes stricter, and the
113
+ # stricter answer is the right one: the line is genuinely ambiguous about
114
+ # which token owns the literal, and declining to guess is the loud answer
115
+ # this scanner is supposed to give. Moving the quoted mention after the
116
+ # payload, or dropping the quotes, restores the capture.
117
+ def payload_brace(line, token_at)
118
+ after_token = token_at + INTENT_TOKEN.length
119
+ brace_at = line.index("{", after_token)
120
+ return nil if brace_at.nil?
121
+
122
+ next_token = line.index(INTENT_TOKEN, after_token)
123
+ return nil if next_token && brace_at > next_token
124
+
125
+ brace_at
126
+ end
127
+
128
+ # Returns the index just past the `}` matching the `{` at `text[start]`.
129
+ #
130
+ # Bracket-balanced and string-aware, so a brace inside a quoted value does
131
+ # not end the payload early. Annotations are single-line per PROTOCOL.md
132
+ # §1, so `text` is one line.
133
+ #
134
+ # @raise [ScanError] when the literal is unterminated or unbalanced
135
+ def scan_object(text, start)
136
+ stack = []
137
+ i = start
138
+ length = text.length
139
+
140
+ while i < length
141
+ char = text[i]
142
+
143
+ if char == '"' || char == "'"
144
+ i = scan_string(text, i, char)
145
+ next
146
+ end
147
+
148
+ if char == "{" || char == "["
149
+ stack.push(char)
150
+ elsif char == "}" || char == "]"
151
+ raise ScanError, UNBALANCED_BRACKETS if stack.empty? || stack.last != OPENERS[char]
152
+
153
+ stack.pop
154
+ return i + 1 if stack.empty?
155
+ end
156
+
157
+ i += 1
158
+ end
159
+
160
+ raise ScanError, UNTERMINATED_OBJECT
161
+ end
162
+
163
+ # Returns the index just past the closing `quote` of a string literal.
164
+ #
165
+ # `text[start]` must be the opening quote. Backslash escapes are honoured,
166
+ # so a quote or brace *inside* the string never terminates the scan.
167
+ #
168
+ # @raise [ScanError] when the string literal is unterminated
169
+ def scan_string(text, start, quote)
170
+ i = start + 1
171
+ length = text.length
172
+
173
+ while i < length
174
+ char = text[i]
175
+
176
+ if char == "\\"
177
+ i += 2 # skip the escape and whatever it escapes
178
+ next
179
+ end
180
+
181
+ return i + 1 if char == quote
182
+
183
+ i += 1
184
+ end
185
+
186
+ raise ScanError, "unterminated #{quote}-quoted string"
187
+ end
188
+ end
189
+ end
190
+ end