check_please 0.4.1 → 0.5.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.gitignore +3 -1
- data/Gemfile.lock +1 -1
- data/README.md +284 -24
- data/Rakefile +46 -3
- data/bin/gh-md-toc +350 -0
- data/lib/check_please.rb +41 -25
- data/lib/check_please/comparison.rb +75 -14
- data/lib/check_please/diffs.rb +4 -0
- data/lib/check_please/error.rb +20 -0
- data/lib/check_please/flag.rb +13 -3
- data/lib/check_please/path.rb +121 -14
- data/lib/check_please/path_segment.rb +88 -0
- data/lib/check_please/reification.rb +50 -0
- data/lib/check_please/version.rb +1 -1
- data/usage_examples.rb +8 -2
- metadata +5 -2
@@ -42,23 +42,84 @@ module CheckPlease
|
|
42
42
|
end
|
43
43
|
|
44
44
|
def compare_arrays(ref_array, can_array, path)
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
ref = ref_array[i]
|
51
|
-
can = can_array[i]
|
52
|
-
|
53
|
-
case
|
54
|
-
when ref_array.length < n ; record_diff ref, can, new_path, :extra
|
55
|
-
when can_array.length < n ; record_diff ref, can, new_path, :missing
|
56
|
-
else
|
57
|
-
compare ref, can, new_path
|
58
|
-
end
|
45
|
+
if ( key = path.key_for_compare(flags) )
|
46
|
+
compare_arrays_by_key ref_array, can_array, path, key
|
47
|
+
else
|
48
|
+
compare_arrays_by_index ref_array, can_array, path
|
59
49
|
end
|
60
50
|
end
|
61
51
|
|
52
|
+
def compare_arrays_by_key(ref_array, can_array, path, key_name)
|
53
|
+
refs_by_key = index_array!(ref_array, path, key_name, "reference")
|
54
|
+
cans_by_key = index_array!(can_array, path, key_name, "candidate")
|
55
|
+
key_values = (refs_by_key.keys | cans_by_key.keys)
|
56
|
+
|
57
|
+
key_values.compact! # NOTE: will break if nil is ever used as a key (but WHO WOULD DO THAT?!)
|
58
|
+
key_values.sort!
|
59
|
+
|
60
|
+
key_values.each do |key_value|
|
61
|
+
new_path = path + "#{key_name}=#{key_value}"
|
62
|
+
ref = refs_by_key[key_value]
|
63
|
+
can = cans_by_key[key_value]
|
64
|
+
case
|
65
|
+
when ref.nil? ; record_diff ref, can, new_path, :extra
|
66
|
+
when can.nil? ; record_diff ref, can, new_path, :missing
|
67
|
+
else ; compare ref, can, new_path
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
def index_array!(array_of_hashes, path, key_name, ref_or_can)
|
73
|
+
elements_by_key = {}
|
74
|
+
|
75
|
+
array_of_hashes.each.with_index do |h, i|
|
76
|
+
# make sure we have a hash
|
77
|
+
unless h.is_a?(Hash)
|
78
|
+
raise CheckPlease::TypeMismatchError, \
|
79
|
+
"The element at position #{i} in the #{ref_or_can} array is not a hash."
|
80
|
+
end
|
81
|
+
|
82
|
+
# try to get the value of the attribute identified by key_name
|
83
|
+
key_value = h.fetch(key_name) {
|
84
|
+
raise CheckPlease::NoSuchKeyError, \
|
85
|
+
<<~EOF
|
86
|
+
The #{ref_or_can} hash at position #{i} has no #{key_name.inspect} key.
|
87
|
+
Keys it does have: #{h.keys.inspect}
|
88
|
+
EOF
|
89
|
+
}
|
90
|
+
|
91
|
+
# complain about dupes
|
92
|
+
if elements_by_key.has_key?(key_value)
|
93
|
+
key_val_expr = "#{key_name}=#{key_value}"
|
94
|
+
raise CheckPlease::DuplicateKeyError, \
|
95
|
+
"Duplicate #{ref_or_can} element found at path '#{path + key_val_expr}'."
|
96
|
+
end
|
97
|
+
|
98
|
+
# ok, now we can proceed
|
99
|
+
elements_by_key[key_value] = h
|
100
|
+
end
|
101
|
+
|
102
|
+
elements_by_key
|
103
|
+
end
|
104
|
+
|
105
|
+
def compare_arrays_by_index(ref_array, can_array, path)
|
106
|
+
max_len = [ ref_array, can_array ].map(&:length).max
|
107
|
+
(0...max_len).each do |i|
|
108
|
+
n = i + 1 # count in human pls
|
109
|
+
new_path = path + n
|
110
|
+
|
111
|
+
ref = ref_array[i]
|
112
|
+
can = can_array[i]
|
113
|
+
|
114
|
+
case
|
115
|
+
when ref_array.length < n ; record_diff ref, can, new_path, :extra
|
116
|
+
when can_array.length < n ; record_diff ref, can, new_path, :missing
|
117
|
+
else
|
118
|
+
compare ref, can, new_path
|
119
|
+
end
|
120
|
+
end
|
121
|
+
end
|
122
|
+
|
62
123
|
def compare_hashes(ref_hash, can_hash, path)
|
63
124
|
record_missing_keys ref_hash, can_hash, path
|
64
125
|
compare_common_keys ref_hash, can_hash, path
|
data/lib/check_please/diffs.rb
CHANGED
data/lib/check_please/error.rb
CHANGED
@@ -6,8 +6,28 @@ module CheckPlease
|
|
6
6
|
# instead....
|
7
7
|
end
|
8
8
|
|
9
|
+
class DuplicateKeyError < ::IndexError
|
10
|
+
include CheckPlease::Error
|
11
|
+
end
|
12
|
+
|
9
13
|
class InvalidFlag < ArgumentError
|
10
14
|
include CheckPlease::Error
|
11
15
|
end
|
12
16
|
|
17
|
+
class InvalidPath < ArgumentError
|
18
|
+
include CheckPlease::Error
|
19
|
+
end
|
20
|
+
|
21
|
+
class InvalidPathSegment < ArgumentError
|
22
|
+
include CheckPlease::Error
|
23
|
+
end
|
24
|
+
|
25
|
+
class NoSuchKeyError < ::KeyError
|
26
|
+
include CheckPlease::Error
|
27
|
+
end
|
28
|
+
|
29
|
+
class TypeMismatchError < ::TypeError
|
30
|
+
include CheckPlease::Error
|
31
|
+
end
|
32
|
+
|
13
33
|
end
|
data/lib/check_please/flag.rb
CHANGED
@@ -29,12 +29,22 @@ module CheckPlease
|
|
29
29
|
@coercer = block
|
30
30
|
end
|
31
31
|
|
32
|
+
def description=(value)
|
33
|
+
if value.is_a?(String) && value =~ /\n/m
|
34
|
+
lines = value.lines
|
35
|
+
else
|
36
|
+
lines = Array(value).map(&:to_s)
|
37
|
+
end
|
38
|
+
|
39
|
+
@description = lines.map(&:rstrip)
|
40
|
+
end
|
41
|
+
|
32
42
|
def mutually_exclusive_to(flag_name)
|
33
43
|
@validators << ->(flags, _) { flags.send(flag_name).empty? }
|
34
44
|
end
|
35
45
|
|
36
|
-
def
|
37
|
-
@
|
46
|
+
def repeatable
|
47
|
+
@repeatable = true
|
38
48
|
self.default_proc = ->{ Array.new }
|
39
49
|
end
|
40
50
|
|
@@ -47,7 +57,7 @@ module CheckPlease
|
|
47
57
|
def __set__(value, on:, flags:)
|
48
58
|
val = _coerce(value)
|
49
59
|
_validate(flags, val)
|
50
|
-
if @
|
60
|
+
if @repeatable
|
51
61
|
on[name] ||= []
|
52
62
|
on[name].concat(Array(val))
|
53
63
|
else
|
data/lib/check_please/path.rb
CHANGED
@@ -1,29 +1,74 @@
|
|
1
1
|
module CheckPlease
|
2
2
|
|
3
|
+
# TODO: this class is getting a bit large; maybe split out some of the stuff that uses flags?
|
3
4
|
class Path
|
5
|
+
include CheckPlease::Reification
|
6
|
+
can_reify String, Symbol, Numeric, nil
|
7
|
+
|
4
8
|
SEPARATOR = "/"
|
5
9
|
|
6
10
|
def self.root
|
7
|
-
new
|
11
|
+
new('/')
|
8
12
|
end
|
9
13
|
|
10
|
-
|
11
|
-
|
14
|
+
|
15
|
+
|
16
|
+
attr_reader :to_s, :segments
|
17
|
+
def initialize(name_or_segments = [])
|
18
|
+
case name_or_segments
|
19
|
+
when String, Symbol, Numeric, nil
|
20
|
+
names = name_or_segments.to_s.split(SEPARATOR)
|
21
|
+
names.shift until names.empty? || names.first =~ /\S/
|
22
|
+
segments = PathSegment.reify(names)
|
23
|
+
when Array
|
24
|
+
segments = PathSegment.reify(name_or_segments)
|
25
|
+
else
|
26
|
+
raise InvalidPath, "not sure what to do with #{name_or_segments.inspect}"
|
27
|
+
end
|
28
|
+
|
29
|
+
if segments.any?(&:empty?)
|
30
|
+
raise InvalidPath, "#{self.class.name} cannot contain empty segments"
|
31
|
+
end
|
32
|
+
|
12
33
|
@segments = Array(segments)
|
34
|
+
|
13
35
|
@to_s = SEPARATOR + @segments.join(SEPARATOR)
|
14
36
|
freeze
|
37
|
+
rescue InvalidPathSegment => e
|
38
|
+
raise InvalidPath, e.message
|
15
39
|
end
|
16
40
|
|
17
41
|
def +(new_basename)
|
18
|
-
self.
|
42
|
+
new_segments = self.segments.dup
|
43
|
+
new_segments << new_basename # don't reify here; it'll get done on Path#initialize
|
44
|
+
self.class.new(new_segments)
|
45
|
+
end
|
46
|
+
|
47
|
+
def ==(other)
|
48
|
+
self.to_s == other.to_s
|
49
|
+
end
|
50
|
+
|
51
|
+
def ancestors
|
52
|
+
list = []
|
53
|
+
p = self
|
54
|
+
loop do
|
55
|
+
break if p.root?
|
56
|
+
p = p.parent
|
57
|
+
list.unshift p
|
58
|
+
end
|
59
|
+
list.reverse
|
60
|
+
end
|
61
|
+
|
62
|
+
def basename
|
63
|
+
segments.last.to_s
|
19
64
|
end
|
20
65
|
|
21
66
|
def depth
|
22
|
-
1 +
|
67
|
+
1 + segments.length
|
23
68
|
end
|
24
69
|
|
25
70
|
def excluded?(flags)
|
26
|
-
return false if root?
|
71
|
+
return false if root? # that would just be silly
|
27
72
|
|
28
73
|
return true if too_deep?(flags)
|
29
74
|
return true if explicitly_excluded?(flags)
|
@@ -33,33 +78,95 @@ module CheckPlease
|
|
33
78
|
end
|
34
79
|
|
35
80
|
def inspect
|
36
|
-
"
|
81
|
+
"<#{self.class.name} '#{to_s}'>"
|
82
|
+
end
|
83
|
+
|
84
|
+
# TODO: Naming Things
|
85
|
+
def key_for_compare(flags)
|
86
|
+
mbk_exprs = unpack_mbk_exprs(flags)
|
87
|
+
matches = mbk_exprs.select { |mbk_expr|
|
88
|
+
# NOTE: matching on parent because MBK '/foo/:id' should return 'id' for path '/foo'
|
89
|
+
mbk_expr.parent.match?(self)
|
90
|
+
}
|
91
|
+
|
92
|
+
case matches.length
|
93
|
+
when 0 ; nil
|
94
|
+
when 1 ; matches.first.segments.last.key
|
95
|
+
else ; raise "More than one match_by_key expression for path '#{self}': #{matches.map(&:to_s).inspect}"
|
96
|
+
end
|
97
|
+
end
|
98
|
+
|
99
|
+
def match?(path_or_string)
|
100
|
+
# If the strings are literally equal, we're good..
|
101
|
+
return true if self == path_or_string
|
102
|
+
|
103
|
+
# Otherwise, compare segments: do we have the same number, and do they all #match?
|
104
|
+
other = reify(path_or_string)
|
105
|
+
return false if other.depth != self.depth
|
106
|
+
|
107
|
+
seg_pairs = self.segments.zip(other.segments)
|
108
|
+
seg_pairs.all? { |a, b| a.match?(b) }
|
109
|
+
end
|
110
|
+
|
111
|
+
def parent
|
112
|
+
return nil if root? # TODO: consider the Null Object pattern
|
113
|
+
self.class.new(segments[0..-2])
|
37
114
|
end
|
38
115
|
|
39
116
|
def root?
|
40
|
-
|
117
|
+
@segments.empty?
|
41
118
|
end
|
42
119
|
|
43
120
|
private
|
44
121
|
|
122
|
+
# O(n^2) check to see if any of the path's ancestors are on a list
|
123
|
+
# (as of this writing, this should never actually happen, but I'm being thorough)
|
124
|
+
def ancestor_on_list?(paths)
|
125
|
+
paths.any? { |path|
|
126
|
+
ancestors.any? { |ancestor| ancestor == path }
|
127
|
+
}
|
128
|
+
end
|
129
|
+
|
45
130
|
def explicitly_excluded?(flags)
|
46
|
-
flags.reject_paths.
|
131
|
+
return false if flags.reject_paths.empty?
|
132
|
+
return true if self_on_list?(flags.reject_paths)
|
133
|
+
return true if ancestor_on_list?(flags.reject_paths)
|
134
|
+
false
|
47
135
|
end
|
48
136
|
|
49
137
|
def implicitly_excluded?(flags)
|
50
138
|
return false if flags.select_paths.empty?
|
51
|
-
flags.select_paths
|
139
|
+
return false if self_on_list?(flags.select_paths)
|
140
|
+
return false if ancestor_on_list?(flags.select_paths)
|
141
|
+
true
|
142
|
+
end
|
143
|
+
|
144
|
+
# A path of "/foo/:id/bar/:name" has two key expressions:
|
145
|
+
# - "/foo/:id"
|
146
|
+
# - "/foo/:id/bar/:name"
|
147
|
+
def key_exprs
|
148
|
+
( [self] + ancestors )
|
149
|
+
.reject { |path| path.root? }
|
150
|
+
.select { |path| path.segments.last&.key_expr? }
|
52
151
|
end
|
53
152
|
|
54
|
-
#
|
55
|
-
def
|
56
|
-
|
153
|
+
# O(n) check to see if the path itself is on a list
|
154
|
+
def self_on_list?(paths)
|
155
|
+
paths.any? { |path| self == path }
|
57
156
|
end
|
58
157
|
|
59
158
|
def too_deep?(flags)
|
60
159
|
return false if flags.max_depth.nil?
|
61
|
-
flags.max_depth
|
160
|
+
depth > flags.max_depth
|
62
161
|
end
|
162
|
+
|
163
|
+
def unpack_mbk_exprs(flags)
|
164
|
+
flags.match_by_key
|
165
|
+
.map { |path| path.send(:key_exprs) }
|
166
|
+
.flatten
|
167
|
+
.uniq { |e| e.to_s } # use the block form so we don't have to implement #hash and #eql? in horrible ways
|
168
|
+
end
|
169
|
+
|
63
170
|
end
|
64
171
|
|
65
172
|
end
|
@@ -0,0 +1,88 @@
|
|
1
|
+
module CheckPlease
|
2
|
+
|
3
|
+
class PathSegment
|
4
|
+
include CheckPlease::Reification
|
5
|
+
can_reify String, Symbol, Numeric, nil
|
6
|
+
|
7
|
+
KEY_EXPR = %r{
|
8
|
+
^
|
9
|
+
\: # a literal colon
|
10
|
+
( # capture key
|
11
|
+
[^\:]+ # followed by one or more things that aren't colons
|
12
|
+
) # end capture key
|
13
|
+
$
|
14
|
+
}x
|
15
|
+
|
16
|
+
KEY_VAL_EXPR = %r{
|
17
|
+
^
|
18
|
+
( # capture key
|
19
|
+
[^=]+ # stuff (just not an equal sign)
|
20
|
+
) # end capture key
|
21
|
+
\= # an equal sign
|
22
|
+
( # capture key value
|
23
|
+
[^=]+ # stuff (just not an equal sign)
|
24
|
+
) # end capture key value
|
25
|
+
$
|
26
|
+
}x
|
27
|
+
|
28
|
+
attr_reader :name, :key, :key_value
|
29
|
+
alias_method :to_s, :name
|
30
|
+
|
31
|
+
def initialize(name = nil)
|
32
|
+
@name = name.to_s.strip
|
33
|
+
if @name =~ %r(\s) # has any whitespace
|
34
|
+
raise InvalidPathSegment, <<~EOF
|
35
|
+
#{name.inspect} is not a valid #{self.class} name
|
36
|
+
EOF
|
37
|
+
end
|
38
|
+
parse_key_and_value
|
39
|
+
freeze
|
40
|
+
end
|
41
|
+
|
42
|
+
def empty?
|
43
|
+
name.empty?
|
44
|
+
end
|
45
|
+
|
46
|
+
def key_expr?
|
47
|
+
name.match?(KEY_EXPR)
|
48
|
+
end
|
49
|
+
|
50
|
+
def key_val_expr?
|
51
|
+
name.match?(KEY_VAL_EXPR)
|
52
|
+
end
|
53
|
+
|
54
|
+
def match?(other_segment_or_string)
|
55
|
+
other = self.class.reify(other_segment_or_string)
|
56
|
+
|
57
|
+
match_types = [ self.match_type, other.match_type ]
|
58
|
+
case match_types
|
59
|
+
when [ :plain, :plain ] ; self.name == other.name
|
60
|
+
when [ :key, :key_value ] ; self.key == other.key
|
61
|
+
when [ :key_value, :key ] ; self.key == other.key
|
62
|
+
else ; false
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
protected
|
67
|
+
|
68
|
+
def match_type
|
69
|
+
return :key if key_expr?
|
70
|
+
return :key_value if key_val_expr?
|
71
|
+
:plain
|
72
|
+
end
|
73
|
+
|
74
|
+
private
|
75
|
+
|
76
|
+
def parse_key_and_value
|
77
|
+
case name
|
78
|
+
when KEY_EXPR
|
79
|
+
@key = $1
|
80
|
+
when KEY_VAL_EXPR
|
81
|
+
@key, @key_value = $1, $2
|
82
|
+
else
|
83
|
+
# :nothingtodohere:
|
84
|
+
end
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
end
|
@@ -0,0 +1,50 @@
|
|
1
|
+
module CheckPlease
|
2
|
+
|
3
|
+
module Reification
|
4
|
+
def self.included(receiver)
|
5
|
+
receiver.extend ClassMethods
|
6
|
+
receiver.send :include, InstanceMethods
|
7
|
+
end
|
8
|
+
|
9
|
+
module ClassMethods
|
10
|
+
def reifiable
|
11
|
+
@_reifiable ||= []
|
12
|
+
end
|
13
|
+
|
14
|
+
def can_reify(*klasses)
|
15
|
+
klasses.flatten!
|
16
|
+
|
17
|
+
unless ( klasses - [nil] ).all? { |e| e.is_a?(Class) }
|
18
|
+
raise ArgumentError, "classes (or nil) only, please"
|
19
|
+
end
|
20
|
+
|
21
|
+
reifiable.concat klasses
|
22
|
+
reifiable.uniq!
|
23
|
+
nil
|
24
|
+
end
|
25
|
+
|
26
|
+
def reify(primitive_or_object)
|
27
|
+
case primitive_or_object
|
28
|
+
when self ; return primitive_or_object
|
29
|
+
when Array ; return primitive_or_object.map { |e| reify(e) }
|
30
|
+
when *reifiable ; return new(primitive_or_object)
|
31
|
+
end
|
32
|
+
# note early return ^^^
|
33
|
+
|
34
|
+
# that didn't work? complain!
|
35
|
+
acceptable = reifiable.map { |e| Class === e ? e.name : e.inspect }
|
36
|
+
raise ArgumentError, <<~EOF
|
37
|
+
#{self}.reify was given: #{primitive_or_object.inspect}
|
38
|
+
but only accepts: #{acceptable.join(", ")}
|
39
|
+
EOF
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
module InstanceMethods
|
44
|
+
def reify(x)
|
45
|
+
self.class.reify(x)
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
end
|