tryouts 3.5.0 → 3.5.2

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.
@@ -0,0 +1,178 @@
1
+ In Ruby 3.4+, `case/when` and `case/in` represent fundamentally different approaches to conditional logic:
2
+
3
+ ## `case/when` - Traditional Equality Matching
4
+
5
+ Uses the `===` operator for comparison. Simple and straightforward:
6
+
7
+ ```ruby
8
+ def classify_response(status)
9
+ case status
10
+ when 200..299
11
+ "success"
12
+ when 400..499
13
+ "client_error"
14
+ when 500..599
15
+ "server_error"
16
+ when String
17
+ "string_status"
18
+ else
19
+ "unknown"
20
+ end
21
+ end
22
+ ```
23
+
24
+ ## `case/in` - Pattern Matching with Destructuring
25
+
26
+ Matches structure and binds variables. Much more powerful:
27
+
28
+ ```ruby
29
+ def process_api_response(response)
30
+ case response
31
+ in { status: 200, data: { user: { name: String => name, age: Integer => age } } }
32
+ "User #{name} is #{age} years old"
33
+
34
+ in { status: 200, data: Array => items } if items.length > 10
35
+ "Got #{items.length} items"
36
+
37
+ in { status: 400..499, error: { message: msg } }
38
+ "Client error: #{msg}"
39
+
40
+ in { status: 500.. }
41
+ "Server error occurred"
42
+
43
+ in nil | {}
44
+ "Empty response"
45
+ else
46
+ "Unexpected response format"
47
+ end
48
+ end
49
+ ```
50
+
51
+ ## Key Differences
52
+
53
+ ### 1. **Variable Binding**
54
+
55
+ ```ruby
56
+ # case/when - no binding
57
+ case user
58
+ when Hash
59
+ puts user[:name] # Must access manually
60
+ end
61
+
62
+ # case/in - automatic binding
63
+ case user
64
+ in { name: String => username, age: } # 'age' variable created automatically
65
+ puts username # Bound variable available
66
+ end
67
+ ```
68
+
69
+ ### 2. **Structural Matching**
70
+
71
+ ```ruby
72
+ # case/when - only surface comparison
73
+ case data
74
+ when Array
75
+ # Know it's an array, but not its contents
76
+ end
77
+
78
+ # case/in - deep structure matching
79
+ case data
80
+ in [first, *middle, last] if middle.length > 2
81
+ # Automatically destructured with guard condition
82
+ end
83
+ ```
84
+
85
+ ### 3. **Guard Conditions**
86
+
87
+ ```ruby
88
+ # case/when - separate if needed
89
+ case number
90
+ when Integer
91
+ if number > 100
92
+ "big integer"
93
+ else
94
+ "small integer"
95
+ end
96
+ end
97
+
98
+ # case/in - integrated guards
99
+ case number
100
+ in Integer => n if n > 100
101
+ "big integer"
102
+ in Integer
103
+ "small integer"
104
+ end
105
+ ```
106
+
107
+ ## Practical Example for Tryouts
108
+
109
+ For parsing tryout lines, here's the difference:
110
+
111
+ ### Traditional `case/when`
112
+ ```ruby
113
+ def parse_line(line)
114
+ case line
115
+ when /^##\s*(.+)/
116
+ [:description, $1.strip]
117
+ when /^#=>\s*(.+)/
118
+ [:expectation, $1.strip]
119
+ when /^#=\?>\s*(.+)/
120
+ [:debug_info, $1.strip]
121
+ else
122
+ [:code, line]
123
+ end
124
+ end
125
+ ```
126
+
127
+ ### Pattern Matching `case/in`
128
+ ```ruby
129
+ def parse_line(line)
130
+ case line
131
+ in /^##\s*(.+)/ => description
132
+ [:description, description.strip]
133
+ in /^#=>\s*(.+)/ => expectation
134
+ [:expectation, expectation.strip]
135
+ in /^#=\?>\s*(.+)/ => debug_expr
136
+ [:debug_info, debug_expr.strip]
137
+ in /^\s*$/
138
+ [:blank]
139
+ else
140
+ [:code, line]
141
+ end
142
+ end
143
+ ```
144
+
145
+ ## When to Use Which
146
+
147
+ ### Use `case/when` for:
148
+ - Simple value comparisons
149
+ - Class/type checking
150
+ - Range matching
151
+ - Traditional switch-like logic
152
+
153
+ ### Use `case/in` for:
154
+ - Complex data structure matching
155
+ - When you need variable binding
156
+ - Guard conditions
157
+ - Destructuring arrays/hashes
158
+ - Multiple conditions per branch
159
+
160
+ ## Ruby 3.4+ Enhancements
161
+
162
+ Ruby 3.4 added several pattern matching improvements:
163
+
164
+ ```ruby
165
+ # Variable binding in array patterns
166
+ case data
167
+ in [String => first, *String => middle, String => last]
168
+ # All string array with bound variables
169
+ end
170
+
171
+ # Hash patterns with rest
172
+ case config
173
+ in { required: true, **rest } if rest.keys.all? { |k| k.is_a?(Symbol) }
174
+ # Required config with symbol keys only
175
+ end
176
+ ```
177
+
178
+ For the Tryouts modernization, `case/in` provides cleaner syntax for parsing complex comment patterns while binding the captured content directly to variables, eliminating the need for global match variables like `$1`.
@@ -6,11 +6,89 @@ require_relative 'shared_methods'
6
6
  require_relative '../parser_warning'
7
7
 
8
8
  class Tryouts
9
- # Fixed PrismParser with pattern matching for robust token filtering
10
9
  module Parsers
10
+ # Base class for all tryout parsers providing common functionality
11
+ #
12
+ # BaseParser establishes the foundation for parsing tryout files by handling
13
+ # file loading, Prism integration, and providing shared parsing infrastructure.
14
+ # All concrete parser implementations (EnhancedParser, LegacyParser) inherit
15
+ # from this class.
16
+ #
17
+ # @abstract Subclass and implement {#parse} to create a concrete parser
18
+ # @example Implementing a custom parser
19
+ # class MyCustomParser < Tryouts::Parsers::BaseParser
20
+ # def parse
21
+ # # Your parsing logic here
22
+ # # Must return a Tryouts::Testrun object
23
+ # end
24
+ #
25
+ # private
26
+ #
27
+ # def parser_type
28
+ # :custom
29
+ # end
30
+ # end
31
+ #
32
+ # @!attribute [r] source_path
33
+ # @return [String] Path to the source file being parsed
34
+ # @!attribute [r] source
35
+ # @return [String] Raw source code content
36
+ # @!attribute [r] lines
37
+ # @return [Array<String>] Source lines with line endings removed
38
+ # @!attribute [r] prism_result
39
+ # @return [Prism::ParseResult] Result of parsing source with Prism
40
+ # @!attribute [r] parsed_at
41
+ # @return [Time] Timestamp when parsing was initiated
42
+ # @!attribute [r] options
43
+ # @return [Hash] Parser configuration options
44
+ # @!attribute [r] warnings
45
+ # @return [Array<Tryouts::ParserWarning>] Collection of parsing warnings
46
+ #
47
+ # ## Shared Functionality
48
+ #
49
+ # ### 1. File and Source Management
50
+ # - Automatic file reading and line splitting
51
+ # - UTF-8 encoding handling
52
+ # - Path normalization and validation
53
+ #
54
+ # ### 2. Prism Integration
55
+ # - Automatic Prism parsing of source code
56
+ # - Syntax error detection and handling
57
+ # - AST access for advanced parsing needs
58
+ #
59
+ # ### 3. Warning System
60
+ # - Centralized warning collection and management
61
+ # - Type-safe warning objects with context
62
+ # - Integration with output formatters
63
+ #
64
+ # ### 4. Shared Methods
65
+ # - Token grouping and classification logic
66
+ # - Test case boundary detection
67
+ # - Common utility methods for all parsers
68
+ #
69
+ # ## Parser Requirements
70
+ #
71
+ # Concrete parser implementations must:
72
+ # 1. Implement the abstract `parse` method
73
+ # 2. Return a `Tryouts::Testrun` object
74
+ # 3. Handle syntax errors appropriately
75
+ # 4. Provide a unique `parser_type` identifier
76
+ #
77
+ # @see EnhancedParser For Prism-based comment extraction
78
+ # @see LegacyParser For line-by-line parsing approach
79
+ # @see SharedMethods For common parsing utilities
80
+ # @since 3.0.0
11
81
  class BaseParser
12
82
  include Tryouts::Parsers::SharedMethods
13
83
 
84
+ # Initialize a new parser instance
85
+ #
86
+ # @param source_path [String] Absolute path to the tryout source file
87
+ # @param options [Hash] Configuration options for parsing behavior
88
+ # @option options [Boolean] :strict Enable strict mode validation
89
+ # @option options [Boolean] :warnings Enable warning collection (default: true)
90
+ # @raise [Errno::ENOENT] If source file doesn't exist
91
+ # @raise [Errno::EACCES] If source file isn't readable
14
92
  def initialize(source_path, options = {})
15
93
  @source_path = source_path
16
94
  @source = File.read(source_path)
@@ -21,6 +99,28 @@ class Tryouts
21
99
  @warnings = []
22
100
  end
23
101
 
102
+ # Parse the source file into structured test data
103
+ #
104
+ # @abstract Subclasses must implement this method
105
+ # @return [Tryouts::Testrun] Parsed test structure with setup, tests, teardown, and warnings
106
+ # @raise [NotImplementedError] If called directly on BaseParser
107
+ def parse
108
+ raise NotImplementedError, "Subclasses must implement #parse"
109
+ end
110
+
111
+ protected
112
+
113
+ # Get the parser type identifier
114
+ #
115
+ # @abstract Subclasses should override to provide unique identifier
116
+ # @return [Symbol] Parser type identifier
117
+ def parser_type
118
+ :base
119
+ end
120
+
121
+ # Access to instance variables for subclasses
122
+ attr_reader :source_path, :source, :lines, :prism_result, :parsed_at, :options
123
+
24
124
  end
25
125
  end
26
126
  end
@@ -1,16 +1,74 @@
1
1
  # lib/tryouts/parsers/enhanced_parser.rb
2
2
 
3
- # Enhanced parser using Prism's inhouse comment extraction capabilities
4
- # Drop-in replacement for PrismParser that eliminates HEREDOC parsing issues
5
-
6
3
  require_relative '../test_case'
7
4
  require_relative 'base_parser'
8
5
 
9
6
  class Tryouts
10
- # Enhanced parser that replaces manual line-by-line parsing with inhouse Prism APIs
11
- # while maintaining full compatibility with the original parser's logic structure
7
+ # Enhanced parser using Prism's native comment extraction for robust parsing
8
+ #
9
+ # The EnhancedParser is the default parser that provides syntax-aware comment
10
+ # extraction by leveraging Ruby's official Prism parser. This approach eliminates
11
+ # common parsing issues found in regex-based parsers, particularly with complex
12
+ # Ruby syntax.
13
+ #
14
+ # @example Basic usage
15
+ # parser = Tryouts::EnhancedParser.new(source_code, file_path)
16
+ # testrun = parser.parse
17
+ # puts testrun.test_cases.length
18
+ #
19
+ # @example Problematic code that EnhancedParser handles correctly
20
+ # source = <<~RUBY
21
+ # ## Test HEREDOC handling
22
+ # sql = <<~SQL
23
+ # SELECT * FROM users
24
+ # -- This is NOT a tryout comment
25
+ # #=> This is NOT a tryout expectation
26
+ # SQL
27
+ # puts sql.length
28
+ # #=> Integer # This IS a real expectation
29
+ # RUBY
30
+ #
31
+ # @!attribute [r] parser_type
32
+ # @return [Symbol] Returns :enhanced to identify parser type
33
+ #
34
+ # ## Key Benefits over LegacyParser
35
+ #
36
+ # ### 1. HEREDOC Safety
37
+ # - Uses Prism's `parse_comments()` to extract only actual Ruby comments
38
+ # - Automatically excludes content inside string literals, HEREDOCs, and interpolation
39
+ # - Prevents false positive expectation detection
40
+ #
41
+ # ### 2. Inline Comment Handling
42
+ # - Correctly handles lines with both code and comments
43
+ # - Supports multiple comments per line with proper positioning
44
+ # - Emits separate tokens for code and comment content
45
+ #
46
+ # ### 3. Syntax Awareness
47
+ # - Leverages Ruby's official parser for accurate code understanding
48
+ # - Handles complex Ruby syntax edge cases reliably
49
+ # - More robust than regex-based parsing approaches
50
+ #
51
+ # ### 4. Performance
52
+ # - Uses optimized C-based Prism parsing for comment extraction
53
+ # - Efficient handling of large files with complex syntax
54
+ #
55
+ # ## Pattern Matching
56
+ # Uses Ruby 3.4+ pattern matching (`case/in`) for token classification,
57
+ # providing clean, modern syntax for expectation type detection.
58
+ #
59
+ # @see LegacyParser For simpler regex-based parsing (legacy compatibility)
60
+ # @see BaseParser For shared parsing functionality
61
+ # @since 3.2.0
12
62
  class EnhancedParser < Tryouts::Parsers::BaseParser
13
63
 
64
+ # Parse source code into a Testrun using Prism-based comment extraction
65
+ #
66
+ # This method provides the main parsing logic that converts raw Ruby source
67
+ # code containing tryout syntax into structured test cases. Uses Prism's
68
+ # native comment extraction to avoid HEREDOC parsing issues.
69
+ #
70
+ # @return [Tryouts::Testrun] Structured test data with setup, test cases, teardown, and warnings
71
+ # @raise [Tryouts::TryoutSyntaxError] If source contains syntax errors or strict mode violations
14
72
  def parse
15
73
  return handle_syntax_errors if @prism_result.failure?
16
74
 
@@ -25,7 +83,20 @@ class Tryouts
25
83
 
26
84
  private
27
85
 
28
- # Inhouse comment extraction - replaces the manual regex parsing
86
+ # Extract and tokenize comments using Prism's native comment extraction
87
+ #
88
+ # This method replaces manual line-by-line regex parsing with Prism's
89
+ # built-in comment extraction capabilities. The key benefit is that
90
+ # `Prism.parse_comments()` only returns actual Ruby comments, automatically
91
+ # excluding content inside string literals, HEREDOCs, and interpolations.
92
+ #
93
+ # @return [Array<Hash>] Array of token hashes with keys :type, :content, :line, etc.
94
+ # @example Token structure
95
+ # [
96
+ # { type: :description, content: "Test case description", line: 5 },
97
+ # { type: :code, content: "result = calculate(x)", line: 6 },
98
+ # { type: :expectation, content: "42", line: 7, ast: <Prism::Node> }
99
+ # ]
29
100
  def tokenize_content_with_inhouse_extraction
30
101
  tokens = []
31
102
 
@@ -47,8 +118,8 @@ class Tryouts
47
118
  tokens << { type: :code, content: line, line: index, ast: parse_ruby_line(line) }
48
119
  emitted_code = true
49
120
  end
50
- # Inline comment may carry expectations; classify it too
51
- tokens << classify_comment_inhousely(comment_content, line_number)
121
+ # Inline comment (after code) - treat as regular comment, not expectation
122
+ tokens << { type: :comment, content: comment_content.sub(/^#\s*/, ''), line: line_number - 1 }
52
123
  else
53
124
  tokens << classify_comment_inhousely(comment_content, line_number)
54
125
  end
@@ -69,44 +140,125 @@ class Tryouts
69
140
  tokens
70
141
  end
71
142
 
72
- # Inhouse comment classification - replaces complex regex patterns
143
+ # Classify comment content into specific token types using pattern matching
144
+ #
145
+ # Takes a raw comment string and determines what type of tryout token it
146
+ # represents (description, expectation, etc.). Uses Ruby 3.4+ pattern matching
147
+ # for clean, maintainable classification logic.
148
+ #
149
+ # @param content [String] The comment content (including # prefix)
150
+ # @param line_number [Integer] 1-based line number for error reporting
151
+ # @return [Hash] Token hash with :type, :content, :line and other type-specific keys
152
+ #
153
+ # @example Valid expectation
154
+ # classify_comment_inhousely("#=> 42", 10)
155
+ # # => { type: :expectation, content: "42", line: 9, ast: <Prism::Node> }
156
+ #
157
+ # @example Malformed expectation (triggers warning)
158
+ # classify_comment_inhousely("#=INVALID> 42", 10)
159
+ # # => { type: :malformed_expectation, syntax: "INVALID", content: "42", line: 9 }
160
+ # # Also adds warning to parser's warning collection
161
+ #
162
+ # @example Test description
163
+ # classify_comment_inhousely("## Test basic math", 5)
164
+ # # => { type: :description, content: "Test basic math", line: 4 }
73
165
  def classify_comment_inhousely(content, line_number)
74
166
  case content
75
- when /^##\s*(.*)$/
167
+ in /^##\s*(.*)$/ # Test description format: ## description
76
168
  { type: :description, content: $1.strip, line: line_number - 1 }
77
- when /^#\s*TEST\s*\d*:\s*(.*)$/
169
+ in /^#\s*TEST\s*\d*:\s*(.*)$/ # rubocop:disable Lint/DuplicateBranch
78
170
  { type: :description, content: $1.strip, line: line_number - 1 }
79
- when /^#\s*=!>\s*(.*)$/
171
+ in /^#\s*=!>\s*(.*)$/ # Exception expectation
80
172
  { type: :exception_expectation, content: $1.strip, line: line_number - 1, ast: parse_expectation($1.strip) }
81
- when /^#\s*=<>\s*(.*)$/
173
+ in /^#\s*=<>\s*(.*)$/ # Intentional failure expectation
82
174
  { type: :intentional_failure_expectation, content: $1.strip, line: line_number - 1, ast: parse_expectation($1.strip) }
83
- when /^#\s*==>\s*(.*)$/
175
+ in /^#\s*==>\s*(.*)$/ # Boolean true expectation
84
176
  { type: :true_expectation, content: $1.strip, line: line_number - 1, ast: parse_expectation($1.strip) }
85
- when %r{^#\s*=/=>\s*(.*)$}
177
+ in %r{^#\s*=/=>\s*(.*)$} # Boolean false expectation
86
178
  { type: :false_expectation, content: $1.strip, line: line_number - 1, ast: parse_expectation($1.strip) }
87
- when /^#\s*=\|>\s*(.*)$/
179
+ in /^#\s*=\|>\s*(.*)$/ # Boolean (true or false) expectation
88
180
  { type: :boolean_expectation, content: $1.strip, line: line_number - 1, ast: parse_expectation($1.strip) }
89
- when /^#\s*=\*>\s*(.*)$/
181
+ in /^#\s*=\*>\s*(.*)$/ # Non-nil expectation
90
182
  { type: :non_nil_expectation, content: $1.strip, line: line_number - 1 }
91
- when /^#\s*=:>\s*(.*)$/
183
+ in /^#\s*=:>\s*(.*)$/ # Result type expectation
92
184
  { type: :result_type_expectation, content: $1.strip, line: line_number - 1, ast: parse_expectation($1.strip) }
93
- when /^#\s*=~>\s*(.*)$/
185
+ in /^#\s*=~>\s*(.*)$/ # Regex match expectation
94
186
  { type: :regex_match_expectation, content: $1.strip, line: line_number - 1, ast: parse_expectation($1.strip) }
95
- when /^#\s*=%>\s*(.*)$/
187
+ in /^#\s*=%>\s*(.*)$/ # Performance time expectation
96
188
  { type: :performance_time_expectation, content: $1.strip, line: line_number - 1, ast: parse_expectation($1.strip) }
97
- when /^#\s*=(\d+)>\s*(.*)$/
189
+ in /^#\s*=(\d+)>\s*(.*)$/ # Output expectation (stdout/stderr with pipe number)
98
190
  { type: :output_expectation, content: $2.strip, pipe: $1.to_i, line: line_number - 1, ast: parse_expectation($2.strip) }
99
- when /^#\s*=>\s*(.*)$/
191
+ in /^#\s*=>\s*(.*)$/ # Regular expectation
100
192
  { type: :expectation, content: $1.strip, line: line_number - 1, ast: parse_expectation($1.strip) }
101
- when /^##\s*=>\s*(.*)$/
193
+ in /^#\s*=([^>=:!~%*|\/\s]+)>\s*(.*)$/ # Malformed expectation - invalid characters between = and >
194
+ syntax = $1
195
+ content_part = $2.strip
196
+ add_warning(ParserWarning.malformed_expectation(
197
+ line_number: line_number,
198
+ syntax: syntax,
199
+ context: content.strip
200
+ ))
201
+ { type: :malformed_expectation, syntax: syntax, content: content_part, line: line_number - 1 }
202
+ in /^##\s*=>\s*(.*)$/ # Commented out expectation (should be ignored)
102
203
  { type: :comment, content: '=>' + $1.strip, line: line_number - 1 }
103
- when /^#\s*(.*)$/
204
+ in content if looks_like_malformed_expectation?(content) # Comprehensive malformed expectation detection
205
+ detected_syntax = extract_malformed_syntax(content)
206
+ add_warning(ParserWarning.malformed_expectation(
207
+ line_number: line_number,
208
+ syntax: detected_syntax,
209
+ context: content.strip
210
+ ))
211
+ { type: :malformed_expectation, syntax: detected_syntax, content: content.strip, line: line_number - 1 }
212
+ in /^#\s*(.*)$/ # Single hash comment - potential description
104
213
  { type: :potential_description, content: $1.strip, line: line_number - 1 }
105
- else
214
+ else # Unknown comment format
106
215
  { type: :comment, content: content.sub(/^#\s*/, ''), line: line_number - 1 }
107
216
  end
108
217
  end
109
218
 
219
+ # Detect if a comment looks like a malformed expectation attempt
220
+ # This catches patterns that suggest someone tried to write an expectation
221
+ # but got the syntax wrong (missing parts, wrong spacing, extra characters, etc.)
222
+ #
223
+ # Only flags as malformed if it starts with patterns that look like expectation syntax,
224
+ # not just any comment that happens to contain equals signs in natural language.
225
+ def looks_like_malformed_expectation?(content)
226
+ # Skip if it's already handled by specific patterns above
227
+ return false if content.match?(/^##\s*/) # Description
228
+ return false if content.match?(/^#\s*TEST\s*\d*:\s*/) # TEST format
229
+ return false if content.match?(/^##\s*=>\s*/) # Commented out expectation
230
+
231
+ # Only flag as malformed if it looks like an expectation attempt at the start
232
+ # Patterns that suggest malformed expectation syntax:
233
+ # - Starts with #= but doesn't match valid patterns
234
+ # - Has multiple = or > characters in suspicious positions near the start
235
+ # - Looks like broken expectation syntax (not natural language)
236
+
237
+ return true if content.match?(/^#\s*=\s*>/) # "# = >" (spaces in wrong places)
238
+ return true if content.match?(/^#\s*==+>/) # "# ==> " (wrong number of =)
239
+ return true if content.match?(/^#\s*=[^=:!~%*|\/>\s][^>]*>/) # "# =X> " (invalid chars between = and >)
240
+ return true if content.match?(/^#\s*>[^=]/) # "# >something" (starts with >)
241
+ return true if content.match?(/^#\s*<[^=]/) # "# <something" (starts with <)
242
+
243
+ false # Regular comments with = signs in natural language are OK
244
+ end
245
+
246
+ # Extract the malformed syntax portion for warning display
247
+ def extract_malformed_syntax(content)
248
+ # Try to identify what the user was attempting to write
249
+ case content
250
+ when /^#\s*([=><][^=><]*[=><].*?)(\s|$)/ # Pattern with expectation chars
251
+ $1.strip
252
+ when /^#\s*([=><].*?)(\s|$)/ # Simple pattern starting with expectation char
253
+ $1.strip
254
+ when /^#\s*(.*?[=><].*?)(\s|$)/ # Pattern containing expectation chars
255
+ $1.strip
256
+ else
257
+ # Fallback: show the part after #
258
+ content.sub(/^#\s*/, '').split(/\s/).first || 'unknown'
259
+ end
260
+ end
261
+
110
262
  # Parser type identification for metadata
111
263
  def parser_type
112
264
  :enhanced