png_conform 0.1.2 → 0.1.3

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.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +116 -6
  3. data/Gemfile +1 -1
  4. data/config/validation_profiles.yml +105 -0
  5. data/lib/png_conform/analyzers/comparison_analyzer.rb +41 -7
  6. data/lib/png_conform/analyzers/metrics_analyzer.rb +6 -9
  7. data/lib/png_conform/analyzers/optimization_analyzer.rb +30 -24
  8. data/lib/png_conform/analyzers/resolution_analyzer.rb +31 -32
  9. data/lib/png_conform/cli.rb +12 -0
  10. data/lib/png_conform/commands/check_command.rb +118 -53
  11. data/lib/png_conform/configuration.rb +147 -0
  12. data/lib/png_conform/container.rb +113 -0
  13. data/lib/png_conform/models/validation_result.rb +30 -4
  14. data/lib/png_conform/pipelines/pipeline_result.rb +39 -0
  15. data/lib/png_conform/pipelines/stages/analysis_stage.rb +35 -0
  16. data/lib/png_conform/pipelines/stages/base_stage.rb +23 -0
  17. data/lib/png_conform/pipelines/stages/chunk_validation_stage.rb +74 -0
  18. data/lib/png_conform/pipelines/stages/sequence_validation_stage.rb +77 -0
  19. data/lib/png_conform/pipelines/stages/signature_validation_stage.rb +41 -0
  20. data/lib/png_conform/pipelines/validation_pipeline.rb +90 -0
  21. data/lib/png_conform/readers/full_load_reader.rb +13 -4
  22. data/lib/png_conform/readers/streaming_reader.rb +27 -2
  23. data/lib/png_conform/reporters/color_reporter.rb +17 -14
  24. data/lib/png_conform/reporters/visual_elements.rb +22 -16
  25. data/lib/png_conform/services/analysis_manager.rb +120 -0
  26. data/lib/png_conform/services/chunk_processor.rb +195 -0
  27. data/lib/png_conform/services/file_signature.rb +226 -0
  28. data/lib/png_conform/services/file_strategy.rb +78 -0
  29. data/lib/png_conform/services/lru_cache.rb +170 -0
  30. data/lib/png_conform/services/parallel_validator.rb +118 -0
  31. data/lib/png_conform/services/profile_manager.rb +41 -12
  32. data/lib/png_conform/services/result_builder.rb +299 -0
  33. data/lib/png_conform/services/validation_cache.rb +210 -0
  34. data/lib/png_conform/services/validation_orchestrator.rb +188 -0
  35. data/lib/png_conform/services/validation_service.rb +53 -337
  36. data/lib/png_conform/services/validator_pool.rb +142 -0
  37. data/lib/png_conform/utils/colorizer.rb +149 -0
  38. data/lib/png_conform/validators/chunk_registry.rb +12 -0
  39. data/lib/png_conform/validators/streaming_idat_validator.rb +123 -0
  40. data/lib/png_conform/version.rb +1 -1
  41. data/png_conform.gemspec +1 -0
  42. metadata +38 -2
@@ -1,43 +1,26 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "../validators/chunk_registry"
4
- require_relative "../models/validation_result"
5
- require_relative "../models/file_analysis"
6
- require_relative "../models/image_info"
7
- require_relative "../models/compression_info"
8
- # Lazy load analyzers - only require when needed (Phase 2: Lazy Loading)
9
- # require_relative "../analyzers/resolution_analyzer"
10
- # require_relative "../analyzers/optimization_analyzer"
11
- # require_relative "../analyzers/metrics_analyzer"
3
+ require_relative "validation_orchestrator"
12
4
 
13
5
  module PngConform
14
6
  module Services
15
7
  # Main validation orchestration service
16
8
  #
17
- # This service coordinates the validation of PNG files by:
18
- # 1. Reading chunks from the file
19
- # 2. Creating appropriate validators for each chunk
20
- # 3. Executing validation in the correct order
21
- # 4. Collecting and aggregating results
22
- #
23
- # The service follows a pipeline architecture:
24
- # File → Chunks → Validators → Results
9
+ # This service coordinates the validation of PNG files by delegating
10
+ # all operations to the ValidationOrchestrator class.
25
11
  #
26
12
  class ValidationService
27
- attr_reader :reader, :context, :results, :chunks
28
-
29
13
  # Convenience method to validate a file by path
30
14
  #
31
15
  # @param filepath [String] Path to PNG file
32
16
  # @param options [Hash] Optional CLI options
33
- # @return [ValidationResult] Validation results
17
+ # @return [FileAnalysis] Validation results
34
18
  def self.validate_file(filepath, options = {})
35
- require_relative "../readers/full_load_reader"
36
- reader = Readers::FullLoadReader.new(filepath)
37
- service = new(reader, filepath, options)
38
- service.validate
19
+ ValidationOrchestrator.validate_file(filepath, options)
39
20
  end
40
21
 
22
+ attr_reader :reader, :context, :results, :chunks
23
+
41
24
  # Initialize validation service
42
25
  #
43
26
  # @param reader [Object] File reader (StreamingReader or FullLoadReader)
@@ -47,282 +30,91 @@ module PngConform
47
30
  @reader = reader
48
31
  @filepath = filepath
49
32
  @options = options
50
- @context = Validators::ValidationContext.new
51
- @results = []
52
- @chunks = [] # Store chunks as we read them
33
+ @orchestrator = ValidationOrchestrator.new(reader, filepath, options)
34
+ @context = @orchestrator.context
35
+ @chunks = []
53
36
  end
54
37
 
55
38
  # Validate the PNG file
56
39
  #
57
- # This is the main entry point for validation. It processes all chunks
58
- # in order, validates them, and collects the results.
59
- #
60
40
  # @return [FileAnalysis] Complete file analysis with all data
61
41
  def validate
62
- validate_signature
63
- validate_chunks
64
- validate_chunk_sequence
65
- build_file_analysis
42
+ file_analysis = @orchestrator.validate
43
+ @chunks = @orchestrator.chunks
44
+ file_analysis
66
45
  end
67
46
 
68
47
  # Validate PNG signature
69
- #
70
- # Checks that the file starts with the PNG signature:
71
- # 137 80 78 71 13 10 26 10
72
- #
73
- # @return [void]
74
- def validate_signature
75
- sig = reader.signature
76
- expected = [137, 80, 78, 71, 13, 10, 26, 10].pack("C*")
77
-
78
- return if sig == expected
79
-
80
- add_error("Invalid PNG signature")
81
- end
48
+ def validate_signature; end
82
49
 
83
50
  # Validate all chunks in the file
84
- #
85
- # Processes each chunk in order:
86
- # 1. Check for validator
87
- # 2. Create validator instance
88
- # 3. Execute validation
89
- # 4. Collect results
90
- #
91
- # @return [void]
92
- def validate_chunks
93
- reader.each_chunk do |chunk|
94
- @chunks << chunk # Store chunk for later use
95
- validate_chunk(chunk)
96
- end
97
- end
51
+ def validate_chunks; end
98
52
 
99
53
  # Validate a single chunk
100
54
  #
101
55
  # @param chunk [Object] Chunk to validate
102
- # @return [void]
103
- def validate_chunk(chunk)
104
- # Get validator for this chunk type
105
- validator = Validators::ChunkRegistry.create_validator(chunk, context)
106
-
107
- if validator
108
- # Validate chunk with registered validator
109
- validator.validate
110
- # Errors are stored in context, not validator
111
- else
112
- # Unknown chunk - check if it's safe to ignore
113
- handle_unknown_chunk(chunk)
114
- end
115
-
116
- # Mark chunk as seen AFTER validation
117
- # This allows validators to check for duplicates before marking
118
- # Convert BinData::String to regular String for hash key consistency
119
- context.mark_chunk_seen(chunk.chunk_type.to_s, chunk)
120
- end
56
+ def validate_chunk(chunk); end
121
57
 
122
58
  # Handle unknown chunk types
123
59
  #
124
- # Unknown chunks are checked for safety:
125
- # - If ancillary (bit 5 of first byte = 1), it's safe to ignore
126
- # - If critical (bit 5 = 0), it's an error
127
- #
128
60
  # @param chunk [Object] Unknown chunk
129
- # @return [void]
130
- def handle_unknown_chunk(chunk)
131
- # Convert BinData::String to regular String
132
- chunk_type = chunk.chunk_type.to_s
133
- first_byte = chunk_type.bytes[0]
134
-
135
- # Bit 5 (0x20) of first byte indicates ancillary vs critical
136
- if (first_byte & 0x20).zero?
137
- # Critical chunk - must be recognized
138
- add_error("Unknown critical chunk type: #{chunk_type}")
139
- else
140
- # Ancillary chunk - safe to ignore
141
- add_info("Unknown ancillary chunk type: #{chunk_type} (ignored)")
142
- end
143
- end
61
+ def handle_unknown_chunk(chunk); end
144
62
 
145
63
  # Validate chunk sequence requirements
146
- #
147
- # Checks high-level sequencing rules:
148
- # - IHDR must be first chunk
149
- # - IEND must be last chunk
150
- # - At least one IDAT chunk required
151
- #
152
- # @return [void]
153
- def validate_chunk_sequence
154
- validate_ihdr_first
155
- validate_iend_last
156
- validate_idat_present
157
- end
64
+ def validate_chunk_sequence; end
158
65
 
159
66
  # Check that IHDR is the first chunk
160
- #
161
- # @return [void]
162
- def validate_ihdr_first
163
- return if context.seen?("IHDR")
164
-
165
- add_error("Missing IHDR chunk (must be first)")
166
- end
67
+ def validate_ihdr_first; end
167
68
 
168
69
  # Check that IEND is the last chunk
169
- #
170
- # @return [void]
171
- def validate_iend_last
172
- return if context.seen?("IEND")
173
-
174
- add_error("Missing IEND chunk (must be last)")
175
- end
70
+ def validate_iend_last; end
176
71
 
177
72
  # Check that at least one IDAT chunk exists
178
- #
179
- # @return [void]
180
- def validate_idat_present
181
- return if context.seen?("IDAT")
182
-
183
- add_error("Missing IDAT chunk (at least one required)")
184
- end
73
+ def validate_idat_present; end
185
74
 
186
75
  # Build complete FileAnalysis with validation results and analyzer data
187
76
  #
188
- # Proper Model → Formatter pattern
189
- # - Builds ValidationResult (legacy)
190
- # - Extracts ImageInfo and CompressionInfo
191
- # - Runs analyzers conditionally (Phase 1.1 optimization)
192
- # - Returns complete FileAnalysis model
193
- #
194
77
  # @return [FileAnalysis] Complete analysis model
195
78
  def build_file_analysis
196
- # First build the ValidationResult (legacy structure)
197
- validation_result = build_validation_result
198
-
199
- # Extract image info from IHDR
200
- image_info = extract_image_info(validation_result)
201
-
202
- # Build complete FileAnalysis
203
- Models::FileAnalysis.new.tap do |analysis|
204
- analysis.file_path = @filepath || "unknown"
205
- analysis.file_size = validation_result.file_size
206
- analysis.file_type = validation_result.file_type
207
- analysis.validation_result = validation_result
208
- # chunks delegated to validation_result (no need to set)
209
- analysis.image_info = image_info
210
- analysis.compression_info = extract_compression_info(validation_result)
211
-
212
- # Run analyzers conditionally (Phase 1.1: saves ~80ms)
213
- if need_resolution_analysis?
214
- analysis.resolution_analysis = run_resolution_analysis(validation_result)
215
- end
216
-
217
- if need_optimization_analysis?
218
- analysis.optimization_analysis = run_optimization_analysis(validation_result)
219
- end
220
-
221
- if need_metrics_analysis?
222
- analysis.metrics = run_metrics_analysis(validation_result)
223
- end
224
- end
79
+ @orchestrator.validate
225
80
  end
226
81
 
227
- # Build ValidationResult (original method renamed)
228
- def build_validation_result
229
- Models::ValidationResult.new.tap do |result|
230
- # Set file metadata
231
- result.filename = @filepath || "unknown"
232
-
233
- result.file_type = determine_file_type
234
-
235
- # Calculate file size from chunks if reader doesn't provide it
236
- result.file_size = if reader.respond_to?(:file_size)
237
- reader.file_size
238
- else
239
- # 8 bytes signature + sum of chunk sizes (8 byte header + data + 4 byte CRC per chunk)
240
- 8 + @chunks.sum { |c| 12 + c.length }
241
- end
242
-
243
- # Add all chunks with CRC validation
244
- crc_error_count = 0
245
- @chunks.each do |bindata_chunk|
246
- chunk = Models::Chunk.from_bindata(bindata_chunk,
247
- bindata_chunk.abs_offset)
248
-
249
- # Validate CRC
250
- expected_crc = bindata_chunk.crc
251
- actual_crc = calculate_crc(bindata_chunk)
252
- chunk.crc_expected = format_hex(expected_crc)
253
- chunk.crc_actual = format_hex(actual_crc)
254
- chunk.valid_crc = (expected_crc == actual_crc)
255
-
256
- crc_error_count += 1 unless chunk.valid_crc
257
-
258
- result.add_chunk(chunk)
259
- end
260
-
261
- result.crc_errors_count = crc_error_count
262
-
263
- # Calculate compression ratio for PNG files (Phase 1.2: lazy calculation)
264
- result.compression_ratio = if result.file_type == "PNG" && need_compression_ratio?
265
- calculate_compression_ratio(result.chunks)
266
- else
267
- 0.0
268
- end
269
-
270
- # Add errors from service (@results)
271
- @results.select { |r| r[:type] == :error }.each do |r|
272
- result.error(r[:message])
273
- end
274
-
275
- # Add errors from validators (context)
276
- context.all_errors.each do |e|
277
- result.error(e[:message])
278
- end
279
-
280
- # Add warnings from service (@results)
281
- @results.select { |r| r[:type] == :warning }.each do |r|
282
- result.warning(r[:message])
283
- end
284
-
285
- # Add warnings from validators (context)
286
- context.all_warnings.each do |w|
287
- result.warning(w[:message])
288
- end
289
-
290
- # Add info from service (@results)
291
- @results.select { |r| r[:type] == :info }.each do |r|
292
- result.info(r[:message])
293
- end
294
-
295
- # Add info from validators (context)
296
- context.all_info.each do |i|
297
- result.info(i[:message])
298
- end
299
- end
300
- end
82
+ # Build ValidationResult
83
+ def build_validation_result; end
301
84
 
302
85
  private
303
86
 
304
87
  # Add an error to results
305
88
  #
306
89
  # @param message [String] Error message
307
- # @return [void]
308
90
  def add_error(message)
309
- @results << { type: :error, message: message }
91
+ @context.add_error(
92
+ chunk_type: nil,
93
+ message: message,
94
+ severity: :error,
95
+ )
310
96
  end
311
97
 
312
98
  # Add a warning to results
313
99
  #
314
100
  # @param message [String] Warning message
315
- # @return [void]
316
101
  def add_warning(message)
317
- @results << { type: :warning, message: message }
102
+ @context.add_error(
103
+ chunk_type: nil,
104
+ message: message,
105
+ severity: :warning,
106
+ )
318
107
  end
319
108
 
320
109
  # Add info to results
321
110
  #
322
111
  # @param message [String] Info message
323
- # @return [void]
324
112
  def add_info(message)
325
- @results << { type: :info, message: message }
113
+ @context.add_error(
114
+ chunk_type: nil,
115
+ message: message,
116
+ severity: :info,
117
+ )
326
118
  end
327
119
 
328
120
  # Merge results from a validator
@@ -330,7 +122,6 @@ module PngConform
330
122
  # @param errors [Array<String>] Error messages
331
123
  # @param warnings [Array<String>] Warning messages
332
124
  # @param info [Array<String>] Info messages
333
- # @return [void]
334
125
  def merge_results(errors, warnings, info)
335
126
  errors.each { |msg| add_error(msg) }
336
127
  warnings.each { |msg| add_warning(msg) }
@@ -340,13 +131,7 @@ module PngConform
340
131
  # Determine file type based on chunks
341
132
  #
342
133
  # @return [String] File type (PNG, MNG, JNG, or UNKNOWN)
343
- def determine_file_type
344
- return Models::ValidationResult::FILE_TYPE_MNG if context.seen?("MHDR")
345
- return Models::ValidationResult::FILE_TYPE_JNG if context.seen?("JHDR")
346
- return Models::ValidationResult::FILE_TYPE_PNG if context.seen?("IHDR")
347
-
348
- Models::ValidationResult::FILE_TYPE_UNKNOWN
349
- end
134
+ def determine_file_type; end
350
135
 
351
136
  # Calculate CRC32 for a chunk
352
137
  #
@@ -354,7 +139,6 @@ module PngConform
354
139
  # @return [Integer] CRC32 value
355
140
  def calculate_crc(chunk)
356
141
  require "zlib"
357
- # CRC is calculated over chunk type + chunk data
358
142
  Zlib.crc32(chunk.chunk_type.to_s + chunk.data.to_s)
359
143
  end
360
144
 
@@ -370,95 +154,27 @@ module PngConform
370
154
  #
371
155
  # @param chunks [Array<Chunk>] All chunks
372
156
  # @return [Float] Compression ratio as percentage, 0.0 if cannot calculate
373
- def calculate_compression_ratio(chunks)
374
- idat_chunks = chunks.select { |c| c.type == "IDAT" }
375
- return 0.0 if idat_chunks.empty?
376
-
377
- compressed_size = idat_chunks.sum(&:length)
378
- return 0.0 if compressed_size.zero?
379
-
380
- # Try to decompress to get original size
381
- # Need to get actual binary data from BinData chunks, not Model chunks
382
- begin
383
- require "zlib"
384
-
385
- # Get IDAT chunks from the original BinData chunks
386
- idat_bindata = @chunks.select { |c| c.chunk_type.to_s == "IDAT" }
387
- compressed_data = idat_bindata.map { |c| c.data.to_s }.join
388
-
389
- decompressed = Zlib::Inflate.inflate(compressed_data)
390
- original_size = decompressed.bytesize
391
-
392
- return 0.0 if original_size.zero?
393
-
394
- # Calculate percentage: (compressed/original - 1) * 100
395
- # Negative means compression, positive means expansion
396
- ((compressed_size.to_f / original_size - 1) * 100).round(1)
397
- rescue StandardError
398
- # If decompression fails, we can't calculate ratio
399
- # Return 0.0 instead of nil so it appears in YAML/JSON
400
- 0.0
401
- end
402
- end
157
+ def calculate_compression_ratio(chunks); end
403
158
 
404
159
  # Extract ImageInfo from IHDR chunk
405
- def extract_image_info(result)
406
- ihdr = result.ihdr_chunk
407
- return nil unless ihdr&.data && ihdr.data.bytesize >= 13
408
-
409
- width = ihdr.data.bytes[0..3].pack("C*").unpack1("N")
410
- height = ihdr.data.bytes[4..7].pack("C*").unpack1("N")
411
- bit_depth = ihdr.data.bytes[8]
412
- color_type = ihdr.data.bytes[9]
413
- interlace = ihdr.data.bytes[12]
414
-
415
- Models::ImageInfo.new.tap do |info|
416
- info.width = width
417
- info.height = height
418
- info.bit_depth = bit_depth
419
- info.color_type = color_type_name(color_type)
420
- info.interlaced = interlace == 1
421
- info.animated = false # Could check for APNG chunks
422
- end
423
- end
160
+ def extract_image_info(result); end
424
161
 
425
162
  # Extract CompressionInfo
426
- def extract_compression_info(result)
427
- return nil unless result.compression_ratio
428
-
429
- Models::CompressionInfo.new.tap do |info|
430
- info.compression_ratio = result.compression_ratio
431
- info.compressed_size = result.chunks.select do |c|
432
- c.type == "IDAT"
433
- end.sum(&:length)
434
- end
435
- end
163
+ def extract_compression_info(result); end
436
164
 
437
165
  # Run resolution analyzer
438
- def run_resolution_analysis(result)
439
- require_relative "../analyzers/resolution_analyzer" unless defined?(Analyzers::ResolutionAnalyzer)
440
- Analyzers::ResolutionAnalyzer.new(result).analyze
441
- rescue StandardError => e
442
- { error: "Resolution analysis failed: #{e.message}" }
443
- end
166
+ def run_resolution_analysis(result); end
444
167
 
445
168
  # Run optimization analyzer
446
- def run_optimization_analysis(result)
447
- require_relative "../analyzers/optimization_analyzer" unless defined?(Analyzers::OptimizationAnalyzer)
448
- Analyzers::OptimizationAnalyzer.new(result).analyze
449
- rescue StandardError => e
450
- { error: "Optimization analysis failed: #{e.message}" }
451
- end
169
+ def run_optimization_analysis(result); end
452
170
 
453
171
  # Run metrics analyzer
454
- def run_metrics_analysis(result)
455
- require_relative "../analyzers/metrics_analyzer" unless defined?(Analyzers::MetricsAnalyzer)
456
- Analyzers::MetricsAnalyzer.new(result).analyze
457
- rescue StandardError => e
458
- { error: "Metrics analysis failed: #{e.message}" }
459
- end
172
+ def run_metrics_analysis(result); end
460
173
 
461
174
  # Helper to convert color type code to name
175
+ #
176
+ # @param code [Integer] Color type code
177
+ # @return [String] Color type name
462
178
  def color_type_name(code)
463
179
  case code
464
180
  when 0 then "grayscale"
@@ -470,28 +186,28 @@ module PngConform
470
186
  end
471
187
  end
472
188
 
473
- # Check if resolution analysis is needed (Phase 1.1)
189
+ # Check if resolution analysis is needed
474
190
  def need_resolution_analysis?
475
191
  return true unless @options[:quiet]
476
192
 
477
193
  @options[:resolution] || @options[:mobile_ready]
478
194
  end
479
195
 
480
- # Check if optimization analysis is needed (Phase 1.1)
196
+ # Check if optimization analysis is needed
481
197
  def need_optimization_analysis?
482
198
  return true unless @options[:quiet]
483
199
 
484
200
  @options[:optimize]
485
201
  end
486
202
 
487
- # Check if metrics analysis is needed (Phase 1.1)
203
+ # Check if metrics analysis is needed
488
204
  def need_metrics_analysis?
489
205
  return true if ["yaml", "json"].include?(@options[:format])
490
206
 
491
207
  @options[:metrics]
492
208
  end
493
209
 
494
- # Check if compression ratio calculation is needed (Phase 1.2)
210
+ # Check if compression ratio calculation is needed
495
211
  def need_compression_ratio?
496
212
  return true if ["yaml", "json"].include?(@options[:format])
497
213
 
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PngConform
4
+ module Services
5
+ # Validator pool for reusing validator instances
6
+ #
7
+ # Reduces object allocation overhead by pooling validator instances
8
+ # across chunk validations. Validators are reset between uses to ensure
9
+ # clean state for each validation.
10
+ #
11
+ # This pool is scoped to a single validation session and should be
12
+ # discarded after validation completes.
13
+ #
14
+ class ValidatorPool
15
+ # Initialize validator pool
16
+ #
17
+ # @param options [Hash] Pool options
18
+ # @option options [Integer] :max_per_type Maximum validators to cache per type
19
+ def initialize(options = {})
20
+ @max_per_type = options.fetch(:max_per_type, 5)
21
+ @pools = {} # chunk_type => [validator instances]
22
+ @stats = {
23
+ created: 0,
24
+ reused: 0,
25
+ resets: 0,
26
+ }
27
+ end
28
+
29
+ # Acquire a validator for the given chunk type
30
+ #
31
+ # Returns a validator from the pool if available, or creates a new one.
32
+ # The validator is reset before being returned.
33
+ #
34
+ # @param chunk_type [String] Chunk type code
35
+ # @param validator_class [Class] Validator class to instantiate
36
+ # @param chunk [Object] Chunk object
37
+ # @param context [ValidationContext] Validation context
38
+ # @return [Object] Validator instance
39
+ def acquire(chunk_type, validator_class, chunk, context)
40
+ pool = pool_for(chunk_type)
41
+
42
+ # Try to get a validator from the pool
43
+ validator = pool.pop
44
+
45
+ if validator
46
+ @stats[:reused] += 1
47
+ reset_validator(validator, chunk, context)
48
+ else
49
+ @stats[:created] += 1
50
+ validator = validator_class.new(chunk, context)
51
+ end
52
+
53
+ validator
54
+ end
55
+
56
+ # Return a validator to the pool
57
+ #
58
+ # @param chunk_type [String] Chunk type code
59
+ # @param validator [Object] Validator instance to return
60
+ # @return [void]
61
+ def release(chunk_type, validator)
62
+ pool = pool_for(chunk_type)
63
+
64
+ # Only pool if under limit
65
+ if pool.size < @max_per_type
66
+ # Clear validator state before returning to pool
67
+ clear_validator(validator)
68
+ pool << validator
69
+ end
70
+ # If pool is full, just let validator be garbage collected
71
+ end
72
+
73
+ # Clear all pools
74
+ #
75
+ # @return [void]
76
+ def clear
77
+ @pools.clear
78
+ reset_stats
79
+ end
80
+
81
+ # Get pool statistics
82
+ #
83
+ # @return [Hash] Statistics about pool usage
84
+ def stats
85
+ pool_sizes = @pools.transform_values(&:size)
86
+ @stats.merge(pool_sizes: pool_sizes,
87
+ total_pooled: pool_sizes.values.sum)
88
+ end
89
+
90
+ # Reset statistics
91
+ #
92
+ # @return [void]
93
+ def reset_stats
94
+ @stats = {
95
+ created: 0,
96
+ reused: 0,
97
+ resets: 0,
98
+ }
99
+ end
100
+
101
+ private
102
+
103
+ # Get or create pool for a chunk type
104
+ #
105
+ # @param chunk_type [String] Chunk type code
106
+ # @return [Array] Pool array for this chunk type
107
+ def pool_for(chunk_type)
108
+ @pools[chunk_type] ||= []
109
+ end
110
+
111
+ # Reset validator for reuse with new chunk and context
112
+ #
113
+ # @param validator [Object] Validator instance
114
+ # @param chunk [Object] New chunk object
115
+ # @param context [ValidationContext] New validation context
116
+ # @return [void]
117
+ def reset_validator(validator, chunk, context)
118
+ @stats[:resets] += 1
119
+
120
+ # Reset the validator's chunk and context
121
+ validator.instance_variable_set(:@chunk, chunk)
122
+ validator.instance_variable_set(:@context, context)
123
+
124
+ # Clear any cached results/errors
125
+ clear_validator(validator)
126
+ end
127
+
128
+ # Clear validator state
129
+ #
130
+ # @param validator [Object] Validator instance
131
+ # @return [void]
132
+ def clear_validator(validator)
133
+ # Clear common instance variables that validators might cache
134
+ validator.remove_instance_variable(:@errors) if validator.instance_variable_defined?(:@errors)
135
+ validator.remove_instance_variable(:@warnings) if validator.instance_variable_defined?(:@warnings)
136
+ validator.remove_instance_variable(:@info) if validator.instance_variable_defined?(:@info)
137
+ rescue NameError
138
+ # Variable doesn't exist, that's fine
139
+ end
140
+ end
141
+ end
142
+ end