coradoc 2.0.1 → 2.0.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.
Files changed (54) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +55 -167
  3. data/coradoc-adoc/lib/coradoc/asciidoc/model/base.rb +4 -3
  4. data/coradoc-adoc/lib/coradoc/asciidoc/model/document.rb +1 -1
  5. data/coradoc-adoc/lib/coradoc/asciidoc/model/include.rb +1 -1
  6. data/coradoc-adoc/lib/coradoc/asciidoc/model/resolver.rb +2 -2
  7. data/coradoc-adoc/lib/coradoc/asciidoc/model/serialization/asciidoc_transform.rb +3 -3
  8. data/coradoc-adoc/lib/coradoc/asciidoc/model/table_row.rb +1 -1
  9. data/coradoc-adoc/lib/coradoc/asciidoc/model/text_element.rb +4 -8
  10. data/coradoc-adoc/lib/coradoc/asciidoc/parse_error.rb +6 -6
  11. data/coradoc-adoc/lib/coradoc/asciidoc/serializer/adoc_serializer.rb +5 -10
  12. data/coradoc-adoc/lib/coradoc/asciidoc/serializer/formatter.rb +4 -3
  13. data/coradoc-adoc/lib/coradoc/asciidoc/serializer/serializers/base.rb +8 -20
  14. data/coradoc-adoc/lib/coradoc/asciidoc/serializer/serializers/block/core.rb +1 -1
  15. data/coradoc-adoc/lib/coradoc/asciidoc/serializer/serializers/document.rb +3 -6
  16. data/coradoc-adoc/lib/coradoc/asciidoc/serializer/serializers/inline/strikethrough.rb +1 -1
  17. data/coradoc-adoc/lib/coradoc/asciidoc/serializer/serializers/list/item.rb +5 -9
  18. data/coradoc-adoc/lib/coradoc/asciidoc/transform/from_core_model.rb +26 -34
  19. data/coradoc-adoc/lib/coradoc/asciidoc/transform/from_core_model_registrations.rb +18 -18
  20. data/coradoc-adoc/lib/coradoc/asciidoc/transform/to_core_model.rb +96 -123
  21. data/coradoc-adoc/lib/coradoc/asciidoc/transform/to_core_model_registrations.rb +10 -6
  22. data/coradoc-adoc/lib/coradoc/asciidoc/transformer/header_rules.rb +5 -5
  23. data/coradoc-adoc/lib/coradoc/asciidoc/transformer/list_rules.rb +2 -2
  24. data/coradoc-adoc/lib/coradoc/asciidoc/transformer/structural_rules.rb +1 -1
  25. data/coradoc-adoc/lib/coradoc/asciidoc/transformer.rb +5 -5
  26. data/coradoc-adoc/lib/coradoc/asciidoc.rb +1 -1
  27. data/coradoc-adoc/lib/coradoc/util/asciidoc.rb +4 -3
  28. data/coradoc-adoc/spec/coradoc/asciidoc/transform/from_core_model_spec.rb +4 -2
  29. data/coradoc-docx/lib/coradoc/docx/transform/rules/run_rule.rb +4 -1
  30. data/coradoc-docx/lib/coradoc/docx/transform/rules/simple_field_rule.rb +5 -1
  31. data/coradoc-docx/lib/coradoc/docx/transform/rules/table_rule.rb +0 -1
  32. data/coradoc-docx/lib/coradoc/docx.rb +6 -4
  33. data/coradoc-docx/spec/coradoc/docx/transform/from_core_model_spec.rb +5 -2
  34. data/coradoc-docx/spec/coradoc/docx/transform/rules/rule_unit_spec.rb +27 -7
  35. data/coradoc-docx/spec/coradoc/docx/transform/to_core_model_spec.rb +6 -2
  36. data/coradoc-html/lib/coradoc/html/converters/base.rb +4 -1
  37. data/coradoc-html/lib/coradoc/html.rb +1 -1
  38. data/coradoc-markdown/lib/coradoc/markdown/transform/from_core_model.rb +2 -2
  39. data/coradoc-markdown/lib/coradoc/markdown.rb +1 -1
  40. data/lib/coradoc/configurable.rb +6 -2
  41. data/lib/coradoc/coradoc.rb +18 -16
  42. data/lib/coradoc/core_model/base.rb +3 -3
  43. data/lib/coradoc/core_model/list_item.rb +3 -3
  44. data/lib/coradoc/core_model/toc_generator.rb +1 -1
  45. data/lib/coradoc/document_manipulator.rb +9 -13
  46. data/lib/coradoc/format_module.rb +16 -4
  47. data/lib/coradoc/input.rb +1 -1
  48. data/lib/coradoc/output.rb +1 -1
  49. data/lib/coradoc/query.rb +38 -186
  50. data/lib/coradoc/registry.rb +5 -7
  51. data/lib/coradoc/serializer/registry.rb +3 -5
  52. data/lib/coradoc/validation.rb +40 -21
  53. data/lib/coradoc/version.rb +1 -1
  54. metadata +1 -1
data/lib/coradoc/query.rb CHANGED
@@ -3,44 +3,12 @@
3
3
  module Coradoc
4
4
  # Document querying and introspection API.
5
5
  #
6
- # This module provides CSS-like selectors for navigating and querying
7
- # document trees. It enables powerful document manipulation patterns.
8
- #
9
- # @example Querying documents
10
- # doc = Coradoc.parse(adoc_text, format: :asciidoc)
11
- #
12
- # # Find all sections
13
- # sections = doc.query('section')
14
- #
15
- # # Find level-2 sections
16
- # doc.query('section.level-2').each do |section|
17
- # puts section.title
18
- # end
19
- #
20
- # # Find paragraphs with specific role
21
- # examples = doc.query('[role=example]')
22
- #
23
- # # Complex selectors
24
- # doc.query('section > paragraph:first-child')
25
- #
6
+ # Provides CSS-like selectors for navigating and querying document trees.
26
7
  module Query
27
8
  # Selector parsing and matching
28
- #
29
- # Supports CSS-like selectors for document querying:
30
- # - Element type: `section`, `paragraph`, `table`
31
- # - Class/level: `.level-2`, `.important`
32
- # - ID: `#intro`, `#section-1`
33
- # - Attributes: `[id=intro]`, `[role=example]`, `[level>1]`
34
- # - Pseudo-classes: `:first-child`, `:last-child`, `:nth-child(2)`
35
- # - Combinators: `>` (child), space (descendant)
36
- #
37
9
  class Selector
38
10
  attr_reader :element_type, :id, :classes, :attributes, :pseudo_classes
39
11
 
40
- # Parse a selector string
41
- #
42
- # @param selector [String] CSS-like selector
43
- # @return [Selector] Parsed selector object
44
12
  def self.parse(selector)
45
13
  new.parse(selector)
46
14
  end
@@ -53,40 +21,31 @@ module Coradoc
53
21
  @pseudo_classes = []
54
22
  end
55
23
 
56
- # Parse a selector string into this object
57
- #
58
- # @param selector [String] The selector to parse
59
- # @return [self]
60
24
  def parse(selector)
61
25
  @original = selector.to_s.strip
62
26
  return self if @original.empty?
63
27
 
64
- # Parse element type
65
28
  @original.sub!(/\A([a-z_][a-z0-9_-]*)/i) do |match|
66
29
  @element_type = match.downcase
67
30
  ''
68
31
  end
69
32
 
70
- # Parse ID
71
33
  @original.sub!(/#([a-z_][a-z0-9_-]*)/i) do
72
34
  @id = ::Regexp.last_match(1)
73
35
  ''
74
36
  end
75
37
 
76
- # Parse classes
77
38
  @original.gsub!(/\.([a-z_][a-z0-9_-]*)/i) do
78
39
  @classes << ::Regexp.last_match(1)
79
40
  ''
80
41
  end
81
42
 
82
- # Parse attributes
83
43
  @original.gsub!(/\[([^\]]+)\]/) do
84
44
  attr_expr = ::Regexp.last_match(1)
85
45
  parse_attribute(attr_expr)
86
46
  ''
87
47
  end
88
48
 
89
- # Parse pseudo-classes
90
49
  @original.gsub!(/:([a-z-]+)(?:\(([^)]+)\))?/i) do
91
50
  name = ::Regexp.last_match(1).downcase
92
51
  arg = ::Regexp.last_match(2)
@@ -97,34 +56,16 @@ module Coradoc
97
56
  self
98
57
  end
99
58
 
100
- # Check if an element matches this selector
101
- #
102
- # @param element [CoreModel::Base] The element to check
103
- # @return [Boolean]
104
59
  def matches?(element)
105
60
  return false unless element
106
-
107
- # Check element type
108
61
  return false if @element_type && !type_matches?(element)
109
-
110
- # Check ID
111
- return false if @id && element_id(element) != @id
112
-
113
- # Check classes/roles
62
+ return false if @id && element.id != @id
114
63
  return false if @classes.any? && !classes_match?(element)
115
-
116
- # Check attributes
117
64
  return false if @attributes.any? && !attributes_match?(element)
118
65
 
119
66
  true
120
67
  end
121
68
 
122
- # Check pseudo-class conditions
123
- #
124
- # @param element [CoreModel::Base] The element to check
125
- # @param siblings [Array] Sibling elements
126
- # @param index [Integer] Element's index among siblings
127
- # @return [Boolean]
128
69
  def matches_pseudo_classes?(element, siblings:, index:)
129
70
  @pseudo_classes.all? do |pseudo|
130
71
  case pseudo[:name]
@@ -134,20 +75,17 @@ module Coradoc
134
75
  index == siblings.length - 1
135
76
  when 'nth-child'
136
77
  n = pseudo[:argument].to_i
137
- index == n - 1 # 1-indexed in CSS
78
+ index == n - 1
138
79
  when 'only-child'
139
80
  siblings.length == 1
140
81
  when 'empty'
141
82
  empty_element?(element)
142
83
  else
143
- true # Unknown pseudo-classes pass
84
+ true
144
85
  end
145
86
  end
146
87
  end
147
88
 
148
- # Check if selector is universal (*)
149
- #
150
- # @return [Boolean]
151
89
  def universal?
152
90
  @element_type == '*' || @original == '*'
153
91
  end
@@ -155,7 +93,6 @@ module Coradoc
155
93
  private
156
94
 
157
95
  def parse_attribute(expr)
158
- # Handle different attribute operators
159
96
  case expr
160
97
  when /(\w+)\s*=\s*["']?([^"']+)["']?/
161
98
  @attributes[::Regexp.last_match(1).to_sym] = {
@@ -183,7 +120,6 @@ module Coradoc
183
120
  value: ::Regexp.last_match(2)
184
121
  }
185
122
  when /(\w+)/
186
- # Attribute presence check
187
123
  @attributes[::Regexp.last_match(1).to_sym] = { operator: :present }
188
124
  end
189
125
  end
@@ -191,14 +127,9 @@ module Coradoc
191
127
  def type_matches?(element)
192
128
  return true if @element_type == '*'
193
129
 
194
- # First check the element_type attribute if present (for StructuralElement/Block)
195
- if element.respond_to?(:element_type) && element.element_type
196
- return element.element_type.to_s.downcase == @element_type.downcase
197
- end
130
+ return element.element_type.to_s.downcase == @element_type.downcase if (element.is_a?(CoreModel::StructuralElement) || element.is_a?(CoreModel::Block)) && element.element_type
198
131
 
199
- # Then check the class-derived snake_case name (exact match only)
200
132
  class_name = class_to_query_name(element.class)
201
-
202
133
  class_name == @element_type
203
134
  end
204
135
 
@@ -212,22 +143,25 @@ module Coradoc
212
143
  .downcase
213
144
  end
214
145
 
215
- def element_id(element)
216
- element.respond_to?(:id) ? element.id : nil
217
- end
218
-
219
146
  def classes_match?(element)
220
- element_classes = if element.respond_to?(:role) && element.role
221
- element.role.split.map(&:downcase)
222
- elsif element.respond_to?(:classes)
223
- Array(element.classes).map(&:downcase)
224
- else
147
+ element_classes = if element.is_a?(CoreModel::StructuralElement) && element.element_type
148
+ [element.element_type]
149
+ elsif element.is_a?(CoreModel::Base)
225
150
  []
151
+ else
152
+ extract_role(element)
226
153
  end
227
154
 
228
155
  @classes.all? { |c| element_classes.include?(c.downcase) }
229
156
  end
230
157
 
158
+ def extract_role(element)
159
+ role = element.public_send(:role)
160
+ role ? role.to_s.split.map(&:downcase) : []
161
+ rescue NoMethodError
162
+ []
163
+ end
164
+
231
165
  def attributes_match?(element)
232
166
  @attributes.all? do |attr_name, condition|
233
167
  value = get_attribute_value(element, attr_name)
@@ -237,17 +171,23 @@ module Coradoc
237
171
 
238
172
  def get_attribute_value(element, attr_name)
239
173
  case attr_name
240
- when :id
241
- element.respond_to?(:id) ? element.id : nil
174
+ when :id, :title
175
+ element.public_send(attr_name)
242
176
  when :level
243
- element.respond_to?(:level) ? element.level : nil
244
- when :role
245
- element.respond_to?(:role) ? element.role : nil
177
+ if element.is_a?(CoreModel::StructuralElement)
178
+ element.level
179
+ else
180
+ element.public_send(:level)
181
+ end
182
+ when :element_type
183
+ element.element_type if element.is_a?(CoreModel::StructuralElement) || element.is_a?(CoreModel::Block)
246
184
  when :type
247
- element.respond_to?(:type) ? element.type : nil
185
+ element.type if element.is_a?(CoreModel::AnnotationBlock) || element.is_a?(CoreModel::InlineElement)
248
186
  else
249
- element.respond_to?(attr_name) ? element.send(attr_name) : nil
187
+ element.public_send(attr_name) if element.is_a?(CoreModel::Base) && element.class.attributes.key?(attr_name)
250
188
  end
189
+ rescue NoMethodError
190
+ nil
251
191
  end
252
192
 
253
193
  def match_attribute_condition(value, condition)
@@ -270,7 +210,7 @@ module Coradoc
270
210
  end
271
211
 
272
212
  def empty_element?(element)
273
- return true unless element.respond_to?(:content)
213
+ return true unless element.is_a?(CoreModel::Block) || element.is_a?(CoreModel::StructuralElement)
274
214
 
275
215
  content = element.content
276
216
  case content
@@ -285,82 +225,46 @@ module Coradoc
285
225
  end
286
226
 
287
227
  # Query result set - collection of matched elements
288
- #
289
- # Provides array-like access with additional query methods for
290
- # chaining and further filtering.
291
- #
292
228
  class ResultSet
293
229
  include Enumerable
294
230
 
295
- # @return [Array<CoreModel::Base>] Matched elements
296
231
  attr_reader :elements
297
232
 
298
- # Create a new result set
299
- #
300
- # @param elements [Array<CoreModel::Base>] Matched elements
301
233
  def initialize(elements = [])
302
234
  @elements = Array(elements).compact
303
235
  end
304
236
 
305
- # Iterate over elements
306
- #
307
- # @yield [CoreModel::Base] Each matched element
308
- # @return [Enumerator]
309
237
  def each(&block)
310
238
  @elements.each(&block)
311
239
  end
312
240
 
313
- # Get element at index
314
- #
315
- # @param index [Integer] Element index
316
- # @return [CoreModel::Base, nil]
317
241
  def [](index)
318
242
  @elements[index]
319
243
  end
320
244
 
321
- # Number of matched elements
322
- #
323
- # @return [Integer]
324
245
  def length
325
246
  @elements.length
326
247
  end
327
248
  alias size length
328
249
 
329
- # Check if result set is empty
330
- #
331
- # @return [Boolean]
332
250
  def empty?
333
251
  @elements.empty?
334
252
  end
335
253
 
336
- # Get first element
337
- #
338
- # @return [CoreModel::Base, nil]
339
254
  def first
340
255
  @elements.first
341
256
  end
342
257
 
343
- # Get last element
344
- #
345
- # @return [CoreModel::Base, nil]
346
258
  def last
347
259
  @elements.last
348
260
  end
349
261
 
350
- # Filter results with an additional selector
351
- #
352
- # @param selector [String] CSS-like selector
353
- # @return [ResultSet] Filtered results
354
262
  def filter(selector)
355
263
  parsed = Selector.parse(selector)
356
264
  filtered = @elements.select { |e| parsed.matches?(e) }
357
265
  ResultSet.new(filtered)
358
266
  end
359
267
 
360
- # Query within each element in the result set
361
- #
362
- # @param selector [String] CSS-like selector
363
- # @return [ResultSet] Combined results
364
268
  def query(selector)
365
269
  results = @elements.flat_map do |element|
366
270
  Query.query_within(element, selector).to_a
@@ -368,72 +272,40 @@ module Coradoc
368
272
  ResultSet.new(results.uniq)
369
273
  end
370
274
 
371
- # Map over elements and return new result set
372
- #
373
- # @yield [CoreModel::Base] Block to transform elements
374
- # @return [ResultSet]
375
275
  def map(&block)
376
276
  ResultSet.new(@elements.map(&block))
377
277
  end
378
278
 
379
- # Select elements matching block
380
- #
381
- # @yield [CoreModel::Base] Test block
382
- # @return [ResultSet]
383
279
  def select(&block)
384
280
  ResultSet.new(@elements.select(&block))
385
281
  end
386
282
 
387
- # Reject elements matching block
388
- #
389
- # @yield [CoreModel::Base] Test block
390
- # @return [ResultSet]
391
283
  def reject(&block)
392
284
  ResultSet.new(@elements.reject(&block))
393
285
  end
394
286
 
395
- # Convert to array
396
- #
397
- # @return [Array<CoreModel::Base>]
398
287
  def to_a
399
288
  @elements.dup
400
289
  end
401
290
 
402
- # Pretty print representation
403
- #
404
- # @return [String]
405
291
  def inspect
406
292
  "#<Coradoc::Query::ResultSet count=#{length}>"
407
293
  end
408
294
  end
409
295
 
410
296
  # Query engine for executing selectors
411
- #
412
297
  class Engine
413
- # Query a document or element
414
- #
415
- # @param document [CoreModel::Base] Root document/element
416
- # @param selector [String] CSS-like selector
417
- # @return [ResultSet] Matched elements
418
298
  def self.query(document, selector)
419
299
  new.query(document, selector)
420
300
  end
421
301
 
422
- # Query document with selector
423
- #
424
- # @param document [CoreModel::Base] Root element
425
- # @param selector [String] CSS-like selector
426
- # @return [ResultSet] Matched elements
427
302
  def query(document, selector)
428
303
  return ResultSet.new if document.nil? || selector.to_s.strip.empty?
429
304
 
430
- # Handle comma-separated selectors
431
305
  return query_multiple(document, selector.split(',').map(&:strip)) if selector.include?(',')
432
306
 
433
- # Handle descendant combinator (space) and child combinator (>)
434
307
  return query_with_combinators(document, selector) if selector.include?('>') || selector.include?(' ')
435
308
 
436
- # Simple single selector
437
309
  parsed = Selector.parse(selector)
438
310
  results = []
439
311
 
@@ -462,11 +334,9 @@ module Coradoc
462
334
  parts = parse_combinator_selector(selector)
463
335
  results = []
464
336
 
465
- # Find elements matching the first part
466
337
  first_results = query(document, parts[:first])
467
338
  return ResultSet.new if first_results.empty?
468
339
 
469
- # For each first match, look for descendants/children
470
340
  first_results.each do |parent|
471
341
  find_matching_descendants(parent, parts[:rest]).each do |match|
472
342
  results << match
@@ -477,7 +347,6 @@ module Coradoc
477
347
  end
478
348
 
479
349
  def parse_combinator_selector(selector)
480
- # Simple parsing - handles "parent > child" and "parent child"
481
350
  if selector.include?(' > ')
482
351
  parts = selector.split(' > ', 2)
483
352
  { first: parts[0], rest: [{ combinator: :child, selector: parts[1] }] }
@@ -509,7 +378,6 @@ module Coradoc
509
378
  results.concat(find_matching_descendants(child, remaining)) if parsed.matches?(child) && pseudo_matches?(
510
379
  parsed, child, siblings, index
511
380
  )
512
- # Also search deeper
513
381
  results.concat(find_matching_descendants(child, parts))
514
382
  end
515
383
  end
@@ -541,20 +409,10 @@ module Coradoc
541
409
 
542
410
  # Module-level query methods
543
411
  class << self
544
- # Query a document with a selector
545
- #
546
- # @param document [CoreModel::Base] The document to query
547
- # @param selector [String] CSS-like selector
548
- # @return [ResultSet] Matched elements
549
412
  def query(document, selector)
550
413
  Engine.query(document, selector)
551
414
  end
552
415
 
553
- # Query within an element (not including the element itself)
554
- #
555
- # @param element [CoreModel::Base] The parent element
556
- # @param selector [String] CSS-like selector
557
- # @return [ResultSet] Matched elements
558
416
  def query_within(element, selector)
559
417
  parsed = Selector.parse(selector)
560
418
  results = []
@@ -571,22 +429,16 @@ module Coradoc
571
429
  ResultSet.new(results)
572
430
  end
573
431
 
574
- # Get navigable children from an element.
575
- # Uses ChildrenContent#children when available, falls back to content.
576
- #
577
- # @param element [Object] Element to get children from
578
- # @return [Array] Navigable child elements
579
432
  def get_children(element)
580
433
  return [] unless element
581
434
 
582
- children = if element.respond_to?(:children) && element.children&.any?
583
- element.children
584
- elsif element.respond_to?(:content) && element.content
585
- Array(element.content).select { |c| c.is_a?(CoreModel::Base) }
586
- else
587
- []
588
- end
589
- Array(children)
435
+ if element.is_a?(CoreModel::StructuralElement) && element.children&.any?
436
+ element.children
437
+ elsif element.is_a?(CoreModel::Block) && element.content
438
+ Array(element.content).select { |c| c.is_a?(CoreModel::Base) }
439
+ else
440
+ []
441
+ end
590
442
  end
591
443
 
592
444
  private
@@ -46,8 +46,6 @@ module Coradoc
46
46
  # @param options [Hash] optional per-item configuration
47
47
  # @return [void]
48
48
  def define(item, **opts)
49
- return unless item.respond_to?(:processor_id)
50
-
51
49
  register(item.processor_id, item, opts)
52
50
  end
53
51
 
@@ -85,9 +83,7 @@ module Coradoc
85
83
 
86
84
  # Direct access to the items hash (for backward compatibility)
87
85
  # @return [Hash<Symbol, Object>]
88
- def items
89
- @items
90
- end
86
+ attr_reader :items
91
87
 
92
88
  # Number of registered items
93
89
  #
@@ -132,7 +128,9 @@ module Coradoc
132
128
  # @return [Object, nil]
133
129
  def for_file(filename)
134
130
  @items.values.find do |item|
135
- item.respond_to?(:processor_match?) && item.processor_match?(filename)
131
+ item.processor_match?(filename)
132
+ rescue NoMethodError
133
+ false
136
134
  end
137
135
  end
138
136
 
@@ -149,7 +147,7 @@ module Coradoc
149
147
  for_file(options[:filename])
150
148
  end
151
149
 
152
- label = @error_label || "processor"
150
+ label = @error_label || 'processor'
153
151
  raise ArgumentError, "No #{label} found for: #{options}" unless item
154
152
 
155
153
  item.processor_execute(content, options)
@@ -100,14 +100,12 @@ module Coradoc
100
100
  serializer_class = lookup(model)
101
101
  return nil unless serializer_class
102
102
 
103
- serializer = serializer_class.respond_to?(:new) ? serializer_class.new : serializer_class
103
+ serializer = serializer_class.is_a?(Class) ? serializer_class.new : serializer_class
104
104
 
105
- if serializer.respond_to?(:serialize)
105
+ if serializer.is_a?(Base)
106
106
  serializer.serialize(model, format: format, **options)
107
- elsif serializer.respond_to?(:to_s)
108
- serializer.to_s
109
107
  else
110
- model.to_s
108
+ serializer.to_s
111
109
  end
112
110
  end
113
111
 
@@ -177,23 +177,26 @@ module Coradoc
177
177
  class Rule
178
178
  attr_reader :name, :options
179
179
 
180
- # Create a validation rule
181
- #
182
- # @param name [Symbol] Rule name
183
- # @param options [Hash] Rule options
184
180
  def initialize(name, **options)
185
181
  @name = name
186
182
  @options = options
187
183
  end
188
184
 
189
- # Validate an element
190
- #
191
- # @param element [Object] Element to validate
192
- # @param context [Hash] Validation context
193
- # @return [Array<String>] Error messages
194
185
  def validate(element, context = {})
195
186
  raise NotImplementedError, 'Subclasses must implement #validate'
196
187
  end
188
+
189
+ private
190
+
191
+ def field_value(element, field)
192
+ if element.is_a?(CoreModel::Base)
193
+ element.public_send(field) if element.class.attributes.key?(field)
194
+ else
195
+ element.public_send(field)
196
+ end
197
+ rescue NoMethodError
198
+ nil
199
+ end
197
200
  end
198
201
 
199
202
  # Built-in validation rules
@@ -212,7 +215,13 @@ module Coradoc
212
215
  private
213
216
 
214
217
  def get_value(element, field)
215
- element.send(field) if element.respond_to?(field)
218
+ if element.is_a?(CoreModel::Base)
219
+ element.public_send(field) if element.class.attributes.key?(field)
220
+ else
221
+ element.public_send(field)
222
+ end
223
+ rescue NoMethodError
224
+ nil
216
225
  end
217
226
  end
218
227
 
@@ -221,7 +230,7 @@ module Coradoc
221
230
  def validate(element, _context = {})
222
231
  field = options[:field]
223
232
  expected_type = options[:type]
224
- value = element.send(field) if element.respond_to?(field)
233
+ value = field_value(element, field)
225
234
 
226
235
  return [] if value.nil? && !options[:required]
227
236
  return [] if value.nil?
@@ -236,12 +245,12 @@ module Coradoc
236
245
  class Length < Rule
237
246
  def validate(element, _context = {})
238
247
  field = options[:field]
239
- value = element.send(field) if element.respond_to?(field)
248
+ value = field_value(element, field)
240
249
 
241
250
  return [] if value.nil?
242
251
 
243
252
  errors = []
244
- length = value.respond_to?(:length) ? value.length : 0
253
+ length = value.is_a?(String) ? value.length : 0
245
254
 
246
255
  errors << "#{field} must have at least #{options[:min]} characters/items" if options[:min] && length < options[:min]
247
256
 
@@ -255,12 +264,12 @@ module Coradoc
255
264
  class Count < Rule
256
265
  def validate(element, _context = {})
257
266
  field = options[:field]
258
- value = element.send(field) if element.respond_to?(field)
267
+ value = field_value(element, field)
259
268
 
260
269
  return [] if value.nil?
261
270
 
262
271
  errors = []
263
- count = value.respond_to?(:count) ? value.count : 0
272
+ count = value.is_a?(Enumerable) ? value.count : 0
264
273
 
265
274
  errors << "#{field} must have at least #{options[:min]} items" if options[:min] && count < options[:min]
266
275
 
@@ -275,7 +284,7 @@ module Coradoc
275
284
  def validate(element, _context = {})
276
285
  field = options[:field]
277
286
  pattern = options[:pattern]
278
- value = element.send(field) if element.respond_to?(field)
287
+ value = field_value(element, field)
279
288
 
280
289
  return [] if value.nil?
281
290
 
@@ -377,7 +386,7 @@ module Coradoc
377
386
  private
378
387
 
379
388
  def validate_field(document, name, config, result)
380
- value = document.respond_to?(name) ? document.send(name) : nil
389
+ value = field_value(document, name)
381
390
  path = name.to_s
382
391
 
383
392
  # Check required
@@ -398,7 +407,7 @@ module Coradoc
398
407
  end
399
408
 
400
409
  # Check min_length
401
- if config[:min_length] && value.respond_to?(:length) && (value.length < config[:min_length])
410
+ if config[:min_length] && value.is_a?(String) && (value.length < config[:min_length])
402
411
  result.add_error(
403
412
  "#{name} must have at least #{config[:min_length]} characters",
404
413
  path: path,
@@ -407,7 +416,7 @@ module Coradoc
407
416
  end
408
417
 
409
418
  # Check max_length
410
- if config[:max_length] && value.respond_to?(:length) && (value.length > config[:max_length])
419
+ if config[:max_length] && value.is_a?(String) && (value.length > config[:max_length])
411
420
  result.add_error(
412
421
  "#{name} must have at most #{config[:max_length]} characters",
413
422
  path: path,
@@ -416,7 +425,7 @@ module Coradoc
416
425
  end
417
426
 
418
427
  # Check min_count
419
- if config[:min_count] && value.respond_to?(:count) && (value.count < config[:min_count])
428
+ if config[:min_count] && value.is_a?(Enumerable) && (value.count < config[:min_count])
420
429
  result.add_error(
421
430
  "#{name} must have at least #{config[:min_count]} items",
422
431
  path: path,
@@ -433,6 +442,16 @@ module Coradoc
433
442
  code: :format
434
443
  )
435
444
  end
445
+
446
+ def field_value(document, field)
447
+ if document.is_a?(CoreModel::Base)
448
+ document.public_send(field) if document.class.attributes.key?(field)
449
+ else
450
+ document.public_send(field)
451
+ end
452
+ rescue NoMethodError
453
+ nil
454
+ end
436
455
  end
437
456
 
438
457
  # Schema generator from CoreModel types
@@ -479,7 +498,7 @@ module Coradoc
479
498
  # )
480
499
  #
481
500
  def generate(model_class, required: [], ignored: [], custom_rules: {})
482
- return nil unless model_class.respond_to?(:attributes)
501
+ return nil unless model_class.is_a?(Class) && model_class < CoreModel::Base
483
502
 
484
503
  # Pre-compute attribute definitions before the schema block
485
504
  attribute_defs = compute_attribute_definitions(