Module: SpecGuard::RSpec::AnnotationScanner
- Defined in:
- lib/specguard/rspec/annotation_scanner.rb
Overview
Finds @intent: annotations in test source and captures the object
literal that follows each one.
This is a syntactic pass only: it hands back the payload exactly as written (still in PROTOCOL.md §1's permissive syntax — see PayloadNormalizer) and never inspects its contents. Schema validation is a later stage entirely.
The scan is string-aware: quoted content is skipped over wholesale, so
a behavior sentence containing an apostrophe or an unbalanced { cannot
terminate the capture early. Plain brace-counting gets that case wrong.
The algorithm is PROTOCOL.md §1's: find each @intent: token and capture
the object literal after it, string-aware and bracket-balanced.
open-test-intent's validate-intent implements the same rules; where the
two disagree, PROTOCOL.md decides.
Constant Summary collapse
- INTENT_TOKEN =
"@intent:"- OPENERS =
Closing bracket => the opener it must match.
{ "}" => "{", "]" => "[" }.freeze
- UNTERMINATED_OBJECT =
The
{atstartwas never closed on this line. "unterminated object literal (an annotation must fit on one line)"- UNBALANCED_BRACKETS =
"unbalanced brackets in the annotation payload"- NO_PAYLOAD =
"no '{...}' object literal follows the @intent: token"
Class Method Summary collapse
-
.each_intent(text) {|line_no, raw_payload, problem| ... } ⇒ Enumerator
Yields
[line_no, raw_payload, problem]for every@intent:token intext, in source order. -
.payload_brace(line, token_at) ⇒ Object
Returns the index of the
{opening the payload of the@intent:token attoken_at, or nil when that token has no payload. -
.scan_object(text, start) ⇒ Object
Returns the index just past the
}matching the{attext[start]. -
.scan_string(text, start, quote) ⇒ Object
Returns the index just past the closing
quoteof a string literal.
Class Method Details
.each_intent(text) {|line_no, raw_payload, problem| ... } ⇒ Enumerator
Yields [line_no, raw_payload, problem] for every @intent: token in
text, in source order.
raw_payload is the object literal as written; it is nil when
problem explains why the annotation could not be captured.
An @intent: token carrying no extractable payload is reported, not
skipped — a typo'd annotation must fail loudly rather than silently
counting as "this example is unannotated".
49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 |
# File 'lib/specguard/rspec/annotation_scanner.rb', line 49 def each_intent(text) return enum_for(:each_intent, text) unless block_given? text.each_line.with_index(1) do |raw_line, line_no| line = raw_line.chomp pos = 0 loop do token_at = line.index(INTENT_TOKEN, pos) break if token_at.nil? brace_at = payload_brace(line, token_at) if brace_at.nil? yield line_no, nil, NO_PAYLOAD break end begin finish = scan_object(line, brace_at) rescue ScanError => e yield line_no, nil, e. break end yield line_no, line[brace_at...finish], nil # Resume *after* the captured payload so trailing prose containing # another `@intent:` is still seen, but the payload's own contents # are never rescanned. pos = finish end end end |
.payload_brace(line, token_at) ⇒ Object
Returns the index of the { opening the payload of the @intent:
token at token_at, or nil when that token has no payload.
The search is bounded by the next @intent: token on the line. An
unbounded search reads to end of line, so a malformed token followed by
a well-formed one adopts its neighbour's object literal and is yielded
with problem: nil — a typo'd annotation reported as valid, carrying
somebody else's intent, and (because the caller resumes past the
captured payload) swallowing the well-formed token on the way. That is
the exact opposite of {each_intent}'s "reported, not skipped" contract,
and PROTOCOL.md §1 supports the bounded reading: a payload is the object
following its own token, which a literal on the far side of a second
token is not.
The bound can only ever shrink the search — it never picks a different brace, only declines one — so a token whose payload lies beyond the next token takes the {NO_PAYLOAD} path instead of a false pass.
line.index is a naive string search, so it also finds an @intent:
written inside a quoted string. That is harmless for the case it looks
like it would break — a token quoted inside a payload is by definition
after that payload's {, so the bound is inert and the payload is
captured as before. The occurrence has to sit between the token and its
{ to matter at all, which means it is in the prose separating them:
# @intent: like the "@intent:" above { entity: "Order", ... }
That line now reports NO_PAYLOAD where it previously captured the literal. It is the one shape this bound makes stricter, and the stricter answer is the right one: the line is genuinely ambiguous about which token owns the literal, and declining to guess is the loud answer this scanner is supposed to give. Moving the quoted mention after the payload, or dropping the quotes, restores the capture.
117 118 119 120 121 122 123 124 125 126 |
# File 'lib/specguard/rspec/annotation_scanner.rb', line 117 def payload_brace(line, token_at) after_token = token_at + INTENT_TOKEN.length brace_at = line.index("{", after_token) return nil if brace_at.nil? next_token = line.index(INTENT_TOKEN, after_token) return nil if next_token && brace_at > next_token brace_at end |
.scan_object(text, start) ⇒ Object
Returns the index just past the } matching the { at text[start].
Bracket-balanced and string-aware, so a brace inside a quoted value does
not end the payload early. Annotations are single-line per PROTOCOL.md
§1, so text is one line.
135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 |
# File 'lib/specguard/rspec/annotation_scanner.rb', line 135 def scan_object(text, start) stack = [] i = start length = text.length while i < length char = text[i] if char == '"' || char == "'" i = scan_string(text, i, char) next end if char == "{" || char == "[" stack.push(char) elsif char == "}" || char == "]" raise ScanError, UNBALANCED_BRACKETS if stack.empty? || stack.last != OPENERS[char] stack.pop return i + 1 if stack.empty? end i += 1 end raise ScanError, UNTERMINATED_OBJECT end |
.scan_string(text, start, quote) ⇒ Object
Returns the index just past the closing quote of a string literal.
text[start] must be the opening quote. Backslash escapes are honoured,
so a quote or brace inside the string never terminates the scan.
169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 |
# File 'lib/specguard/rspec/annotation_scanner.rb', line 169 def scan_string(text, start, quote) i = start + 1 length = text.length while i < length char = text[i] if char == "\\" i += 2 # skip the escape and whatever it escapes next end return i + 1 if char == quote i += 1 end raise ScanError, "unterminated #{quote}-quoted string" end |