cml 1.4.2 → 1.5.0
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.
- data/.rspec +1 -0
- data/Gemfile +4 -0
- data/Gemfile.lock +33 -0
- data/README.rdoc +13 -3
- data/Rakefile +9 -49
- data/cml.gemspec +23 -125
- data/lib/cml/converters/jsawesome.rb +3 -3
- data/lib/cml/gold.rb +12 -7
- data/lib/cml/liquid_filters.rb +4 -0
- data/lib/cml/logic.rb +424 -0
- data/lib/cml/logic_tree/graph.rb +107 -0
- data/lib/cml/logic_tree/solver.rb +43 -0
- data/lib/cml/parser.rb +47 -7
- data/lib/cml/tag.rb +42 -21
- data/lib/cml/tags/checkbox.rb +14 -4
- data/lib/cml/tags/checkboxes.rb +4 -0
- data/lib/cml/tags/group.rb +4 -0
- data/lib/cml/tags/hours.rb +263 -0
- data/lib/cml/tags/iterate.rb +4 -0
- data/lib/cml/tags/meta.rb +4 -0
- data/lib/cml/tags/option.rb +4 -0
- data/lib/cml/tags/radio.rb +13 -4
- data/lib/cml/tags/radios.rb +4 -0
- data/lib/cml/tags/ratings.rb +9 -1
- data/lib/cml/tags/search.rb +8 -2
- data/lib/cml/tags/select.rb +6 -2
- data/lib/cml/tags/taxonomy.rb +148 -0
- data/lib/cml/tags/thumb.rb +4 -0
- data/lib/cml/tags/unknown.rb +4 -0
- data/lib/cml/version.rb +3 -0
- data/lib/cml.rb +3 -0
- data/spec/converters/jsawesome_spec.rb +0 -9
- data/spec/fixtures/logic_broken.cml +5 -0
- data/spec/fixtures/logic_circular.cml +5 -0
- data/spec/fixtures/logic_grouped.cml +7 -0
- data/spec/fixtures/logic_nested.cml +7 -0
- data/spec/fixtures/logic_none.cml +7 -0
- data/spec/fixtures/logic_not_nested.cml +7 -0
- data/spec/liquid_filter_spec.rb +19 -0
- data/spec/logic_depends_on_spec.rb +242 -0
- data/spec/logic_spec.rb +207 -0
- data/spec/logic_tree_graph_spec.rb +465 -0
- data/spec/logic_tree_solver_spec.rb +58 -0
- data/spec/meta_spec.rb +12 -2
- data/spec/show_data_spec.rb +3 -2
- data/spec/spec_helper.rb +22 -6
- data/spec/tags/checkboxes_spec.rb +2 -2
- data/spec/tags/group_spec.rb +5 -5
- data/spec/tags/hours_spec.rb +404 -0
- data/spec/tags/radios_spec.rb +2 -2
- data/spec/tags/ratings_spec.rb +1 -1
- data/spec/tags/select_spec.rb +45 -0
- data/spec/tags/tag_spec.rb +25 -0
- data/spec/tags/taxonomy_spec.rb +112 -0
- data/spec/validation_spec.rb +52 -0
- metadata +112 -17
- data/VERSION +0 -1
data/lib/cml/logic.rb
ADDED
@@ -0,0 +1,424 @@
|
|
1
|
+
module CML
|
2
|
+
# Build and calculate things based on some CML's dependendency tree as
|
3
|
+
# dictated by `only-if` CML logic.
|
4
|
+
class LogicTree
|
5
|
+
attr_reader :root_nodes, :nodes, :parser, :errors
|
6
|
+
|
7
|
+
DEPTH_LIMIT = 10
|
8
|
+
|
9
|
+
def initialize( parser )
|
10
|
+
@parser = parser
|
11
|
+
@errors = []
|
12
|
+
# Hash of Node instances, keyed by tag names.
|
13
|
+
@nodes = @parser.tags.inject( {} ) do |memo, tag|
|
14
|
+
memo[tag.name] = Node.new( self, tag )
|
15
|
+
memo
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
# Recursively determine the depth of a Node based on logic. For example:
|
20
|
+
#
|
21
|
+
# A
|
22
|
+
# / \
|
23
|
+
# B C
|
24
|
+
# / \
|
25
|
+
# D E
|
26
|
+
#
|
27
|
+
# Which gives depths of:
|
28
|
+
#
|
29
|
+
# A = 1
|
30
|
+
# B, C = 2
|
31
|
+
# D, E = 3
|
32
|
+
#
|
33
|
+
# If we've recursed 10 times, we're probably in a cycle, so we just return
|
34
|
+
# a max of 10 here.
|
35
|
+
def depth( node, recurse_depth = 1 )
|
36
|
+
return 1 unless node.has_logic? && recurse_depth < DEPTH_LIMIT
|
37
|
+
depth = 1 + node.depends_on.map do |depended_node|
|
38
|
+
self.depth( depended_node, recurse_depth + 1 )
|
39
|
+
end.max
|
40
|
+
end
|
41
|
+
|
42
|
+
def max_depth
|
43
|
+
@nodes.values.map do |node|
|
44
|
+
node.depth
|
45
|
+
end.max
|
46
|
+
rescue CML::TagLogic::Error, CML::LogicTree::Graph::Error
|
47
|
+
# When logic contains errors, return depth of 1 (maintains backwards compatibility)
|
48
|
+
1
|
49
|
+
end
|
50
|
+
|
51
|
+
def has_grouped_logic?
|
52
|
+
@nodes.values.any? {|node| node.has_grouped_logic? }
|
53
|
+
rescue CML::TagLogic::Error, CML::LogicTree::Graph::Error
|
54
|
+
# When logic contains errors, return no grouping (maintains backwards compatibility)
|
55
|
+
false
|
56
|
+
end
|
57
|
+
|
58
|
+
def has_liquid_logic?
|
59
|
+
@nodes.values.any? {|node| node.has_liquid_logic? }
|
60
|
+
end
|
61
|
+
|
62
|
+
def valid?
|
63
|
+
@errors = []
|
64
|
+
graph
|
65
|
+
true
|
66
|
+
rescue CML::TagLogic::Error, CML::LogicTree::Graph::Error => e
|
67
|
+
@errors << e.message
|
68
|
+
false
|
69
|
+
rescue
|
70
|
+
@errors << "CML only-if logic cannot be parsed."
|
71
|
+
false
|
72
|
+
end
|
73
|
+
|
74
|
+
def graph
|
75
|
+
@graph ||= Graph.new(self)
|
76
|
+
@graph.structure
|
77
|
+
end
|
78
|
+
|
79
|
+
# A node in CML::LogicTree, containing a CML::Tag in @tag
|
80
|
+
class Node
|
81
|
+
attr_reader :tag, :tree
|
82
|
+
|
83
|
+
def initialize( tree, tag )
|
84
|
+
@tree = tree
|
85
|
+
@tag = tag
|
86
|
+
@tag.logic_tree = @tree
|
87
|
+
self
|
88
|
+
end
|
89
|
+
|
90
|
+
def depth
|
91
|
+
@depth ||= @tree.depth( self )
|
92
|
+
end
|
93
|
+
|
94
|
+
# Hash of tag dependency details (name/is_not/match_key) on (only-if logic)
|
95
|
+
def dependencies_on(&block)
|
96
|
+
if block_given?
|
97
|
+
@tag.dependencies_on_fields(&block)
|
98
|
+
else
|
99
|
+
@dependencies_on_nodes ||= @tag.dependencies_on_fields
|
100
|
+
end
|
101
|
+
end
|
102
|
+
|
103
|
+
# Array of Node instances the tag depends on (only-if logic)
|
104
|
+
def depends_on
|
105
|
+
@depends_on_nodes ||= @tag.depends_on_fields.map do |field_name|
|
106
|
+
@tree.nodes[field_name]
|
107
|
+
end.compact.uniq
|
108
|
+
end
|
109
|
+
|
110
|
+
def has_logic?
|
111
|
+
self.depends_on.size > 0
|
112
|
+
end
|
113
|
+
|
114
|
+
def has_grouped_logic?
|
115
|
+
@tag.depends_on_fields
|
116
|
+
!!@tag.has_grouped_logic
|
117
|
+
end
|
118
|
+
|
119
|
+
def has_liquid_logic?
|
120
|
+
@tag.depends_on_fields
|
121
|
+
!!@tag.has_liquid_logic
|
122
|
+
end
|
123
|
+
end
|
124
|
+
end
|
125
|
+
|
126
|
+
# Logic behavior included in CML::Parser
|
127
|
+
module ParserLogic
|
128
|
+
def has_nested_logic?
|
129
|
+
self.logic_tree.max_depth > 2
|
130
|
+
end
|
131
|
+
|
132
|
+
def has_grouped_logic?
|
133
|
+
self.logic_tree.has_grouped_logic?
|
134
|
+
end
|
135
|
+
|
136
|
+
def has_liquid_logic?
|
137
|
+
self.logic_tree.has_liquid_logic?
|
138
|
+
end
|
139
|
+
|
140
|
+
def logic_tree
|
141
|
+
@logic_tree ||= CML::LogicTree.new( self )
|
142
|
+
end
|
143
|
+
end
|
144
|
+
|
145
|
+
# Logic behavior included in CML::Tag
|
146
|
+
module TagLogic
|
147
|
+
attr_accessor :logic_tree, :has_grouped_logic, :has_liquid_logic, :errors
|
148
|
+
|
149
|
+
# These Errors cause CML::Parser#valid? failures; their messages are
|
150
|
+
# end-user-facing and should help the user correct their mistakes.
|
151
|
+
class Error < StandardError; end
|
152
|
+
class NodeNotFound < Error; end
|
153
|
+
|
154
|
+
# Override in Tags classes that should be omitted from the logic graph.
|
155
|
+
def in_logic_graph?
|
156
|
+
true
|
157
|
+
end
|
158
|
+
|
159
|
+
def only_if
|
160
|
+
@only_if ||= @attrs["only-if"].to_s
|
161
|
+
end
|
162
|
+
|
163
|
+
# For each only-if token, e.g, `omg`, `!omg`, `omg:[0]`, `omg:[wtf]`, etc.,
|
164
|
+
# call the block with args field_name, match_key & is_not.
|
165
|
+
def each_logic_token_in(logic_expression)
|
166
|
+
return unless block_given?
|
167
|
+
logic_expression.split( /\+\+|\|\|/ ).each do |logic_token|
|
168
|
+
field_name, match_key = logic_token.split( ":" )
|
169
|
+
|
170
|
+
# prune the possibly-dangling open paren from logic grouping
|
171
|
+
field_name.gsub!( /^\(/, '' )
|
172
|
+
# prune the possibly-dangling close paren from logic grouping
|
173
|
+
match_key.gsub!( /\)$/, '') if match_key
|
174
|
+
|
175
|
+
# detect NOT logic
|
176
|
+
is_not = if /^!/===field_name
|
177
|
+
field_name = field_name.gsub( /^!/, '' )
|
178
|
+
true
|
179
|
+
else
|
180
|
+
false
|
181
|
+
end
|
182
|
+
|
183
|
+
yield field_name, match_key, is_not
|
184
|
+
end
|
185
|
+
end
|
186
|
+
|
187
|
+
# Generate the full logic structure for an only-if.
|
188
|
+
#
|
189
|
+
# The optional block can be used as a flat collector of all fields; its return value is ignored.
|
190
|
+
#
|
191
|
+
def expand_logic(only_if, &block)
|
192
|
+
parsed = CML::TagLogic.parse_expression(only_if)
|
193
|
+
expand_parsed_expression(parsed, &block)
|
194
|
+
end
|
195
|
+
|
196
|
+
# Generate the full logic structure for a parsed only-if.
|
197
|
+
#
|
198
|
+
# The optional block can be used as a flat collector of all fields; its return value is ignored.
|
199
|
+
#
|
200
|
+
def expand_parsed_expression(parsed_expression, &block)
|
201
|
+
expanded_tree = {}
|
202
|
+
parsed_expression.each_with_index do |expression_array, i|
|
203
|
+
combinator, phrase = expression_array
|
204
|
+
combinator_key = CML::TagLogic.resolve_combinator(parsed_expression, i)
|
205
|
+
branch = expanded_tree[combinator_key] ||= []
|
206
|
+
|
207
|
+
if Array===phrase
|
208
|
+
# Recurse for grouped logic
|
209
|
+
branch << expand_parsed_expression(phrase, &block)
|
210
|
+
else
|
211
|
+
name, descs = describe_logic_token(phrase)
|
212
|
+
yield(name, descs) if block_given?
|
213
|
+
value ||= descs
|
214
|
+
branch << { name => value }
|
215
|
+
end
|
216
|
+
end
|
217
|
+
expanded_tree
|
218
|
+
end
|
219
|
+
|
220
|
+
Or = '||'.freeze
|
221
|
+
And = '&&'.freeze
|
222
|
+
CombinatorDefault = Or
|
223
|
+
CombinatorDict = {
|
224
|
+
'||' => Or,
|
225
|
+
'++' => And
|
226
|
+
}
|
227
|
+
|
228
|
+
# Return the effective boolean combinator for the given index in the parsed logic.
|
229
|
+
#
|
230
|
+
def self.resolve_combinator(parsed_expression, i, parent_combinator=nil)
|
231
|
+
combinator = parsed_expression[i] && parsed_expression[i][0]
|
232
|
+
selected = if (combinator.nil? || combinator.size==0) && parent_combinator
|
233
|
+
CombinatorDict[parent_combinator]
|
234
|
+
elsif !combinator.nil? && combinator.size>0
|
235
|
+
# Use the current phrase's combinator.
|
236
|
+
CombinatorDict[combinator]
|
237
|
+
else
|
238
|
+
if parsed_expression[i+1]
|
239
|
+
# Use the next phrase's combinator
|
240
|
+
CombinatorDict[ parsed_expression[i+1][0] ]
|
241
|
+
else
|
242
|
+
CombinatorDefault
|
243
|
+
end
|
244
|
+
end
|
245
|
+
raise(RuntimeError, "Combinator for index #{i} could not be selected from: #{parsed_expression.inspect}") if
|
246
|
+
selected.nil? || selected.size==0
|
247
|
+
selected
|
248
|
+
end
|
249
|
+
|
250
|
+
CombinatorExp = '(\+\+||\|\||)'.freeze
|
251
|
+
OrCombinatorExp = '(\|\||)'.freeze
|
252
|
+
GroupExp = '\(([^\(\)]+)\)'.freeze
|
253
|
+
AndPhraseExp = '([^\(\)\|]+\+\+[^\(\)\|]+)'.freeze
|
254
|
+
TokenExp = '([^\(\)\+\|]+)'.freeze
|
255
|
+
PrecedenceRegexp = /#{CombinatorExp}#{GroupExp}|#{CombinatorExp}#{AndPhraseExp}|#{CombinatorExp}#{TokenExp}/
|
256
|
+
TokenRegexp = /#{CombinatorExp}#{TokenExp}/
|
257
|
+
|
258
|
+
def self.parse_expression(logic_expression)
|
259
|
+
parsed_expression = []
|
260
|
+
logic_expression.scan( PrecedenceRegexp ) do |group_combinator, group_phrase, and_combinator, and_phrase, combinator, token|
|
261
|
+
if group_phrase
|
262
|
+
parsed_expression << [group_combinator, parse_expression(group_phrase)]
|
263
|
+
elsif and_phrase
|
264
|
+
ands = []
|
265
|
+
and_phrase.scan( TokenRegexp ) do |precedence_combinator, precedence_token|
|
266
|
+
ands << [precedence_combinator, precedence_token]
|
267
|
+
end
|
268
|
+
parsed_expression << [and_combinator, ands]
|
269
|
+
else
|
270
|
+
parsed_expression << [combinator, token]
|
271
|
+
end
|
272
|
+
end
|
273
|
+
parsed_expression
|
274
|
+
end
|
275
|
+
|
276
|
+
# A hash of the Tag's dependencies with "only-if" logic.
|
277
|
+
#
|
278
|
+
# Resolves indexed logic selector keys (e.g. `only-if="pick_a_number:[0]"`)
|
279
|
+
# into actual values from parents.
|
280
|
+
#
|
281
|
+
# The optional block can be used as a flat collector of all fields; its return value is ignored.
|
282
|
+
#
|
283
|
+
def dependencies_on_fields(&block)
|
284
|
+
return @dependencies_on_fields if @dependencies_on_fields && !block_given?
|
285
|
+
@dependencies_on_fields = {}
|
286
|
+
|
287
|
+
keep_merge!(dependencies_through_cml_group(&block), @dependencies_on_fields)
|
288
|
+
return @dependencies_on_fields if only_if.empty? || detect_liquid_logic(only_if) || @logic_tree.nil?
|
289
|
+
|
290
|
+
detect_grouped_logic(only_if)
|
291
|
+
|
292
|
+
expanded = expand_logic(only_if, &block)
|
293
|
+
|
294
|
+
keep_merge!(expanded, @dependencies_on_fields)
|
295
|
+
@dependencies_on_fields
|
296
|
+
end
|
297
|
+
|
298
|
+
# Returns array of field names that this tag depends on with "only-if"
|
299
|
+
# logic.
|
300
|
+
def depends_on_fields
|
301
|
+
return @depends_on_fields if @depends_on_fields
|
302
|
+
@depends_on_fields = []
|
303
|
+
|
304
|
+
dependencies_on_fields do |name, descs|
|
305
|
+
@depends_on_fields << name
|
306
|
+
end
|
307
|
+
|
308
|
+
@depends_on_fields
|
309
|
+
end
|
310
|
+
|
311
|
+
# The <cml::group> only-if logic dependencies
|
312
|
+
#
|
313
|
+
# The optional block can be used as a flat collector of all fields; its return value is ignored.
|
314
|
+
#
|
315
|
+
def dependencies_through_cml_group(&block)
|
316
|
+
return @dependencies_through_cml_group if @dependencies_through_cml_group
|
317
|
+
@dependencies_through_cml_group = {}
|
318
|
+
|
319
|
+
@logic_tree.parser.each_cml_group_descendant do |group_node, cml_tag, i|
|
320
|
+
next if group_node.attributes['only-if'].nil? ||
|
321
|
+
group_node.attributes['only-if'].value.size <= 0 ||
|
322
|
+
self != cml_tag
|
323
|
+
group_only_if = group_node.attributes['only-if'].value.to_s
|
324
|
+
|
325
|
+
expanded = expand_logic(group_only_if, &block)
|
326
|
+
keep_merge!(expanded, @dependencies_through_cml_group)
|
327
|
+
end
|
328
|
+
|
329
|
+
@dependencies_through_cml_group
|
330
|
+
end
|
331
|
+
|
332
|
+
# For an only-if value, e.g, `omg`, `!omg`, `omg:[0]`, `omg:[wtf]`, etc.,
|
333
|
+
# return an array of the referenced name & an array of hashes of logic properties.
|
334
|
+
def describe_logic_token(logic_token)
|
335
|
+
field_name, match_key = logic_token.split( ":" )
|
336
|
+
|
337
|
+
# detect NOT logic
|
338
|
+
is_not = if /^!/===field_name
|
339
|
+
field_name = field_name.gsub( /^!/, '' )
|
340
|
+
true
|
341
|
+
else
|
342
|
+
false
|
343
|
+
end
|
344
|
+
|
345
|
+
unless @logic_tree.nodes[field_name]
|
346
|
+
raise CML::TagLogic::NodeNotFound, "CML element '#{name}' contains only-if logic that references a missing field '#{field_name}'."
|
347
|
+
end
|
348
|
+
|
349
|
+
descs = []
|
350
|
+
|
351
|
+
if match_key
|
352
|
+
# unwrap the match key from it's square brackets
|
353
|
+
match_key = match_key.gsub( /^\[([^\]]+)\]$/, "\\1")
|
354
|
+
|
355
|
+
# when an integer index, set `match_key` to the literal value in the parent tag
|
356
|
+
if /^\d+$/===match_key
|
357
|
+
unless @logic_tree.nodes[field_name].tag.children
|
358
|
+
raise CML::TagLogic::NodeNotFound, "CML element '#{name}' contains only-if logic that references a child index, '#{field_name}:[#{match_key}]', but '#{field_name}' contains no child elements."
|
359
|
+
end
|
360
|
+
if (tag_at_index = @logic_tree.nodes[field_name].tag.children[$~.to_s.to_i])
|
361
|
+
descs << { :is_not => is_not, :match_key => tag_at_index.value }
|
362
|
+
end
|
363
|
+
|
364
|
+
# when 'unchecked' logic "is not the checkbox's value or any of the checkboxes/checkbox values";
|
365
|
+
# is_not logic gets inverted instead of just setting true, just incase we're notting a not
|
366
|
+
elsif 'unchecked'==match_key
|
367
|
+
if @logic_tree.nodes[field_name].tag.tag == 'checkbox'
|
368
|
+
checkbox = @logic_tree.nodes[field_name].tag
|
369
|
+
descs << { :is_not => !is_not, :match_key => checkbox.value }
|
370
|
+
else
|
371
|
+
unless @logic_tree.nodes[field_name].tag.children
|
372
|
+
raise CML::TagLogic::NodeNotFound, "CML element '#{name}' contains only-if logic that references a checked value, '#{field_name}:#{match_key}', but '#{field_name}' is not a checkbox nor contains checkboxes."
|
373
|
+
end
|
374
|
+
@logic_tree.nodes[field_name].tag.children.each do |child|
|
375
|
+
next unless child.tag == 'checkbox'
|
376
|
+
descs << { :is_not => !is_not, :match_key => child.value }
|
377
|
+
end
|
378
|
+
end
|
379
|
+
end
|
380
|
+
end
|
381
|
+
|
382
|
+
if descs.empty?
|
383
|
+
# When matcher is nil, invert the is_not logic to be semantically accurate.
|
384
|
+
#
|
385
|
+
# i.e. a nil match key actually means "is not blank,"
|
386
|
+
# while a negated nil matcher actually means "is blank"
|
387
|
+
#
|
388
|
+
is_not_with_nil = match_key.nil? ? !is_not : is_not
|
389
|
+
descs << { :is_not => is_not_with_nil, :match_key => match_key }
|
390
|
+
end
|
391
|
+
|
392
|
+
[field_name, descs]
|
393
|
+
end
|
394
|
+
|
395
|
+
# Detect parenthetical grouping in only-if logic expressions
|
396
|
+
def detect_grouped_logic(only_if)
|
397
|
+
@errors ||= []
|
398
|
+
if /\([^\(\)]+\)/===only_if
|
399
|
+
@has_grouped_logic = true
|
400
|
+
end
|
401
|
+
end
|
402
|
+
|
403
|
+
# Detect Liquid tags in only-if logic expressions
|
404
|
+
def detect_liquid_logic(only_if)
|
405
|
+
@errors ||= []
|
406
|
+
if /\{\{/===only_if
|
407
|
+
@errors << "The logic tree cannot be constructed when only-if contains a Liquid tag: #{only_if}"
|
408
|
+
@has_liquid_logic = true
|
409
|
+
end
|
410
|
+
end
|
411
|
+
|
412
|
+
|
413
|
+
def keep_merge!(hash, target)
|
414
|
+
hash.keys.each do |key|
|
415
|
+
if hash[key].is_a? Hash and self[key].is_a? Hash
|
416
|
+
target[key] = target[key].keep_merge(hash[key])
|
417
|
+
next
|
418
|
+
end
|
419
|
+
target.update(hash) { |key, *values| values.flatten.uniq }
|
420
|
+
end
|
421
|
+
target
|
422
|
+
end
|
423
|
+
end
|
424
|
+
end
|
@@ -0,0 +1,107 @@
|
|
1
|
+
module CML
|
2
|
+
class LogicTree
|
3
|
+
|
4
|
+
# Generates a DAG, directed acyclic graph of the logic tree.
|
5
|
+
class Graph
|
6
|
+
|
7
|
+
# These Errors cause CML::Parser#valid? failures; their messages are
|
8
|
+
# end-user-facing and should help the user correct their mistakes.
|
9
|
+
class Error < StandardError; end
|
10
|
+
class UnresolvableDependency < Error; end
|
11
|
+
|
12
|
+
attr_accessor :logic_tree, :structure
|
13
|
+
|
14
|
+
def initialize(logic_tree)
|
15
|
+
raise(ArgumentError, "Requires a CML::LogicTree, instead got #{logic_tree.inspect}") unless CML::LogicTree===logic_tree
|
16
|
+
@logic_tree = logic_tree
|
17
|
+
create_structure
|
18
|
+
end
|
19
|
+
|
20
|
+
# Generate a hash of field names with hashes of properties, e.g.:
|
21
|
+
# {
|
22
|
+
# "omg"=>{
|
23
|
+
# :outbound_count=>1, :outbound_names=>["w_t_f"] },
|
24
|
+
# "w_t_f"=>{
|
25
|
+
# :inbound_count=>1, :inbound_names=>["omg"], :inbound_matches=>{"||" => [{"omg"=>[{:match_key => nil, :is_not=>true}]}]} }
|
26
|
+
# }
|
27
|
+
#
|
28
|
+
# For inbound matches, the match key tests the value of the remote vertex. Boolean combinators are represented here.
|
29
|
+
#
|
30
|
+
def create_structure
|
31
|
+
# First, inbound links.
|
32
|
+
@structure = map_dependencies do |field, cml_tag, dependency_names, dependencies|
|
33
|
+
if dependencies
|
34
|
+
dependency_names.uniq!
|
35
|
+
{ :inbound_count => dependency_names.size, :inbound_names => dependency_names, :inbound_matches => dependencies }
|
36
|
+
else
|
37
|
+
{}
|
38
|
+
end
|
39
|
+
end
|
40
|
+
# Map outbound links by iterating over each node's inbound.
|
41
|
+
@structure.each do |vertex, props|
|
42
|
+
next if props.nil? || props[:inbound_names].nil?
|
43
|
+
props[:inbound_names].each do |inbound_name|
|
44
|
+
x = @structure[inbound_name]
|
45
|
+
c, n = x[:outbound_count], x[:outbound_names]
|
46
|
+
x[:outbound_names] = (n ? n<<vertex : [vertex]).sort.uniq
|
47
|
+
x[:outbound_count] = x[:outbound_names].size
|
48
|
+
end
|
49
|
+
end
|
50
|
+
@structure
|
51
|
+
end
|
52
|
+
|
53
|
+
# Returns a hash of the logic tree's nodes.
|
54
|
+
#
|
55
|
+
# When a block is given, each node's entry is set to the return value of the block.
|
56
|
+
#
|
57
|
+
def map_dependencies
|
58
|
+
structure = {}
|
59
|
+
deferred_vertex_procs = []
|
60
|
+
deferred_vertex_counts = Hash.new(0)
|
61
|
+
created_vertex_for_fields = []
|
62
|
+
@logic_tree.nodes.select {|k,v| v.tag.in_logic_graph? }.each do |field, cml_tag|
|
63
|
+
|
64
|
+
# This proc is used to generate each vertex.
|
65
|
+
map_vertex = lambda do |cml_tag, dependency_names, dependencies|
|
66
|
+
if block_given?
|
67
|
+
structure[field] = yield(field, cml_tag, dependency_names, dependencies)
|
68
|
+
else
|
69
|
+
structure[field] = { :cml_tag => cml_tag, :dependency_names => dependency_names, :dependencies => dependencies }
|
70
|
+
end
|
71
|
+
created_vertex_for_fields << field
|
72
|
+
end
|
73
|
+
|
74
|
+
# Expand dependencies & also collect a simple list of dependency names.
|
75
|
+
dependency_names = []
|
76
|
+
dependencies = cml_tag.dependencies_on {|name, descs| dependency_names << name }
|
77
|
+
|
78
|
+
# Defer when a CML tag has unsatisfied dependencies, or just generate its vertex.
|
79
|
+
if dependency_names.empty?
|
80
|
+
map_vertex.call(cml_tag, nil, nil)
|
81
|
+
else
|
82
|
+
deferred_vertex_procs << lambda do |this_proc|
|
83
|
+
if dependency_names.all? {|name| created_vertex_for_fields.include? name }
|
84
|
+
map_vertex.call(cml_tag, dependency_names, dependencies)
|
85
|
+
else
|
86
|
+
# Push onto bottom of stack to try again last.
|
87
|
+
raise(UnresolvableDependency, "CML element '#{field}' contains invalid only-if logic.") if
|
88
|
+
deferred_vertex_counts[field] > DEPTH_LIMIT
|
89
|
+
deferred_vertex_counts[field] += 1
|
90
|
+
deferred_vertex_procs.unshift(this_proc)
|
91
|
+
end
|
92
|
+
end
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
# Iterate through deferred dependencies, filling out the structure as dependencies are satisfied.
|
97
|
+
while !deferred_vertex_procs.empty? do
|
98
|
+
p = deferred_vertex_procs.pop
|
99
|
+
p.call(p) # pass the Proc itself, in case it needs to defer again
|
100
|
+
end
|
101
|
+
|
102
|
+
structure
|
103
|
+
end
|
104
|
+
|
105
|
+
end
|
106
|
+
end
|
107
|
+
end
|
@@ -0,0 +1,43 @@
|
|
1
|
+
module CML
|
2
|
+
class LogicTree
|
3
|
+
|
4
|
+
# A utility to solve the logic structures provided by the logic tree.
|
5
|
+
#
|
6
|
+
class Solver
|
7
|
+
|
8
|
+
# Traverse the tree in logic order, until a solution is found.
|
9
|
+
#
|
10
|
+
# A block is required to process each node, returning nil/false or a solution.
|
11
|
+
#
|
12
|
+
# Returns one value for a single-OR solution or an array for multiple-AND solutions.
|
13
|
+
#
|
14
|
+
def self.recurse(dependencies, dependent_name, &block)
|
15
|
+
solution = nil
|
16
|
+
dependencies.each do |k,v|
|
17
|
+
s = case k
|
18
|
+
when '||'
|
19
|
+
ss = nil
|
20
|
+
v.detect {|vv| ss = recurse(vv, dependent_name, &block) }
|
21
|
+
ss if ss
|
22
|
+
when '&&'
|
23
|
+
catch(:unfullfilled_and) do
|
24
|
+
v.collect do |vv|
|
25
|
+
rr = recurse(vv, dependent_name, &block)
|
26
|
+
throw(:unfullfilled_and) unless rr
|
27
|
+
rr
|
28
|
+
end
|
29
|
+
end
|
30
|
+
else
|
31
|
+
yield(dependent_name, k, v[0]) # for a non-boolean node, use the first & only value
|
32
|
+
end
|
33
|
+
if s
|
34
|
+
solution = s
|
35
|
+
break
|
36
|
+
end
|
37
|
+
end
|
38
|
+
solution
|
39
|
+
end
|
40
|
+
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
data/lib/cml/parser.rb
CHANGED
@@ -1,7 +1,14 @@
|
|
1
1
|
module CML
|
2
2
|
class Parser
|
3
|
-
|
4
|
-
|
3
|
+
|
4
|
+
include CML::ParserLogic
|
5
|
+
|
6
|
+
attr_reader :doc, :cftags, :tags, :errors, :cml_tag_map
|
7
|
+
|
8
|
+
TAG_XPATH = '//cml:*'
|
9
|
+
BASE_TAGS_XPATH = ".#{TAG_XPATH}[not(ancestor::cml:checkboxes or ancestor::cml:ratings or ancestor::cml:select or ancestor::cml:radios or self::cml:gold or self::cml:instructions)]"
|
10
|
+
GROUP_XPATH = "//cml:group"
|
11
|
+
GROUP_DESCENDANTS_XPATH = ".#{GROUP_XPATH}#{TAG_XPATH}"
|
5
12
|
|
6
13
|
def initialize(content, opts = {})
|
7
14
|
@opts = opts.merge(:parser => self)
|
@@ -16,8 +23,10 @@ module CML
|
|
16
23
|
#Pull all cml tags that aren't children
|
17
24
|
@cftags = @doc.xpath(BASE_TAGS_XPATH, "xmlns:cml"=>"http://crowdflower.com")
|
18
25
|
normalize if opts[:normalize]
|
26
|
+
@cml_tag_map = {}
|
19
27
|
@tags = @cftags.map do |t|
|
20
|
-
|
28
|
+
cml_tag = Parser.tag_class(t.name).new(t, @opts)
|
29
|
+
@cml_tag_map[t.object_id] = cml_tag
|
21
30
|
end.flatten
|
22
31
|
end
|
23
32
|
|
@@ -31,13 +40,31 @@ module CML
|
|
31
40
|
def convert(opts = nil)
|
32
41
|
@opts.merge!(opts) if opts
|
33
42
|
cloned = @doc.dup
|
34
|
-
|
35
|
-
|
36
|
-
|
43
|
+
|
44
|
+
base_nodes = cloned.xpath(BASE_TAGS_XPATH, "xmlns:cml"=>"http://crowdflower.com")
|
45
|
+
group_children_nodes = cloned.xpath(GROUP_DESCENDANTS_XPATH, "xmlns:cml"=>"http://crowdflower.com")
|
46
|
+
|
47
|
+
(base_nodes - group_children_nodes).each_with_index do |node, i|
|
48
|
+
node.namespace = nil
|
49
|
+
real_index = base_nodes.index( node )
|
50
|
+
node.replace(self.tags[real_index].convert(opts))
|
37
51
|
end
|
38
52
|
cloned
|
39
53
|
end
|
40
54
|
|
55
|
+
# Do something for each <cml::group> descendant "base" CML::Tag
|
56
|
+
def each_cml_group_descendant
|
57
|
+
return unless block_given?
|
58
|
+
group_nodes = @doc.xpath(".#{GROUP_XPATH}", "xmlns:cml"=>"http://crowdflower.com")
|
59
|
+
group_nodes.each do |group_node|
|
60
|
+
group_descendant_nodes = group_node.xpath(BASE_TAGS_XPATH, "xmlns:cml"=>"http://crowdflower.com")
|
61
|
+
group_descendant_nodes.each_with_index do |node, i|
|
62
|
+
cml_tag = @cml_tag_map[node.object_id]
|
63
|
+
yield group_node, cml_tag, i
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
41
68
|
def normalize
|
42
69
|
@cftags.each do |t|
|
43
70
|
if ["radios", "select"].include?(t.name)
|
@@ -59,6 +86,16 @@ module CML
|
|
59
86
|
@fields
|
60
87
|
end
|
61
88
|
|
89
|
+
def finite_fields
|
90
|
+
return @finite_fields if @finite_fields
|
91
|
+
@finite_fields = {}
|
92
|
+
@tags.each do |t|
|
93
|
+
next unless t.finite_value?
|
94
|
+
@finite_fields[t.name] = t
|
95
|
+
end
|
96
|
+
@finite_fields
|
97
|
+
end
|
98
|
+
|
62
99
|
def golds(rich = false, opts={})
|
63
100
|
unless @golds
|
64
101
|
@golds = @tags.map do |tag|
|
@@ -108,6 +145,9 @@ module CML
|
|
108
145
|
@errors << "#{c} a child of #{t.to_s.split("\n")[0]} has a duplicated value, please specify a unique value attribute."
|
109
146
|
end
|
110
147
|
end
|
148
|
+
if !logic_tree.valid?
|
149
|
+
@errors += logic_tree.errors
|
150
|
+
end
|
111
151
|
@errors.length == 0
|
112
152
|
end
|
113
153
|
|
@@ -151,7 +191,7 @@ module CML
|
|
151
191
|
gsub( />/, ">" ).
|
152
192
|
gsub( /"/, """ )
|
153
193
|
end
|
154
|
-
|
194
|
+
|
155
195
|
#This takes the name of the tag and converts it to the appropriate tag class
|
156
196
|
def self.tag_class(name)
|
157
197
|
CML::TagClasses[name] || CML::Tags::Unknown
|