Module: Tryouts::Parsers::SharedMethods

Included in:
BaseParser
Defined in:
lib/tryouts/parsers/shared_methods.rb

Instance Method Summary collapse

Instance Method Details

#add_context_to_block(block, token) ⇒ Object



374
375
376
377
378
379
380
381
# File 'lib/tryouts/parsers/shared_methods.rb', line 374

def add_context_to_block(block, token)
  case [block[:expectations].empty?, token]
  in [true, { type: :comment | :blank }]
    block[:code] << token
  in [false, { type: :comment | :blank }]
    block[:comments] << token
  end
end

#add_warning(warning) ⇒ Object

Warning collection methods



480
481
482
483
# File 'lib/tryouts/parsers/shared_methods.rb', line 480

def add_warning(warning)
  @warnings ||= []
  @warnings << warning
end

#block_has_content?(block) ⇒ Boolean

Returns:

  • (Boolean)


365
366
367
368
369
370
371
372
# File 'lib/tryouts/parsers/shared_methods.rb', line 365

def block_has_content?(block)
  case block
  in { description: String => desc, code: Array => code, expectations: Array => exps }
    !desc.empty? || !code.empty? || !exps.empty?
  else
    false
  end
end

#build_orphan_block(block) ⇒ Object

Orphan blocks (code with no description and no expectations, sandwiched between real test cases) are collected for execution instead of being silently dropped. Comment-only blocks carry no executable code and produce nothing.



266
267
268
269
270
271
272
273
274
275
276
# File 'lib/tryouts/parsers/shared_methods.rb', line 266

def build_orphan_block(block)
  code = extract_pure_code_from_blocks([block])
  return nil if code.strip.empty?

  OrphanBlock.new(
    code: code,
    line_range: calculate_block_range([block]),
    path: @source_path,
    first_code_line: first_code_line(collect_code_tokens([block])),
  )
end

#build_setup(setup_blocks) ⇒ Object



240
241
242
243
244
245
246
247
248
249
# File 'lib/tryouts/parsers/shared_methods.rb', line 240

def build_setup(setup_blocks)
  return Setup.new(code: '', line_range: 0..0, path: @source_path) if setup_blocks.empty?

  Setup.new(
    code: extract_pure_code_from_blocks(setup_blocks),
    line_range: calculate_block_range(setup_blocks),
    path: @source_path,
    first_code_line: first_code_line(collect_code_tokens(setup_blocks)),
  )
end

#build_teardown(teardown_blocks) ⇒ Object



251
252
253
254
255
256
257
258
259
260
# File 'lib/tryouts/parsers/shared_methods.rb', line 251

def build_teardown(teardown_blocks)
  return Teardown.new(code: '', line_range: 0..0, path: @source_path) if teardown_blocks.empty?

  Teardown.new(
    code: extract_pure_code_from_blocks(teardown_blocks),
    line_range: calculate_block_range(teardown_blocks),
    path: @source_path,
    first_code_line: first_code_line(collect_code_tokens(teardown_blocks)),
  )
end

#build_test_case(block) ⇒ Object



408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
# File 'lib/tryouts/parsers/shared_methods.rb', line 408

def build_test_case(block)
  case block
  in {
    type: :test,
    description: String => desc,
    code: Array => code_tokens,
    expectations: Array => exp_tokens,
    start_line: Integer => start_line,
    end_line: Integer => end_line
  }
    # Collect warning for unnamed test
    collect_unnamed_test_warning(block)

    source_lines           = @lines[start_line..end_line]
    first_expectation_line = exp_tokens.empty? ? start_line : exp_tokens.first[:line]

    TestCase.new(
      description: desc,
      code: extract_code_content(code_tokens),
      first_code_line: first_code_line(code_tokens),
      expectations: exp_tokens.map do |token|
        type = case token[:type]
               when :exception_expectation then :exception
               when :intentional_failure_expectation then :intentional_failure
               when :true_expectation then :true # rubocop:disable Lint/BooleanSymbol
               when :false_expectation then :false # rubocop:disable Lint/BooleanSymbol
               when :boolean_expectation then :boolean
               when :result_type_expectation then :result_type
               when :regex_match_expectation then :regex_match
               when :performance_time_expectation then :performance_time
               when :output_expectation then :output
               when :non_nil_expectation then :non_nil
               else :regular
               end

        if token[:type] == :output_expectation
          OutputExpectation.new(content: token[:content], type: type, pipe: token[:pipe])
        else
          Expectation.new(content: token[:content], type: type)
        end
      end,
      line_range: start_line..end_line,
      path: @source_path,
      source_lines: source_lines,
      first_expectation_line: first_expectation_line,
    )
  else
    raise "Invalid test block structure: #{block}"
  end
end

#calculate_block_range(blocks) ⇒ Object



283
284
285
286
287
288
289
290
291
# File 'lib/tryouts/parsers/shared_methods.rb', line 283

def calculate_block_range(blocks)
  return 0..0 if blocks.empty?

  valid_blocks = blocks.filter { |block| block[:start_line] && block[:end_line] }
  return 0..0 if valid_blocks.empty?

  line_ranges = valid_blocks.map { |block| block[:start_line]..block[:end_line] }
  line_ranges.first.first..line_ranges.last.last
end

#calculate_end_line(block) ⇒ Object



401
402
403
404
405
406
# File 'lib/tryouts/parsers/shared_methods.rb', line 401

def calculate_end_line(block)
  content_tokens = [*block[:code], *block[:expectations]]
  return block[:start_line] if content_tokens.empty?

  content_tokens.map { |token| token[:line] }.max || block[:start_line]
end

#classify_blocks(blocks) ⇒ Object

Classify blocks as setup, test, or teardown based on content



384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
# File 'lib/tryouts/parsers/shared_methods.rb', line 384

def classify_blocks(blocks)
  blocks.map.with_index do |block, index|
    block_type = case block
                 in { expectations: [] } if index == 0
                   :setup
                 in { expectations: [] } if index == blocks.size - 1
                   :teardown
                 in { expectations: Array => exps } if !exps.empty?
                   :test
                 else
                   :orphan
                 end

    block.merge(type: block_type, end_line: calculate_end_line(block))
  end
end

#classify_potential_descriptions_with_boundaries(tokens, test_boundaries) ⇒ Object

Convert potential_descriptions to descriptions or comments using test case boundaries



158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
# File 'lib/tryouts/parsers/shared_methods.rb', line 158

def classify_potential_descriptions_with_boundaries(tokens, test_boundaries)
  tokens.map.with_index do |token, index|
    if token[:type] == :potential_description
      line_num         = token[:line]
      within_test_case = test_boundaries.any? do |boundary|
        line_num >= boundary[:start] && line_num <= boundary[:end]
      end

      if within_test_case
        token.merge(type: :comment)
      else
        content = token[:content].strip

        looks_like_test_description = content.match?(/test|example|demonstrate|show|should|when|given/i) &&
                                      content.length > 10

        prev_token      = index > 0 ? tokens[index - 1] : nil
        has_code_before = prev_token && prev_token[:type] == :code

        if has_code_before || !looks_like_test_description
          token.merge(type: :comment)
        else
          following_tokens     = tokens[(index + 1)..]
          meaningful_following = following_tokens.reject { |t| [:blank, :comment].include?(t[:type]) }
          test_window          = meaningful_following.first(5)
          has_code             = test_window.any? { |t| t[:type] == :code }
          has_expectation      = test_window.any? { |t| is_expectation_type?(t[:type]) }

          if has_code && has_expectation && looks_like_test_description
            token.merge(type: :description)
          else
            token.merge(type: :comment)
          end
        end
      end
    else
      token
    end
  end
end

#collect_code_tokens(blocks) ⇒ Object



317
318
319
# File 'lib/tryouts/parsers/shared_methods.rb', line 317

def collect_code_tokens(blocks)
  blocks.flat_map { |block| block[:code] }
end

#collect_unnamed_test_warning(block) ⇒ Object



489
490
491
492
493
494
495
496
497
498
499
# File 'lib/tryouts/parsers/shared_methods.rb', line 489

def collect_unnamed_test_warning(block)
  return unless block[:type] == :test && block[:description].empty?

  line_number = block[:start_line] + 1
  context = @lines[block[:start_line]] || ''

  add_warning(ParserWarning.unnamed_test(
    line_number: line_number,
    context: context.strip
  ))
end

#extract_code_content(code_tokens) ⇒ Object

Code keeps the spacing it had in the source: every comment or blank line between two statements becomes an empty line here. Paired with first_code_line as the eval start line, that makes each statement in a multi-line block report its true source location in a backtrace. Joining the code lines alone would collapse the gaps and drift by however many lines were dropped.



299
300
301
302
303
304
305
306
307
308
# File 'lib/tryouts/parsers/shared_methods.rb', line 299

def extract_code_content(code_tokens)
  positioned = positioned_code_tokens(code_tokens)
  return '' if positioned.empty?

  base  = positioned.first.first
  lines = []

  positioned.each { |line, content| lines[line - base] = content }
  lines.map { |content| content || '' }.join("\n")
end

#extract_pure_code_from_blocks(blocks) ⇒ Object

Modern Ruby 3.4+ pattern matching for robust code extraction



279
280
281
# File 'lib/tryouts/parsers/shared_methods.rb', line 279

def extract_pure_code_from_blocks(blocks)
  extract_code_content(collect_code_tokens(blocks))
end

#find_test_case_boundaries(tokens) ⇒ Object

Find actual test case boundaries by looking for ## descriptions or # TEST: patterns followed by code and expectations



143
144
145
146
147
148
149
150
151
152
153
154
155
# File 'lib/tryouts/parsers/shared_methods.rb', line 143

def find_test_case_boundaries(tokens)
  boundaries = []

  tokens.each_with_index do |token, index|
    if token[:type] == :description
      start_line = token[:line]
      end_line   = find_test_case_end(tokens, index)
      boundaries << { start: start_line, end: end_line } if end_line
    end
  end

  boundaries
end

#find_test_case_end(tokens, start_index) ⇒ Object

Find where a test case ends by looking for the last expectation before the next test description or end of tokens



122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
# File 'lib/tryouts/parsers/shared_methods.rb', line 122

def find_test_case_end(tokens, start_index)
  last_expectation_line = nil

  # Look forward from the description for expectations
  (start_index + 1).upto(tokens.length - 1) do |i|
    token = tokens[i]

    # Stop if we hit another test description
    break if token[:type] == :description

    # Track the last expectation we see
    if is_expectation_type?(token[:type])
      last_expectation_line = token[:line]
    end
  end

  last_expectation_line
end

#first_code_line(code_tokens) ⇒ Object

0-based source line of the block's first executable line, which is not the same as its start_line: a block opens at its description or leading comment, and only the code lines are ever evaluated.



313
314
315
# File 'lib/tryouts/parsers/shared_methods.rb', line 313

def first_code_line(code_tokens)
  positioned_code_tokens(code_tokens).first&.first
end

#group_into_test_blocks(tokens) ⇒ Object



40
41
42
43
44
45
46
47
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
81
82
83
84
85
86
87
88
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
116
117
118
# File 'lib/tryouts/parsers/shared_methods.rb', line 40

def group_into_test_blocks(tokens)
  blocks        = []
  current_block = new_test_block

  tokens.each_with_index do |token, index|
    case [current_block, token]
    in [_, { type: :description, content: String => desc, line: Integer => line_num }]
      # Only combine descriptions if current block has a description but no code/expectations yet
      # Allow blank lines between multi-line descriptions
      if !current_block[:description].empty? && current_block[:code].empty? && current_block[:expectations].empty?
        # Multi-line description continuation
        current_block[:description] = [current_block[:description], desc].join(' ').strip
      elsif has_following_test_pattern?(tokens, index)
        # Only create new block if description is followed by actual test pattern
        blocks << current_block if block_has_content?(current_block)
        current_block = new_test_block.merge(description: desc, start_line: line_num)
      else
        # Treat as regular comment - don't create new block
        current_block[:comments] << token
      end

    in [{ expectations: [], start_line: nil }, { type: :code, content: String => code, line: Integer => line_num }]
      # First code in a new block - set start_line
      current_block[:code] << token
      current_block[:start_line] = line_num

    in [{ expectations: [] }, { type: :code, content: String => code }]
      # Code before expectations - add to current block
      current_block[:code] << token

    in [{ expectations: Array => exps }, { type: :code }] if !exps.empty?
      # Code after expectations - finalize current block and start new one
      blocks << current_block
      current_block = new_test_block.merge(code: [token], start_line: token[:line])

    in [_, { type: :expectation }]
      current_block[:expectations] << token

    in [_, { type: :exception_expectation }]
      current_block[:expectations] << token

    in [_, { type: :intentional_failure_expectation }]
      current_block[:expectations] << token

    in [_, { type: :true_expectation }]
      current_block[:expectations] << token

    in [_, { type: :false_expectation }]
      current_block[:expectations] << token

    in [_, { type: :boolean_expectation }]
      current_block[:expectations] << token

    in [_, { type: :non_nil_expectation }]
      current_block[:expectations] << token

    in [_, { type: :result_type_expectation }]
      current_block[:expectations] << token

    in [_, { type: :regex_match_expectation }]
      current_block[:expectations] << token

    in [_, { type: :performance_time_expectation }]
      current_block[:expectations] << token

    in [_, { type: :output_expectation }]
      current_block[:expectations] << token

    in [_, { type: :malformed_expectation }]
      current_block[:expectations] << token

    in [_, { type: :comment | :blank }]
      add_context_to_block(current_block, token)
    end
  end

  blocks << current_block if block_has_content?(current_block)
  classify_blocks(blocks)
end

#handle_syntax_errorsObject



459
460
461
462
463
464
465
466
467
468
469
470
471
472
# File 'lib/tryouts/parsers/shared_methods.rb', line 459

def handle_syntax_errors
  errors = @prism_result.errors.map do |error|
    line_context = @lines[error.location.start_line - 1] || ''

    TryoutSyntaxError.new(
      error.message,
      line_number: error.location.start_line,
      context: line_context,
      source_file: @source_path,
    )
  end

  raise errors.first if errors.any?
end

#has_following_test_pattern?(tokens, desc_index) ⇒ Boolean

Check if a description token at given index is followed by actual test content (code + expectations), indicating it's a real test case vs just a comment

Returns:

  • (Boolean)


12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
# File 'lib/tryouts/parsers/shared_methods.rb', line 12

def has_following_test_pattern?(tokens, desc_index)
  return false if desc_index >= tokens.length - 1

  # Look ahead for code and expectation tokens after this description
  has_code        = false
  has_expectation = false

  (desc_index + 1...tokens.length).each do |i|
    token = tokens[i]

    case token[:type]
    when :code
      has_code = true
    when :description
      # If we hit another description before finding expectations,
      # this description doesn't have a complete test pattern
      break
    else
      if is_expectation_type?(token[:type])
        has_expectation = true
        break if has_code # Found both code and expectation
      end
    end
  end

  has_code && has_expectation
end

#is_expectation_type?(type) ⇒ Boolean

Check if token type represents any kind of expectation

Returns:

  • (Boolean)


200
201
202
203
204
205
206
207
208
# File 'lib/tryouts/parsers/shared_methods.rb', line 200

def is_expectation_type?(type)
  [
    :expectation, :exception_expectation, :intentional_failure_expectation,
    :true_expectation, :false_expectation, :boolean_expectation,
    :result_type_expectation, :regex_match_expectation,
    :performance_time_expectation, :output_expectation, :non_nil_expectation,
    :malformed_expectation
  ].include?(type)
end

#new_test_blockObject



354
355
356
357
358
359
360
361
362
363
# File 'lib/tryouts/parsers/shared_methods.rb', line 354

def new_test_block
  {
    description: '',
    code: [],
    expectations: [],
    comments: [],
    start_line: nil,
    end_line: nil,
  }
end

#parse_expectation(expr) ⇒ Object



350
351
352
# File 'lib/tryouts/parsers/shared_methods.rb', line 350

def parse_expectation(expr)
  parse_ruby_line(expr)
end

#parse_ruby_line(line) ⇒ Object



336
337
338
339
340
341
342
343
344
345
346
347
348
# File 'lib/tryouts/parsers/shared_methods.rb', line 336

def parse_ruby_line(line)
  return nil if line.strip.empty?

  result = Prism.parse(line.strip)
  case result
  in { errors: [] => errors, value: { body: { body: [ast] } } }
    ast
  in { errors: Array => errors } if errors.any?
    { type: :parse_error, errors: errors, raw: line }
  else
    nil
  end
end

#parser_typeObject

Parser type identification for metadata - to be overridden by subclasses



475
476
477
# File 'lib/tryouts/parsers/shared_methods.rb', line 475

def parser_type
  :shared
end

#positioned_code_tokens(code_tokens) ⇒ Object

[line, content] pairs for executable lines only, in source order. The tokenizer emits at most one :code token per physical line.



323
324
325
326
327
328
329
330
331
332
333
334
# File 'lib/tryouts/parsers/shared_methods.rb', line 323

def positioned_code_tokens(code_tokens)
  code_tokens
    .filter_map do |token|
      case token
      in { type: :code, content: String => content, line: Integer => line }
        [line, content]
      else
        nil
      end
    end
    .sort_by(&:first)
end

#process_test_blocks(classified_blocks) ⇒ Object

Process classified test blocks into domain objects



211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
# File 'lib/tryouts/parsers/shared_methods.rb', line 211

def process_test_blocks(classified_blocks)
  setup_blocks    = classified_blocks.filter { |block| block[:type] == :setup }
  teardown_blocks = classified_blocks.filter { |block| block[:type] == :teardown }

  # Test cases and orphan blocks stay interleaved in source order: the
  # executor runs them sequentially and an orphan executed out of position
  # would corrupt the shared-context local-variable timeline.
  test_cases = classified_blocks.filter_map do |block|
    case block[:type]
    when :test then build_test_case(block)
    when :orphan then build_orphan_block(block)
    end
  end

  testrun = Testrun.new(
    setup: build_setup(setup_blocks),
    test_cases: test_cases,
    teardown: build_teardown(teardown_blocks),
    source_file: @source_path,
    metadata: { parsed_at: @parsed_at, parser: parser_type },
    warnings: warnings
  )

  # Validate strict mode after collecting all warnings
  validate_strict_mode(testrun)

  testrun
end

#validate_strict_mode(testrun) ⇒ Object

Raises:



501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
# File 'lib/tryouts/parsers/shared_methods.rb', line 501

def validate_strict_mode(testrun)
  return unless @options[:strict]

  unnamed_test_warnings = warnings.select { |w| w.type == :unnamed_test }
  return if unnamed_test_warnings.empty?

  # In strict mode, fail with first unnamed test error
  first_warning = unnamed_test_warnings.first
  raise TryoutSyntaxError.new(
    "Strict mode: #{first_warning.message} at line #{first_warning.line_number}. #{first_warning.suggestion}",
    line_number: first_warning.line_number,
    context: first_warning.context,
    source_file: @source_path
  )
end

#warningsObject



485
486
487
# File 'lib/tryouts/parsers/shared_methods.rb', line 485

def warnings
  @warnings ||= []
end