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