specguard-rspec 0.2.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.
- checksums.yaml +7 -0
- data/.github/workflows/release.yml +100 -0
- data/LICENSE +21 -0
- data/README.md +735 -0
- data/Rakefile +28 -0
- data/assets/built-with-yatfa.png +0 -0
- data/bin/specguard-lint +34 -0
- data/lib/specguard/rspec/annotation_lookup.rb +286 -0
- data/lib/specguard/rspec/annotation_scanner.rb +144 -0
- data/lib/specguard/rspec/cli.rb +466 -0
- data/lib/specguard/rspec/configuration.rb +459 -0
- data/lib/specguard/rspec/file_selector.rb +276 -0
- data/lib/specguard/rspec/finding.rb +63 -0
- data/lib/specguard/rspec/formatter.rb +919 -0
- data/lib/specguard/rspec/json_reporter.rb +163 -0
- data/lib/specguard/rspec/linter.rb +134 -0
- data/lib/specguard/rspec/payload_normalizer.rb +139 -0
- data/lib/specguard/rspec/scanner.rb +217 -0
- data/lib/specguard/rspec/schema.rb +122 -0
- data/lib/specguard/rspec/schemas/open-test-intent.v1.json +15 -0
- data/lib/specguard/rspec/transport.rb +310 -0
- data/lib/specguard/rspec/validator_backend.rb +1071 -0
- data/lib/specguard/rspec/version.rb +7 -0
- data/lib/specguard/rspec/violation_renderer.rb +372 -0
- data/lib/specguard/rspec.rb +70 -0
- data/script/bump-version.sh +115 -0
- metadata +88 -0
data/Rakefile
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/gem_tasks"
|
|
4
|
+
require "rspec/core/rake_task"
|
|
5
|
+
|
|
6
|
+
# The fixtures under spec/fixtures/ are linter *input* that happens to be named
|
|
7
|
+
# `*_spec.rb` — exactly what RSpec's default pattern loads. `.rspec` excludes
|
|
8
|
+
# them, and that is the source of truth for a bare `rspec` run.
|
|
9
|
+
#
|
|
10
|
+
# The rake task has to repeat it. RSpec::Core::RakeTask always puts an explicit
|
|
11
|
+
# `--pattern` on the command line, and a command-line `--pattern` discards the
|
|
12
|
+
# `--exclude-pattern` that came from `.rspec` — so the stock task loads the
|
|
13
|
+
# fixtures as examples and the suite dies with
|
|
14
|
+
# "cannot load such file -- rails_helper". Passing the exclusion on the command
|
|
15
|
+
# line too is what makes the two invocations agree. (Clearing `t.pattern`
|
|
16
|
+
# instead does not work: the task then passes an empty argument and matches
|
|
17
|
+
# nothing.)
|
|
18
|
+
#
|
|
19
|
+
# Keep this in step with `.rspec` — `rake` and `rspec` must select the same
|
|
20
|
+
# files. spec/spec_helper.rb fails the suite loudly if a fixture ever does get
|
|
21
|
+
# loaded as an example.
|
|
22
|
+
RSpec::Core::RakeTask.new(:spec) do |t|
|
|
23
|
+
t.rspec_opts = %(--exclude-pattern "spec/fixtures/**/*_spec.rb")
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# `rake` with no arguments used to do nothing at all, which made it a useless
|
|
27
|
+
# CI entrypoint. It now means "run the suite".
|
|
28
|
+
task default: %i[spec]
|
|
Binary file
|
data/bin/specguard-lint
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# specguard-lint — the SpecGuard annotation linter entrypoint.
|
|
5
|
+
#
|
|
6
|
+
# Selects spec files, finds every `@intent:` annotation in them, validates each
|
|
7
|
+
# against the vendored OpenTestIntent schema, and reports every violation. Exit
|
|
8
|
+
# codes are the contract (SPGD-12 §1):
|
|
9
|
+
#
|
|
10
|
+
# 0 all annotations valid, or there were none ("lint, don't require")
|
|
11
|
+
# 1 one or more annotations are malformed
|
|
12
|
+
# 2 the linter could not do its job — misuse, or the tool itself broken
|
|
13
|
+
#
|
|
14
|
+
# SpecGuard::RSpec::CLI#run is written to *return* one of those three and never
|
|
15
|
+
# to raise; see its class comment for why that takes deliberate effort in Ruby.
|
|
16
|
+
# The `require` below is the one thing outside its reach, so it gets the same
|
|
17
|
+
# treatment here: a gem that cannot even load is the linter being broken, which
|
|
18
|
+
# is a 2. Left bare, Ruby would exit 1 and CI would report a malformed
|
|
19
|
+
# annotation nobody wrote.
|
|
20
|
+
|
|
21
|
+
# Allow running from a source checkout without bundler by putting the local
|
|
22
|
+
# lib/ on the load path. When the gem is installed (or run under
|
|
23
|
+
# `bundle exec`), specguard/rspec resolves through the normal load path.
|
|
24
|
+
source_lib = File.expand_path("../lib", __dir__)
|
|
25
|
+
$LOAD_PATH.unshift(source_lib) unless $LOAD_PATH.include?(source_lib)
|
|
26
|
+
|
|
27
|
+
begin
|
|
28
|
+
require "specguard/rspec"
|
|
29
|
+
rescue ScriptError, StandardError => e
|
|
30
|
+
warn "specguard-lint: error: could not load specguard-rspec: #{e.class}: #{e.message}"
|
|
31
|
+
exit 2
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
exit SpecGuard::RSpec::CLI.new.run(ARGV)
|
|
@@ -0,0 +1,286 @@
|
|
|
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
|
+
# {SpecGuard::RSpec::Schema} 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
|
+
# A line's own annotation always wins: the trailing form is written *on*
|
|
121
|
+
# the example, so when a file somehow carries both it is the more specific
|
|
122
|
+
# of the two, and a malformed trailing annotation is not quietly
|
|
123
|
+
# backfilled from the comment above it — that would attach an intent its
|
|
124
|
+
# author had already stopped meaning.
|
|
125
|
+
#
|
|
126
|
+
# `line - 1` is 0 for an example on line 1, and {AnnotationLookup#build_index}
|
|
127
|
+
# is what guarantees that reads back as "no annotation": line 0 is never a
|
|
128
|
+
# key, because the only Finding produced there is the KIND_READ sentinel.
|
|
129
|
+
Index = Struct.new(:own, :inheritable) do
|
|
130
|
+
# @return [Finding, nil]
|
|
131
|
+
def finding_for(line)
|
|
132
|
+
own[line] || inheritable[line - 1]
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# The answer for a file nothing could be read out of. Its own constant so
|
|
137
|
+
# the pessimistic pre-scan cache in {#index_for} and the unreadable-file
|
|
138
|
+
# path cannot drift apart.
|
|
139
|
+
EMPTY_INDEX = Index.new({}.freeze, {}.freeze).freeze
|
|
140
|
+
|
|
141
|
+
# @param schema_path [String] the vendored OpenTestIntent schema. Injected
|
|
142
|
+
# so a spec can point at a broken one without stubbing the loader.
|
|
143
|
+
def initialize(schema_path: SCHEMA_PATH)
|
|
144
|
+
@schema_path = schema_path
|
|
145
|
+
@indexes = {}
|
|
146
|
+
@verdicts = {}
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# The intent to attach to one example, or nil when it is unannotated.
|
|
150
|
+
#
|
|
151
|
+
# Nothing here is rescued: a caller that cannot survive an exception must
|
|
152
|
+
# say so itself, and the formatter does — it wraps this in the same
|
|
153
|
+
# `never_fail_the_run` envelope as everything else, so a blow-up costs the
|
|
154
|
+
# example its annotation and not the suite its exit code. The memoization
|
|
155
|
+
# below is deliberately written so that a failure is cached too, which is
|
|
156
|
+
# what keeps "warns once" from becoming "warns once but rescans the file
|
|
157
|
+
# for every one of the remaining examples".
|
|
158
|
+
#
|
|
159
|
+
# @param file [String, nil] the example's file, as the payload records it
|
|
160
|
+
# @param line [Integer, nil] `example.metadata[:line_number]`
|
|
161
|
+
# @return [Hash, nil] the parsed, schema-valid annotation
|
|
162
|
+
def intent_for(file:, line:)
|
|
163
|
+
finding = finding_for(file: file, line: line)
|
|
164
|
+
|
|
165
|
+
# {Finding#extracted?} is the discovery layer's own word for "this
|
|
166
|
+
# yielded a Hash" — and, pointedly, "not a claim that it is valid".
|
|
167
|
+
# Everything it excludes (KIND_EXTRACTION, KIND_PARSE, KIND_READ) is an
|
|
168
|
+
# annotation the linter fails the build over and this half must not.
|
|
169
|
+
return nil unless finding&.extracted?
|
|
170
|
+
|
|
171
|
+
validated(finding.intent)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
private
|
|
175
|
+
|
|
176
|
+
def finding_for(file:, line:)
|
|
177
|
+
return nil unless file.is_a?(String) && !file.empty?
|
|
178
|
+
return nil unless line.is_a?(Integer) && line >= FIRST_REAL_LINE
|
|
179
|
+
|
|
180
|
+
index_for(file).finding_for(line)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# @return [Index]
|
|
184
|
+
def index_for(file)
|
|
185
|
+
return @indexes[file] if @indexes.key?(file)
|
|
186
|
+
|
|
187
|
+
# Cache the pessimistic answer *before* the scan, not after. If the scan
|
|
188
|
+
# raises, the raise still reaches the formatter (which warns once and
|
|
189
|
+
# moves on) but the empty index stays behind, so the next example in the
|
|
190
|
+
# same file is answered from the cache instead of re-raising and
|
|
191
|
+
# re-reading. Without this, "warns once" would still mean "re-reads a
|
|
192
|
+
# broken file once per example" — the O(examples) cost this class exists
|
|
193
|
+
# to remove, reappearing on exactly the unhappy path where somebody's CI
|
|
194
|
+
# is already having a bad day.
|
|
195
|
+
@indexes[file] = EMPTY_INDEX
|
|
196
|
+
@indexes[file] = build_index(file)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# @return [Index]
|
|
200
|
+
def build_index(file)
|
|
201
|
+
text = read(file)
|
|
202
|
+
return EMPTY_INDEX if text.nil?
|
|
203
|
+
|
|
204
|
+
lines = nil
|
|
205
|
+
own = {}
|
|
206
|
+
inheritable = {}
|
|
207
|
+
|
|
208
|
+
Scanner.scan_text(text, file: file).each do |finding|
|
|
209
|
+
line = finding.line
|
|
210
|
+
next unless line.is_a?(Integer) && line >= FIRST_REAL_LINE
|
|
211
|
+
|
|
212
|
+
# First wins. `each_intent` resumes scanning after each captured
|
|
213
|
+
# payload, so trailing prose containing a second `@intent:` yields a
|
|
214
|
+
# second Finding on the same line. The annotation is the one the
|
|
215
|
+
# author wrote first; the rest of the line is commentary. Stated here
|
|
216
|
+
# rather than left to Hash-insertion order.
|
|
217
|
+
next if own.key?(line)
|
|
218
|
+
|
|
219
|
+
own[line] = finding
|
|
220
|
+
# Materialized only once a real annotation has been found, and never
|
|
221
|
+
# for a file `scan_text` refused: its invalid-UTF-8 answer is a single
|
|
222
|
+
# line-0 Finding, filtered out above, and splitting such a string is
|
|
223
|
+
# itself an ArgumentError waiting to happen.
|
|
224
|
+
lines ||= text.lines
|
|
225
|
+
inheritable[line] = finding if COMMENT_LINE.match?(lines[line - 1].to_s)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
Index.new(own, inheritable)
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# {Scanner.scan_file} would do this read, but it hands back only Findings
|
|
232
|
+
# and the form of an annotation is a property of the *line* it sits on. So
|
|
233
|
+
# the read happens here and {Scanner.scan_text} — the same pipeline, one
|
|
234
|
+
# stage lower — does the extracting. Nothing about what an annotation *is*
|
|
235
|
+
# is re-implemented; a second extractor is how the tool that tells an
|
|
236
|
+
# author their annotation is wrong and the tool that reports it to the
|
|
237
|
+
# platform end up disagreeing.
|
|
238
|
+
#
|
|
239
|
+
# The rescue mirrors `scan_file`'s own, and the two agree on the answer: it
|
|
240
|
+
# turns an unreadable file into a line-0 KIND_READ Finding, which this
|
|
241
|
+
# class filters out, and nil here becomes {EMPTY_INDEX}. Either way, every
|
|
242
|
+
# example in a file that could not be read is unannotated.
|
|
243
|
+
#
|
|
244
|
+
# @return [String, nil]
|
|
245
|
+
def read(file)
|
|
246
|
+
File.read(file, encoding: "UTF-8")
|
|
247
|
+
rescue SystemCallError, IOError
|
|
248
|
+
nil
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# Keyed by the intent itself, so an annotation repeated across a suite is
|
|
252
|
+
# compiled against the schema once rather than once per example that
|
|
253
|
+
# carries it. json_schemer is not free, and this class is explicitly about
|
|
254
|
+
# not paying O(examples) for work that is O(annotations).
|
|
255
|
+
#
|
|
256
|
+
# @return [Hash, nil] the intent when the schema accepts it
|
|
257
|
+
def validated(intent)
|
|
258
|
+
return @verdicts[intent] if @verdicts.key?(intent)
|
|
259
|
+
|
|
260
|
+
@verdicts[intent] = validate(intent)
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def validate(intent)
|
|
264
|
+
loaded = schema
|
|
265
|
+
return nil if loaded.nil?
|
|
266
|
+
|
|
267
|
+
loaded.violations(intent).empty? ? intent : nil
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# Loaded on first use, and at most once — including when loading fails.
|
|
271
|
+
#
|
|
272
|
+
# {Schema.load} raises {SchemaError} when the vendored document cannot be
|
|
273
|
+
# read, parsed or compiled. Under the linter that is a deliberate exit 2.
|
|
274
|
+
# Here it must not be an exit anything, so the raise is left to the
|
|
275
|
+
# formatter's envelope and the nil that gets cached in its place turns
|
|
276
|
+
# every subsequent annotation into an honest `unannotated` — the same
|
|
277
|
+
# answer this class gives for every other thing it could not verify.
|
|
278
|
+
def schema
|
|
279
|
+
return @schema if defined?(@schema)
|
|
280
|
+
|
|
281
|
+
@schema = nil
|
|
282
|
+
@schema = Schema.load(@schema_path)
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
end
|
|
@@ -0,0 +1,144 @@
|
|
|
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
|
+
# Ported from open-test-intent's `bin/validate-intent`
|
|
18
|
+
# (`extract_intents` / `_scan_object` / `_scan_string`), which is the
|
|
19
|
+
# reference implementation of this algorithm.
|
|
20
|
+
module AnnotationScanner
|
|
21
|
+
INTENT_TOKEN = "@intent:"
|
|
22
|
+
|
|
23
|
+
# Closing bracket => the opener it must match.
|
|
24
|
+
OPENERS = { "}" => "{", "]" => "[" }.freeze
|
|
25
|
+
|
|
26
|
+
# The `{` at `start` was never closed on this line.
|
|
27
|
+
UNTERMINATED_OBJECT = "unterminated object literal (an annotation must fit on one line)"
|
|
28
|
+
UNBALANCED_BRACKETS = "unbalanced brackets in the annotation payload"
|
|
29
|
+
NO_PAYLOAD = "no '{...}' object literal follows the @intent: token"
|
|
30
|
+
|
|
31
|
+
module_function
|
|
32
|
+
|
|
33
|
+
# Yields `[line_no, raw_payload, problem]` for every `@intent:` token in
|
|
34
|
+
# `text`, in source order.
|
|
35
|
+
#
|
|
36
|
+
# `raw_payload` is the object literal as written; it is `nil` when
|
|
37
|
+
# `problem` explains why the annotation could not be captured.
|
|
38
|
+
#
|
|
39
|
+
# An `@intent:` token carrying no extractable payload is **reported, not
|
|
40
|
+
# skipped** — a typo'd annotation must fail loudly rather than silently
|
|
41
|
+
# counting as "this example is unannotated".
|
|
42
|
+
#
|
|
43
|
+
# @param text [String] the full source of one file
|
|
44
|
+
# @yieldparam line_no [Integer] 1-based line number
|
|
45
|
+
# @yieldparam raw_payload [String, nil]
|
|
46
|
+
# @yieldparam problem [String, nil]
|
|
47
|
+
# @return [Enumerator] when no block is given
|
|
48
|
+
def each_intent(text)
|
|
49
|
+
return enum_for(:each_intent, text) unless block_given?
|
|
50
|
+
|
|
51
|
+
text.each_line.with_index(1) do |raw_line, line_no|
|
|
52
|
+
line = raw_line.chomp
|
|
53
|
+
pos = 0
|
|
54
|
+
|
|
55
|
+
loop do
|
|
56
|
+
token_at = line.index(INTENT_TOKEN, pos)
|
|
57
|
+
break if token_at.nil?
|
|
58
|
+
|
|
59
|
+
brace_at = line.index("{", token_at + INTENT_TOKEN.length)
|
|
60
|
+
if brace_at.nil?
|
|
61
|
+
yield line_no, nil, NO_PAYLOAD
|
|
62
|
+
break
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
begin
|
|
66
|
+
finish = scan_object(line, brace_at)
|
|
67
|
+
rescue ScanError => e
|
|
68
|
+
yield line_no, nil, e.message
|
|
69
|
+
break
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
yield line_no, line[brace_at...finish], nil
|
|
73
|
+
|
|
74
|
+
# Resume *after* the captured payload so trailing prose containing
|
|
75
|
+
# another `@intent:` is still seen, but the payload's own contents
|
|
76
|
+
# are never rescanned.
|
|
77
|
+
pos = finish
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Returns the index just past the `}` matching the `{` at `text[start]`.
|
|
83
|
+
#
|
|
84
|
+
# Bracket-balanced and string-aware, so a brace inside a quoted value does
|
|
85
|
+
# not end the payload early. Annotations are single-line per PROTOCOL.md
|
|
86
|
+
# §1, so `text` is one line.
|
|
87
|
+
#
|
|
88
|
+
# @raise [ScanError] when the literal is unterminated or unbalanced
|
|
89
|
+
def scan_object(text, start)
|
|
90
|
+
stack = []
|
|
91
|
+
i = start
|
|
92
|
+
length = text.length
|
|
93
|
+
|
|
94
|
+
while i < length
|
|
95
|
+
char = text[i]
|
|
96
|
+
|
|
97
|
+
if char == '"' || char == "'"
|
|
98
|
+
i = scan_string(text, i, char)
|
|
99
|
+
next
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
if char == "{" || char == "["
|
|
103
|
+
stack.push(char)
|
|
104
|
+
elsif char == "}" || char == "]"
|
|
105
|
+
raise ScanError, UNBALANCED_BRACKETS if stack.empty? || stack.last != OPENERS[char]
|
|
106
|
+
|
|
107
|
+
stack.pop
|
|
108
|
+
return i + 1 if stack.empty?
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
i += 1
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
raise ScanError, UNTERMINATED_OBJECT
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Returns the index just past the closing `quote` of a string literal.
|
|
118
|
+
#
|
|
119
|
+
# `text[start]` must be the opening quote. Backslash escapes are honoured,
|
|
120
|
+
# so a quote or brace *inside* the string never terminates the scan.
|
|
121
|
+
#
|
|
122
|
+
# @raise [ScanError] when the string literal is unterminated
|
|
123
|
+
def scan_string(text, start, quote)
|
|
124
|
+
i = start + 1
|
|
125
|
+
length = text.length
|
|
126
|
+
|
|
127
|
+
while i < length
|
|
128
|
+
char = text[i]
|
|
129
|
+
|
|
130
|
+
if char == "\\"
|
|
131
|
+
i += 2 # skip the escape and whatever it escapes
|
|
132
|
+
next
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
return i + 1 if char == quote
|
|
136
|
+
|
|
137
|
+
i += 1
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
raise ScanError, "unterminated #{quote}-quoted string"
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|