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