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.

Ported from open-test-intent's bin/validate-intent (extract_intents / _scan_object / _scan_string), which is the reference implementation of this algorithm.

Constant Summary collapse

INTENT_TOKEN =
"@intent:"
OPENERS =

Closing bracket => the opener it must match.

{ "}" => "{", "]" => "[" }.freeze
UNTERMINATED_OBJECT =

The { at start was 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

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".

Parameters:

  • text (String)

    the full source of one file

Yield Parameters:

  • line_no (Integer)

    1-based line number

  • raw_payload (String, nil)
  • problem (String, nil)

Returns:

  • (Enumerator)

    when no block is given



48
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
# File 'lib/specguard/rspec/annotation_scanner.rb', line 48

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 = line.index("{", token_at + INTENT_TOKEN.length)
      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.message
        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

.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.

Raises:

  • (ScanError)

    when the literal is unterminated or unbalanced



89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
# File 'lib/specguard/rspec/annotation_scanner.rb', line 89

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.

Raises:

  • (ScanError)

    when the string literal is unterminated



123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
# File 'lib/specguard/rspec/annotation_scanner.rb', line 123

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