tryouts 3.3.0 → 3.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.
@@ -1,516 +0,0 @@
1
- # Modern Ruby 3.4+ solution for the teardown bug
2
-
3
- require 'prism'
4
- require_relative 'test_case'
5
-
6
- class Tryouts
7
- # Fixed PrismParser with pattern matching for robust token filtering
8
- class PrismParser
9
- def initialize(source_path)
10
- @source_path = source_path
11
- @source = File.read(source_path)
12
- @lines = @source.lines.map(&:chomp)
13
- @prism_result = Prism.parse(@source)
14
- @parsed_at = Time.now
15
- end
16
-
17
- def parse
18
- return handle_syntax_errors if @prism_result.failure?
19
-
20
- tokens = tokenize_content
21
- test_boundaries = find_test_case_boundaries(tokens)
22
- tokens = classify_potential_descriptions_with_boundaries(tokens, test_boundaries)
23
- test_blocks = group_into_test_blocks(tokens)
24
- process_test_blocks(test_blocks)
25
- end
26
-
27
- private
28
-
29
- # Tokenize content using pattern matching for clean line classification
30
- def tokenize_content
31
- tokens = []
32
-
33
- @lines.each_with_index do |line, index|
34
- token = case line
35
- in /^##\s*(.*)$/ # Test description format: ## description
36
- { type: :description, content: $1.strip, line: index }
37
- in /^#\s*TEST\s*\d*:\s*(.*)$/ # rubocop:disable Lint/DuplicateBranch
38
- { type: :description, content: $1.strip, line: index }
39
- in /^#\s*=!>\s*(.*)$/ # Exception expectation (updated for consistency)
40
- { type: :exception_expectation, content: $1.strip, line: index, ast: parse_expectation($1.strip) }
41
- in /^#\s*=<>\s*(.*)$/ # Intentional failure expectation
42
- { type: :intentional_failure_expectation, content: $1.strip, line: index, ast: parse_expectation($1.strip) }
43
- in /^#\s*==>\s*(.*)$/ # Boolean true expectation
44
- { type: :true_expectation, content: $1.strip, line: index, ast: parse_expectation($1.strip) }
45
- in %r{^#\s*=/=>\s*(.*)$} # Boolean false expectation
46
- { type: :false_expectation, content: $1.strip, line: index, ast: parse_expectation($1.strip) }
47
- in /^#\s*=\|>\s*(.*)$/ # Boolean (true or false) expectation
48
- { type: :boolean_expectation, content: $1.strip, line: index, ast: parse_expectation($1.strip) }
49
- in /^#\s*=:>\s*(.*)$/ # Result type expectation
50
- { type: :result_type_expectation, content: $1.strip, line: index, ast: parse_expectation($1.strip) }
51
- in /^#\s*=~>\s*(.*)$/ # Regex match expectation
52
- { type: :regex_match_expectation, content: $1.strip, line: index, ast: parse_expectation($1.strip) }
53
- in /^#\s*=%>\s*(.*)$/ # Performance time expectation
54
- { type: :performance_time_expectation, content: $1.strip, line: index, ast: parse_expectation($1.strip) }
55
- in /^#\s*=(\d+)>\s*(.*)$/ # Output expectation (stdout/stderr with pipe number)
56
- { type: :output_expectation, content: $2.strip, pipe: $1.to_i, line: index, ast: parse_expectation($2.strip) }
57
- in /^#\s*=>\s*(.*)$/ # Regular expectation
58
- { type: :expectation, content: $1.strip, line: index, ast: parse_expectation($1.strip) }
59
- in /^##\s*=>\s*(.*)$/ # Commented out expectation (should be ignored)
60
- { type: :comment, content: '=>' + $1.strip, line: index }
61
- in /^#\s*(.*)$/ # Single hash comment - potential description
62
- { type: :potential_description, content: $1.strip, line: index }
63
- in /^\s*$/ # Blank line
64
- { type: :blank, line: index }
65
- else # Ruby code
66
- { type: :code, content: line, line: index, ast: parse_ruby_line(line) }
67
- end
68
-
69
- tokens << token
70
- end
71
-
72
- # Return tokens with potential_descriptions - they'll be classified later with test boundaries
73
- tokens
74
- end
75
-
76
- # Find actual test case boundaries by looking for ## descriptions or # TEST: patterns
77
- # followed by code and expectations
78
- def find_test_case_boundaries(tokens)
79
- boundaries = []
80
-
81
- tokens.each_with_index do |token, index|
82
- # Look for explicit test descriptions (## or # TEST:)
83
- if token[:type] == :description
84
- # Find the end of this test case by looking for the last expectation
85
- # before the next description or end of file
86
- start_line = token[:line]
87
- end_line = find_test_case_end(tokens, index)
88
-
89
- boundaries << { start: start_line, end: end_line } if end_line
90
- end
91
- end
92
-
93
- boundaries
94
- end
95
-
96
- # Find where a test case ends by looking for the last expectation
97
- # before the next test description or end of tokens
98
- def find_test_case_end(tokens, start_index)
99
- last_expectation_line = nil
100
-
101
- # Look forward from the description for expectations
102
- (start_index + 1).upto(tokens.length - 1) do |i|
103
- token = tokens[i]
104
-
105
- # Stop if we hit another test description
106
- break if token[:type] == :description
107
-
108
- # Track the last expectation we see
109
- if is_expectation_type?(token[:type])
110
- last_expectation_line = token[:line]
111
- end
112
- end
113
-
114
- last_expectation_line
115
- end
116
-
117
- # Convert potential_descriptions to descriptions or comments using test case boundaries
118
- def classify_potential_descriptions_with_boundaries(tokens, test_boundaries)
119
- tokens.map.with_index do |token, index|
120
- if token[:type] == :potential_description
121
- # Check if this comment falls within any test case boundary
122
- line_num = token[:line]
123
- within_test_case = test_boundaries.any? do |boundary|
124
- line_num >= boundary[:start] && line_num <= boundary[:end]
125
- end
126
-
127
- if within_test_case
128
- # This comment is within a test case, treat as regular comment
129
- token.merge(type: :comment)
130
- else
131
- # For comments outside test boundaries, be more conservative
132
- # Only treat as description if it immediately precedes a test pattern AND
133
- # looks like a test description
134
- content = token[:content].strip
135
-
136
- # Check if this looks like a test description based on content
137
- looks_like_test_description = content.match?(/test|example|demonstrate|show|should|when|given/i) &&
138
- content.length > 10
139
-
140
- # Check if there's code immediately before this (suggesting it's mid-test)
141
- prev_token = index > 0 ? tokens[index - 1] : nil
142
- has_code_before = prev_token && prev_token[:type] == :code
143
-
144
- if has_code_before || !looks_like_test_description
145
- # Treat as regular comment
146
- token.merge(type: :comment)
147
- else
148
- # Look ahead for IMMEDIATE test pattern (stricter than before)
149
- following_tokens = tokens[(index + 1)..]
150
-
151
- # Skip blanks and comments to find meaningful content
152
- meaningful_following = following_tokens.reject { |t| [:blank, :comment].include?(t[:type]) }
153
-
154
- # Look for test pattern within next 5 tokens (more restrictive)
155
- test_window = meaningful_following.first(5)
156
- has_code = test_window.any? { |t| t[:type] == :code }
157
- has_expectation = test_window.any? { |t| is_expectation_type?(t[:type]) }
158
-
159
- # Only promote to description if BOTH code and expectation are found nearby
160
- # AND it looks like a test description
161
- if has_code && has_expectation && looks_like_test_description
162
- token.merge(type: :description)
163
- else
164
- token.merge(type: :comment)
165
- end
166
- end
167
- end
168
- else
169
- token
170
- end
171
- end
172
- end
173
-
174
- # Convert potential_descriptions to descriptions or comments based on context
175
- def classify_potential_descriptions(tokens)
176
- tokens.map.with_index do |token, index|
177
- if token[:type] == :potential_description
178
- # Check if this looks like a test description based on content and context
179
- content = token[:content].strip
180
-
181
- # Skip if it's clearly just a regular comment (short, lowercase, etc.)
182
- # Test descriptions are typically longer and more descriptive
183
- looks_like_regular_comment = content.length < 20 &&
184
- content.downcase == content &&
185
- !content.match?(/test|example|demonstrate|show/i)
186
-
187
- # Check if there's code immediately before this (suggesting it's mid-test)
188
- prev_token = index > 0 ? tokens[index - 1] : nil
189
- has_code_before = prev_token && prev_token[:type] == :code
190
-
191
- if looks_like_regular_comment || has_code_before
192
- # Treat as regular comment
193
- token.merge(type: :comment)
194
- else
195
- # Look ahead for test pattern: code + at least one expectation within reasonable distance
196
- following_tokens = tokens[(index + 1)..]
197
-
198
- # Skip blanks and comments to find meaningful content
199
- meaningful_following = following_tokens.reject { |t| [:blank, :comment].include?(t[:type]) }
200
-
201
- # Look for test pattern: at least one code token followed by at least one expectation
202
- # within the next 10 meaningful tokens (to avoid matching setup/teardown)
203
- test_window = meaningful_following.first(10)
204
- has_code = test_window.any? { |t| t[:type] == :code }
205
- has_expectation = test_window.any? { |t| is_expectation_type?(t[:type]) }
206
-
207
- if has_code && has_expectation
208
- token.merge(type: :description)
209
- else
210
- token.merge(type: :comment)
211
- end
212
- end
213
- else
214
- token
215
- end
216
- end
217
- end
218
-
219
- # Check if token type represents any kind of expectation
220
- def is_expectation_type?(type)
221
- [
222
- :expectation, :exception_expectation, :intentional_failure_expectation,
223
- :true_expectation, :false_expectation, :boolean_expectation,
224
- :result_type_expectation, :regex_match_expectation,
225
- :performance_time_expectation, :output_expectation
226
- ].include?(type)
227
- end
228
-
229
- # Group tokens into logical test blocks using pattern matching
230
- def group_into_test_blocks(tokens)
231
- blocks = []
232
- current_block = new_test_block
233
-
234
- tokens.each do |token|
235
- case [current_block, token]
236
- in [_, { type: :description, content: String => desc, line: Integer => line_num }]
237
- # Only combine descriptions if current block has a description but no code/expectations yet
238
- # Allow blank lines between multi-line descriptions
239
- if !current_block[:description].empty? && current_block[:code].empty? && current_block[:expectations].empty?
240
- # Multi-line description continuation
241
- current_block[:description] = [current_block[:description], desc].join(' ').strip
242
- else
243
- # Start new test block on description
244
- blocks << current_block if block_has_content?(current_block)
245
- current_block = new_test_block.merge(description: desc, start_line: line_num)
246
- end
247
-
248
- in [{ expectations: [], start_line: nil }, { type: :code, content: String => code, line: Integer => line_num }]
249
- # First code in a new block - set start_line
250
- current_block[:code] << token
251
- current_block[:start_line] = line_num
252
-
253
- in [{ expectations: [] }, { type: :code, content: String => code }]
254
- # Code before expectations - add to current block
255
- current_block[:code] << token
256
-
257
- in [{ expectations: Array => exps }, { type: :code }] if !exps.empty?
258
- # Code after expectations - finalize current block and start new one
259
- blocks << current_block
260
- current_block = new_test_block.merge(code: [token], start_line: token[:line])
261
-
262
- in [_, { type: :expectation }]
263
- current_block[:expectations] << token
264
-
265
- in [_, { type: :exception_expectation }]
266
- current_block[:expectations] << token
267
-
268
- in [_, { type: :intentional_failure_expectation }]
269
- current_block[:expectations] << token
270
-
271
- in [_, { type: :true_expectation }]
272
- current_block[:expectations] << token
273
-
274
- in [_, { type: :false_expectation }]
275
- current_block[:expectations] << token
276
-
277
- in [_, { type: :boolean_expectation }]
278
- current_block[:expectations] << token
279
-
280
- in [_, { type: :result_type_expectation }]
281
- current_block[:expectations] << token
282
-
283
- in [_, { type: :regex_match_expectation }]
284
- current_block[:expectations] << token
285
-
286
- in [_, { type: :performance_time_expectation }]
287
- current_block[:expectations] << token
288
-
289
- in [_, { type: :output_expectation }]
290
- current_block[:expectations] << token
291
-
292
- in [_, { type: :comment | :blank }]
293
- add_context_to_block(current_block, token)
294
- end
295
- end
296
-
297
- blocks << current_block if block_has_content?(current_block)
298
- classify_blocks(blocks)
299
- end
300
-
301
- # Process classified test blocks into domain objects
302
- def process_test_blocks(classified_blocks)
303
- setup_blocks = classified_blocks.filter { |block| block[:type] == :setup }
304
- test_blocks = classified_blocks.filter { |block| block[:type] == :test }
305
- teardown_blocks = classified_blocks.filter { |block| block[:type] == :teardown }
306
-
307
- Testrun.new(
308
- setup: build_setup(setup_blocks),
309
- test_cases: test_blocks.map { |block| build_test_case(block) },
310
- teardown: build_teardown(teardown_blocks),
311
- source_file: @source_path,
312
- metadata: { parsed_at: @parsed_at, parser: :prism_v2_fixed },
313
- )
314
- end
315
-
316
- def build_setup(setup_blocks)
317
- return Setup.new(code: '', line_range: 0..0, path: @source_path) if setup_blocks.empty?
318
-
319
- Setup.new(
320
- code: extract_pure_code_from_blocks(setup_blocks),
321
- line_range: calculate_block_range(setup_blocks),
322
- path: @source_path,
323
- )
324
- end
325
-
326
- def build_teardown(teardown_blocks)
327
- return Teardown.new(code: '', line_range: 0..0, path: @source_path) if teardown_blocks.empty?
328
-
329
- Teardown.new(
330
- code: extract_pure_code_from_blocks(teardown_blocks),
331
- line_range: calculate_block_range(teardown_blocks),
332
- path: @source_path,
333
- )
334
- end
335
-
336
- # Modern Ruby 3.4+ pattern matching for robust code extraction
337
- # This filters out comments added by add_context_to_block explicitly
338
- def extract_pure_code_from_blocks(blocks)
339
- blocks
340
- .flat_map { |block| block[:code] }
341
- .filter_map do |token|
342
- case token
343
- in { type: :code, content: String => content }
344
- content
345
- else
346
- nil
347
- end
348
- end
349
- .join("\n")
350
- end
351
-
352
- def calculate_block_range(blocks)
353
- return 0..0 if blocks.empty?
354
-
355
- # Filter out blocks with nil line numbers and build valid ranges
356
- valid_blocks = blocks.filter { |block| block[:start_line] && block[:end_line] }
357
- return 0..0 if valid_blocks.empty?
358
-
359
- line_ranges = valid_blocks.map { |block| block[:start_line]..block[:end_line] }
360
- line_ranges.first.first..line_ranges.last.last
361
- end
362
-
363
- def extract_code_content(code_tokens)
364
- code_tokens
365
- .filter_map do |token|
366
- case token
367
- in { type: :code, content: String => content }
368
- content
369
- else
370
- nil
371
- end
372
- end
373
- .join("\n")
374
- end
375
-
376
- def parse_ruby_line(line)
377
- return nil if line.strip.empty?
378
-
379
- result = Prism.parse(line.strip)
380
- case result
381
- in { errors: [] => errors, value: { body: { body: [ast] } } }
382
- ast
383
- in { errors: Array => errors } if errors.any?
384
- { type: :parse_error, errors: errors, raw: line }
385
- else
386
- nil
387
- end
388
- end
389
-
390
- def parse_expectation(expr)
391
- parse_ruby_line(expr)
392
- end
393
-
394
- def new_test_block
395
- {
396
- description: '',
397
- code: [],
398
- expectations: [],
399
- comments: [],
400
- start_line: nil,
401
- end_line: nil,
402
- }
403
- end
404
-
405
- def block_has_content?(block)
406
- case block
407
- in { description: String => desc, code: Array => code, expectations: Array => exps }
408
- !desc.empty? || !code.empty? || !exps.empty?
409
- else
410
- false
411
- end
412
- end
413
-
414
- def add_context_to_block(block, token)
415
- case [block[:expectations].empty?, token]
416
- in [true, { type: :comment | :blank }]
417
- # Comments before expectations go with code
418
- block[:code] << token
419
- in [false, { type: :comment | :blank }]
420
- # Comments after expectations are test context
421
- block[:comments] << token
422
- end
423
- end
424
-
425
- # Classify blocks as setup, test, or teardown based on content
426
- def classify_blocks(blocks)
427
- blocks.map.with_index do |block, index|
428
- block_type = case block
429
- in { expectations: [] } if index == 0
430
- :setup
431
- in { expectations: [] } if index == blocks.size - 1
432
- :teardown
433
- in { expectations: Array => exps } if !exps.empty?
434
- :test
435
- else
436
- :preamble # Default fallback
437
- end
438
-
439
- block.merge(type: block_type, end_line: calculate_end_line(block))
440
- end
441
- end
442
-
443
- def calculate_end_line(block)
444
- # Only consider actual content (code and expectations), not blank lines/comments
445
- content_tokens = [*block[:code], *block[:expectations]]
446
- return block[:start_line] if content_tokens.empty?
447
-
448
- content_tokens.map { |token| token[:line] }.max || block[:start_line]
449
- end
450
-
451
- def build_test_case(block)
452
- case block
453
- in {
454
- type: :test,
455
- description: String => desc,
456
- code: Array => code_tokens,
457
- expectations: Array => exp_tokens,
458
- start_line: Integer => start_line,
459
- end_line: Integer => end_line
460
- }
461
- # Extract source lines from the original source during parsing
462
- source_lines = @lines[start_line..end_line]
463
-
464
- # Find the first expectation line for better error reporting
465
- first_expectation_line = exp_tokens.empty? ? start_line : exp_tokens.first[:line]
466
-
467
- TestCase.new(
468
- description: desc,
469
- code: extract_code_content(code_tokens),
470
- expectations: exp_tokens.map do |token|
471
- type = case token[:type]
472
- when :exception_expectation then :exception
473
- when :intentional_failure_expectation then :intentional_failure
474
- when :true_expectation then :true # rubocop:disable Lint/BooleanSymbol
475
- when :false_expectation then :false # rubocop:disable Lint/BooleanSymbol
476
- when :boolean_expectation then :boolean
477
- when :result_type_expectation then :result_type
478
- when :regex_match_expectation then :regex_match
479
- when :performance_time_expectation then :performance_time
480
- when :output_expectation then :output
481
- else :regular
482
- end
483
-
484
- # For output expectations, we need to preserve the pipe number
485
- if token[:type] == :output_expectation
486
- OutputExpectation.new(content: token[:content], type: type, pipe: token[:pipe])
487
- else
488
- Expectation.new(content: token[:content], type: type)
489
- end
490
- end,
491
- line_range: start_line..end_line,
492
- path: @source_path,
493
- source_lines: source_lines,
494
- first_expectation_line: first_expectation_line,
495
- )
496
- else
497
- raise "Invalid test block structure: #{block}"
498
- end
499
- end
500
-
501
- def handle_syntax_errors
502
- errors = @prism_result.errors.map do |error|
503
- line_context = @lines[error.location.start_line - 1] || ''
504
-
505
- TryoutSyntaxError.new(
506
- error.message,
507
- line_number: error.location.start_line,
508
- context: line_context,
509
- source_file: @source_path,
510
- )
511
- end
512
-
513
- raise errors.first if errors.any?
514
- end
515
- end
516
- end