exhale 0.1.0 → 0.2.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.
@@ -0,0 +1,260 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+ require_relative "../units/ruby"
5
+
6
+ module Exhale
7
+ module Complexity
8
+ # One increment of a unit's score.
9
+ #
10
+ # line - Where the construct that earned it sits.
11
+ # construct - What earned it: "if", "elsif", "&&", "each block", "send".
12
+ # nesting - The nesting level at the construct.
13
+ # increment - 1, plus the nesting level for the constructs that pay it.
14
+ # meta - true for a metaprogramming point.
15
+ Point = Struct.new(:line, :construct, :nesting, :increment, :meta, keyword_init: true)
16
+
17
+ # G. Ann Campbell's Cognitive Complexity read off the Prism tree, with the
18
+ # Ruby decisions in contract/cognitive/README.md.
19
+ class Cognitive
20
+ ITERATORS = %i[
21
+ each map flat_map collect filter_map select filter reject find detect find_index find_all
22
+ any? all? none? one? count sum inject reduce group_by partition sort_by min_by max_by minmax_by
23
+ uniq zip take_while drop_while chunk_while slice_when times upto downto step loop cycle
24
+ with_index with_object map! collect! select! filter! reject! sort_by!
25
+ ].freeze
26
+
27
+ METAPROGRAMMING = %i[
28
+ eval instance_eval class_eval module_eval instance_exec class_exec module_exec
29
+ define_method define_singleton_method send __send__ public_send
30
+ instance_variable_get instance_variable_set const_get
31
+ ].freeze
32
+
33
+ def self.points(unit)
34
+ raise ArgumentError, "#{unit.identity} isn't Ruby; templates aren't scored" unless unit.language == :ruby
35
+
36
+ new(unit.node, method_name(unit)).points
37
+ end
38
+
39
+ # The name a recursive call would use. A DSL body has none, and neither
40
+ # does a define_method named only by its ordinal.
41
+ def self.method_name(unit)
42
+ return unless unit.kind == :method
43
+
44
+ name = unit.name.sub(/\[\d+\]\z/, "")
45
+ name unless !unit.node.is_a?(Prism::DefNode) && Units::Ruby::DEFINE_METHODS.key?(name.to_sym)
46
+ end
47
+
48
+ def initialize(node, name)
49
+ @node = node
50
+ @name = name&.to_sym
51
+ end
52
+
53
+ # Sorted by line, then in the order the walk met them.
54
+ def points
55
+ @points = []
56
+ @recursed = false
57
+ root
58
+ @points.each_with_index.sort_by { |point, index| [point.line, index] }.map(&:first)
59
+ end
60
+
61
+ private
62
+
63
+ # The unit's own def, block or lambda is its body, at nesting 0.
64
+ def root
65
+ flat("method_missing", @node.location.start_line, 0, meta: true) if @name == :method_missing
66
+ visit(@node.parameters, 0)
67
+ visit(@node.body, 0)
68
+ end
69
+
70
+ def visit(node, nesting)
71
+ return if node.nil? || node.is_a?(Prism::DefNode)
72
+
73
+ case node
74
+ when Prism::IfNode then if_node(node, nesting)
75
+ when Prism::UnlessNode then unless_node(node, nesting)
76
+ when Prism::WhileNode, Prism::UntilNode then loop_node(node, nesting)
77
+ when Prism::ForNode then for_node(node, nesting)
78
+ when Prism::CaseNode, Prism::CaseMatchNode then case_node(node, nesting)
79
+ when Prism::BeginNode then begin_node(node, nesting)
80
+ when Prism::RescueModifierNode then rescue_modifier(node, nesting)
81
+ when Prism::AndNode, Prism::OrNode then boolean(node, nesting)
82
+ when Prism::CallNode then call(node, nesting)
83
+ when Prism::BlockNode, Prism::LambdaNode then nested_body(node, nesting)
84
+ else children(node, nesting)
85
+ end
86
+ end
87
+
88
+ def children(node, nesting)
89
+ node.compact_child_nodes.each { |child| visit(child, nesting) }
90
+ end
91
+
92
+ def nested(construct, line, nesting)
93
+ @points << Point.new(line: line, construct: construct, nesting: nesting, increment: 1 + nesting, meta: false)
94
+ end
95
+
96
+ def flat(construct, line, nesting, meta: false)
97
+ @points << Point.new(line: line, construct: construct, nesting: nesting, increment: 1, meta: meta)
98
+ end
99
+
100
+ # A ternary has no if keyword, and its else costs nothing.
101
+ def if_node(node, nesting)
102
+ ternary = node.if_keyword_loc.nil?
103
+ nested(ternary ? "ternary" : "if", line(node.if_keyword_loc || node.location), nesting)
104
+ visit(node.predicate, nesting)
105
+ visit(node.statements, nesting + 1)
106
+ ternary ? visit(node.subsequent&.statements, nesting + 1) : branches(node.subsequent, nesting)
107
+ end
108
+
109
+ def branches(branch, nesting)
110
+ case branch
111
+ when Prism::IfNode
112
+ flat("elsif", line(branch.if_keyword_loc), nesting)
113
+ visit(branch.predicate, nesting)
114
+ visit(branch.statements, nesting + 1)
115
+ branches(branch.subsequent, nesting)
116
+ when Prism::ElseNode
117
+ flat("else", line(branch.else_keyword_loc), nesting)
118
+ visit(branch.statements, nesting + 1)
119
+ end
120
+ end
121
+
122
+ def unless_node(node, nesting)
123
+ nested("unless", line(node.keyword_loc), nesting)
124
+ visit(node.predicate, nesting)
125
+ visit(node.statements, nesting + 1)
126
+ branches(node.else_clause, nesting)
127
+ end
128
+
129
+ def loop_node(node, nesting)
130
+ nested(node.keyword_loc.slice, line(node.keyword_loc), nesting)
131
+ visit(node.predicate, nesting)
132
+ visit(node.statements, nesting + 1)
133
+ end
134
+
135
+ def for_node(node, nesting)
136
+ nested("for", line(node.for_keyword_loc), nesting)
137
+ visit(node.index, nesting)
138
+ visit(node.collection, nesting)
139
+ visit(node.statements, nesting + 1)
140
+ end
141
+
142
+ # One charge for the whole case; its branches and else sit inside it.
143
+ def case_node(node, nesting)
144
+ nested(node.is_a?(Prism::CaseMatchNode) ? "case/in" : "case", line(node.case_keyword_loc), nesting)
145
+ visit(node.predicate, nesting)
146
+ node.conditions.each { |condition| case_branch(condition, nesting + 1) }
147
+ visit(node.else_clause&.statements, nesting + 1)
148
+ end
149
+
150
+ # A guard on an `in` branch is an if or unless around the pattern in
151
+ # Prism, but it costs 1 flat.
152
+ def case_branch(condition, nesting)
153
+ guard = condition.pattern if condition.is_a?(Prism::InNode)
154
+ return visit(condition, nesting) unless guard.is_a?(Prism::IfNode) || guard.is_a?(Prism::UnlessNode)
155
+
156
+ keyword = guard.is_a?(Prism::IfNode) ? guard.if_keyword_loc : guard.keyword_loc
157
+ flat("#{keyword.slice} guard", line(keyword), nesting)
158
+ visit(guard.statements, nesting)
159
+ visit(guard.predicate, nesting)
160
+ visit(condition.statements, nesting)
161
+ end
162
+
163
+ # The else of a begin/rescue and its ensure cost nothing and don't nest.
164
+ def begin_node(node, nesting)
165
+ visit(node.statements, nesting)
166
+ clause = node.rescue_clause
167
+ while clause
168
+ nested("rescue", line(clause.keyword_loc), nesting)
169
+ clause.exceptions.each { |exception| visit(exception, nesting) }
170
+ visit(clause.reference, nesting)
171
+ visit(clause.statements, nesting + 1)
172
+ clause = clause.subsequent
173
+ end
174
+ visit(node.else_clause&.statements, nesting)
175
+ visit(node.ensure_clause&.statements, nesting)
176
+ end
177
+
178
+ def rescue_modifier(node, nesting)
179
+ nested("rescue", line(node.keyword_loc), nesting)
180
+ visit(node.expression, nesting)
181
+ visit(node.rescue_expression, nesting + 1)
182
+ end
183
+
184
+ # One point per run of like operators, in source order. `!` and
185
+ # parentheses are see-through, so `a && !(b && c)` is one run.
186
+ def boolean(node, nesting)
187
+ operators = []
188
+ operands = []
189
+ flatten(node, operators, operands)
190
+ operators.each_with_index do |(kind, at), index|
191
+ flat(kind, at, nesting) if index.zero? || operators[index - 1].first != kind
192
+ end
193
+ operands.each { |operand| visit(operand, nesting) }
194
+ end
195
+
196
+ def flatten(node, operators, operands)
197
+ if node.is_a?(Prism::AndNode) || node.is_a?(Prism::OrNode)
198
+ flatten(node.left, operators, operands)
199
+ operators << [node.is_a?(Prism::AndNode) ? "&&" : "||", line(node.operator_loc)]
200
+ flatten(node.right, operators, operands)
201
+ elsif (inner = see_through(node))
202
+ flatten(inner, operators, operands)
203
+ else
204
+ operands << node
205
+ end
206
+ end
207
+
208
+ def see_through(node)
209
+ case node
210
+ when Prism::ParenthesesNode
211
+ body = node.body
212
+ body.is_a?(Prism::StatementsNode) && body.body.size == 1 ? body.body.first : nil
213
+ when Prism::CallNode
214
+ node.receiver if node.name == :! && node.arguments.nil? && node.block.nil?
215
+ end
216
+ end
217
+
218
+ def call(node, nesting)
219
+ at = line(node.message_loc || node.location)
220
+ recursion(node, at, nesting)
221
+ flat(node.name.to_s, at, nesting, meta: true) if METAPROGRAMMING.include?(node.name)
222
+ visit(node.receiver, nesting)
223
+ visit(node.arguments, nesting)
224
+ block(node, nesting)
225
+ end
226
+
227
+ def recursion(node, at, nesting)
228
+ return if @recursed || @name.nil? || node.name != @name
229
+ return unless node.receiver.nil? || node.receiver.is_a?(Prism::SelfNode)
230
+
231
+ @recursed = true
232
+ flat("recursion", at, nesting)
233
+ end
234
+
235
+ # `&:sym` and `&method(:x)` aren't blocks. A define_method block inside
236
+ # a unit is no unit of its own (Units::Ruby stops at a unit), so it
237
+ # scores here like any block.
238
+ def block(node, nesting)
239
+ block = node.block
240
+ return visit(block, nesting) unless block.is_a?(Prism::BlockNode)
241
+
242
+ nested("#{node.name} block", line(block.opening_loc), nesting) if iterates?(node.name)
243
+ nested_body(block, nesting)
244
+ end
245
+
246
+ def nested_body(node, nesting)
247
+ visit(node.parameters, nesting + 1)
248
+ visit(node.body, nesting + 1)
249
+ end
250
+
251
+ def iterates?(name)
252
+ ITERATORS.include?(name) || name.start_with?("each_")
253
+ end
254
+
255
+ def line(location)
256
+ location.start_line
257
+ end
258
+ end
259
+ end
260
+ end
@@ -0,0 +1,181 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "cognitive"
4
+ require_relative "../dry/normalizer"
5
+ require_relative "../dry/fingerprints"
6
+ require_relative "../dry/index"
7
+
8
+ module Exhale
9
+ module Complexity
10
+ # A unit and the points it scored, at the head or at the base.
11
+ Scored = Struct.new(:unit, :points) do
12
+ def self.of(unit)
13
+ new(unit, Cognitive.points(unit))
14
+ end
15
+
16
+ def score
17
+ points.sum(&:increment)
18
+ end
19
+
20
+ def meta
21
+ points.count(&:meta)
22
+ end
23
+ end
24
+
25
+ # One unit's verdict.
26
+ #
27
+ # label - :raised and :introduced fail. :kept rose past the floor
28
+ # within a ceiling. :rose, :new, :unchanged, :contracted and
29
+ # :gone pass. With no base, :warning is over the floor and
30
+ # :scored isn't.
31
+ # head, base - Scored, or nil on the side the unit is missing from.
32
+ # match - :identity or :shape when both sides are there.
33
+ # similarity - The shape score of a :shape match.
34
+ # floor - The floor the unit was judged against.
35
+ # ceiling - The Contract clause that keeps it, or nil.
36
+ Row = Struct.new(:label, :head, :base, :match, :similarity, :floor, :ceiling, keyword_init: true) do
37
+ def failing?
38
+ Ratchet::FAILING.include?(label)
39
+ end
40
+
41
+ def unit
42
+ (head || base).unit
43
+ end
44
+ end
45
+
46
+ # Judges each head unit against its base unit. Only a rise can fail, so
47
+ # code is never punished for what it already was.
48
+ class Ratchet
49
+ FAILING = %i[raised introduced].freeze
50
+ SIMILARITY = Rational(80, 100)
51
+ # A unit that rose, a new unit, and a unit with no base to compare
52
+ # against: its label over its limit, and at or under it.
53
+ OUTCOMES = { rose: %i[raised rose], new: %i[introduced new], alone: %i[warning scored] }.freeze
54
+
55
+ # head and base are Scored lists in path and line order; base is nil
56
+ # when there's nothing to compare against. floor_for and ceiling_for
57
+ # take a unit.
58
+ def initialize(head:, base:, floor_for:, ceiling_for:)
59
+ @head = head
60
+ @base = base
61
+ @floor_for = floor_for
62
+ @ceiling_for = ceiling_for
63
+ end
64
+
65
+ def rows
66
+ return @head.map { |scored| judge(scored) } unless @base
67
+
68
+ pairs = match
69
+ taken = taken(pairs)
70
+ @head.map { |scored| judge(scored, *pairs[scored]) } +
71
+ @base.reject { |base| taken[base] }.map { |base| Row.new(label: :gone, base: base) }
72
+ end
73
+
74
+ private
75
+
76
+ def judge(head, base = nil, match = nil, similarity = nil)
77
+ floor = @floor_for.call(head.unit)
78
+ ceiling = @ceiling_for.call(head.unit)
79
+ Row.new(label: label(head.score, base&.score, floor, ceiling), head: head, base: base, match: match,
80
+ similarity: similarity, floor: floor, ceiling: ceiling)
81
+ end
82
+
83
+ # A ceiling only loosens: its max stands in for the floor when higher.
84
+ def label(score, base_score, floor, ceiling)
85
+ return :contracted if base_score && score < base_score
86
+ return :unchanged if score == base_score
87
+
88
+ over, under = OUTCOMES.fetch(situation(base_score))
89
+ return over if score > (ceiling ? [ceiling.max, floor].max : floor)
90
+
91
+ ceiling && score > floor ? :kept : under
92
+ end
93
+
94
+ def situation(base_score)
95
+ return :alone unless @base
96
+
97
+ base_score ? :rose : :new
98
+ end
99
+
100
+ # {head => [base, how, similarity]}: identity first, then shape.
101
+ def match
102
+ pairs = identity_pairs
103
+ taken = taken(pairs)
104
+ shape_matches(@head.reject { |h| pairs.key?(h) }, @base.reject { |b| taken[b] }).each do |head, base, score|
105
+ pairs[head] = [base, :shape, score]
106
+ end
107
+ pairs
108
+ end
109
+
110
+ def taken(pairs)
111
+ pairs.each_value.with_object({}.compare_by_identity) { |(base, *), taken| taken[base] = true }
112
+ end
113
+
114
+ def identity_pairs
115
+ pairs = {}.compare_by_identity
116
+ bases = @base.group_by { |scored| scored.unit.identity }
117
+ @head.group_by { |scored| scored.unit.identity }.each do |identity, heads|
118
+ pair_up(heads, bases.fetch(identity, [])).each { |head, base| pairs[head] = [base, :identity, nil] }
119
+ end
120
+ pairs
121
+ end
122
+
123
+ # An identity is unique within a file, so a unit pairs with the one in
124
+ # its own file first; the rest pair in path and line order.
125
+ def pair_up(heads, bases)
126
+ same = heads.filter_map { |head| (base = bases.find { |b| b.unit.path == head.unit.path }) && [head, base] }
127
+ rest = (heads - same.map(&:first)).zip(bases - same.map(&:last))
128
+ same + rest.select(&:last)
129
+ end
130
+
131
+ # Every pair at or over the threshold, best first; ties go to the base
132
+ # unit first in path and line order. Each side matches at most once.
133
+ def shape_matches(heads, bases)
134
+ return [] if heads.empty? || bases.empty?
135
+
136
+ entries = entries(heads + bases)
137
+ index = Dry::Index.new(entries)
138
+ greedy(candidates(index, entries.first(heads.size), entries.drop(heads.size)), heads, bases)
139
+ end
140
+
141
+ def entries(scored)
142
+ scored.each_with_index.map do |each, id|
143
+ tree = Dry::Fingerprints.build(Dry::Normalizer.normalize(each.unit))
144
+ Dry::Entry.new(id: id, unit: each.unit, tree: tree, set: tree.digests)
145
+ end
146
+ end
147
+
148
+ # [[score, base index, head index]] for every pair at the threshold.
149
+ def candidates(index, heads, bases)
150
+ heads.each_with_index.flat_map do |h, hi|
151
+ bases.each_with_index.filter_map { |b, bi| (score = similarity(index, h, b)) && [score, bi, hi] }
152
+ end
153
+ end
154
+
155
+ def similarity(index, a, b)
156
+ return unless close?(a.total, b.total)
157
+
158
+ score = index.score(a.set, a.total, b.set, b.total)
159
+ score if score >= SIMILARITY
160
+ end
161
+
162
+ def greedy(candidates, heads, bases)
163
+ used_heads = {}
164
+ used_bases = {}
165
+ candidates.sort_by { |score, bi, hi| [-score, bi, hi] }.filter_map do |score, bi, hi|
166
+ next if used_heads[hi] || used_bases[bi]
167
+
168
+ used_heads[hi] = used_bases[bi] = true
169
+ [heads[hi], bases[bi], score]
170
+ end
171
+ end
172
+
173
+ # Jaccard can't reach the threshold when one side outweighs the other
174
+ # by more than its inverse, so those pairs aren't scored.
175
+ def close?(a, b)
176
+ small, large = [a, b].minmax
177
+ small * SIMILARITY.denominator >= large * SIMILARITY.numerator
178
+ end
179
+ end
180
+ end
181
+ end
@@ -69,7 +69,7 @@ module Exhale
69
69
  if closes?(line, fence)
70
70
  state[:blocks] << finish(fence)
71
71
  state[:fence] = nil
72
- state[:prose] = [] if %w[parallel settings covers].include?(fence[:info])
72
+ state[:prose] = [] if %w[parallel settings covers ceiling].include?(fence[:info])
73
73
  else
74
74
  fence[:body] << [line, no]
75
75
  end
@@ -11,23 +11,38 @@ require_relative "contract/resolver"
11
11
 
12
12
  module Exhale
13
13
  # The Contract: Markdown under <root>/contract/<primitive>/ that declares which
14
- # code belongs to a primitive and which units are deliberately parallel.
14
+ # code belongs to a primitive, which units are deliberately parallel, and
15
+ # which stay complex up to a ceiling. Each check reads only its own files:
16
+ # duplication.md for dry, complexity.md for complexity.
15
17
  class Contract
16
18
  Primitive = Struct.new(:name, :path, :covers)
17
- Clause = Struct.new(:primitive, :kind, :path, :line, :heading, :reason, :references, :key)
18
-
19
- CONTENT = %w[covers parallel settings].freeze
20
- SETTING_KEYS = { "threshold" => :threshold, "min-lines" => :min_lines, "min-nodes" => :min_nodes }.freeze
19
+ # max is a ceiling's limit, nil for a parallel clause.
20
+ Clause = Struct.new(:primitive, :kind, :path, :line, :heading, :reason, :references, :key, :max)
21
+
22
+ CONTENT = %w[covers parallel settings ceiling].freeze
23
+ # The blocks each check's own file holds, and the settings it takes.
24
+ BLOCKS = { dry: %w[parallel settings], complexity: %w[ceiling settings] }.freeze
25
+ SETTING_KEYS = {
26
+ dry: { "threshold" => :threshold, "min-lines" => :min_lines, "min-nodes" => :min_nodes },
27
+ complexity: { "floor" => :floor }
28
+ }.freeze
29
+ HOME = { "parallel" => "duplication.md", "ceiling" => "complexity.md", "covers" => "README.md" }.freeze
30
+ # A floor of 0 is allowed: every rise then fails.
31
+ MINIMUMS = { min_lines: 1, min_nodes: 1, floor: 0 }.freeze
32
+ MAX = /\Amax:\s*(.*)\z/
21
33
 
22
34
  attr_reader :primitives, :clauses, :settings, :errors
23
35
 
24
- def self.load(root, dir: "contract")
25
- new(root, dir).tap(&:parse)
36
+ def self.load(root, dir: "contract", check: :dry)
37
+ new(root, dir, check).tap(&:parse)
26
38
  end
27
39
 
28
- def initialize(root, dir)
40
+ def initialize(root, dir, check = :dry)
41
+ raise ArgumentError, "unknown check #{check.inspect}" unless BLOCKS.key?(check)
42
+
29
43
  @root = Pathname.new(root.to_s)
30
44
  @dir = dir
45
+ @check = check
31
46
  @primitives = []
32
47
  @clauses = []
33
48
  @settings = {}
@@ -75,16 +90,27 @@ module Exhale
75
90
  readme = dir.join("README.md")
76
91
  each_block(readme) { |block, path| readme_block(covers, block, path) } if real_file?(readme)
77
92
  @primitives << Primitive.new(name, rel(dir), covers)
78
- duplication_files(dir).each do |file|
79
- each_block(file) { |block, path| duplication_block(name, block, path) }
93
+ check_files(dir).each do |file|
94
+ each_block(file) { |block, path| check_block(name, block, path) }
80
95
  end
81
96
  stray_files(dir).each do |file|
82
- each_block(file) { |block, path| misplaced(block, path, "found in a stray file; use README.md or duplication.md") if CONTENT.include?(block.info) }
97
+ each_block(file) do |block, path|
98
+ next unless CONTENT.include?(block.info)
99
+
100
+ misplaced(block, path, "found in a stray file; use README.md, duplication.md or complexity.md")
101
+ end
83
102
  end
84
103
  end
85
104
 
105
+ def check_files(dir)
106
+ return duplication_files(dir) if @check == :dry
107
+
108
+ file = dir.join("complexity.md")
109
+ real_file?(file) ? [file] : []
110
+ end
111
+
86
112
  def stray_files(dir)
87
- known = [dir.join("README.md"), dir.join("duplication.md")].map(&:to_s)
113
+ known = %w[README.md duplication.md complexity.md].map { |name| dir.join(name).to_s }
88
114
  markdown_files(dir).reject { |f| known.include?(f.to_s) || f.to_s.start_with?(dir.join("duplication/").to_s) }
89
115
  end
90
116
 
@@ -95,7 +121,8 @@ module Exhale
95
121
  def readme_block(covers, block, path)
96
122
  case block.info
97
123
  when "covers" then covers.concat(references(block, path))
98
- when "parallel", "settings" then misplaced(block, path, "belongs in duplication.md")
124
+ when "parallel", "ceiling" then misplaced(block, path, "belongs in #{HOME[block.info]}")
125
+ when "settings" then misplaced(block, path, "belongs in duplication.md or complexity.md")
99
126
  end
100
127
  end
101
128
 
@@ -128,11 +155,16 @@ module Exhale
128
155
  Markdown.blocks(text).each { |block| yield block, path }
129
156
  end
130
157
 
131
- def duplication_block(primitive, block, path)
158
+ # A block type that belongs to the other check, or to README.md, is an
159
+ # error where it sits.
160
+ def check_block(primitive, block, path)
161
+ return unless CONTENT.include?(block.info)
162
+ return misplaced(block, path, "belongs in #{HOME[block.info]}") unless BLOCKS[@check].include?(block.info)
163
+
132
164
  case block.info
133
165
  when "parallel" then add_clause(primitive, block, path)
134
- when "settings" then add_settings(primitive, block, path)
135
- when "covers" then misplaced(block, path, "belongs in README.md")
166
+ when "ceiling" then add_ceiling(primitive, block, path)
167
+ else add_settings(primitive, block, path)
136
168
  end
137
169
  end
138
170
 
@@ -151,6 +183,35 @@ module Exhale
151
183
  @clauses << Clause.new(primitive, :parallel, path, block.line, block.heading, block.reason, refs, key)
152
184
  end
153
185
 
186
+ # A ceiling names units and gives one `max: N` they may reach.
187
+ def add_ceiling(primitive, block, path)
188
+ lines = block.body.map { |text, no| [Markdown.strip_comment(text), no] }.reject { |line, _| line.empty? }
189
+ maxes, names = lines.partition { |line, _| MAX.match?(line) }
190
+ return @errors << ContractError.new(path, block.line, "ceiling block names no unit") if names.empty?
191
+ return unless (max = ceiling_max(maxes, block, path))
192
+
193
+ refs = names.map { |line, no| Reference.new(line, path, no) }
194
+ key = Digest::SHA256.hexdigest("#{primitive}|ceiling|#{max}|#{refs.map(&:text).sort.join("\n")}")
195
+ @clauses << Clause.new(primitive, :ceiling, path, block.line, block.heading, block.reason, refs, key, max)
196
+ end
197
+
198
+ # The one max a ceiling gives, or nil once the reason it has none is
199
+ # recorded.
200
+ def ceiling_max(maxes, block, path)
201
+ return ceiling_error(path, block.line, "ceiling block needs max: N") if maxes.empty?
202
+ return ceiling_error(path, maxes[1].last, "repeated max in a ceiling block") if maxes.size > 1
203
+
204
+ text, no = maxes.first
205
+ value = MAX.match(text)[1]
206
+ max = Integer(value, 10, exception: false)
207
+ max && max >= 0 ? max : ceiling_error(path, no, "bad value for max: #{value.inspect}")
208
+ end
209
+
210
+ def ceiling_error(path, line, message)
211
+ @errors << ContractError.new(path, line, message)
212
+ nil
213
+ end
214
+
154
215
  def add_settings(primitive, block, path)
155
216
  if @settings.key?(primitive)
156
217
  return @errors << ContractError.new(path, block.line, "second settings block for #{primitive}")
@@ -166,7 +227,7 @@ module Exhale
166
227
 
167
228
  def parse_setting(line, path, no, acc)
168
229
  key, value = line.split(":", 2).map(&:strip)
169
- sym = SETTING_KEYS[key]
230
+ sym = SETTING_KEYS[@check][key]
170
231
  return @errors << ContractError.new(path, no, "unknown setting: #{key}") unless sym
171
232
 
172
233
  parsed = setting_value(sym, value)
@@ -183,7 +244,7 @@ module Exhale
183
244
  f if f && f > 0 && f <= 1
184
245
  else
185
246
  i = Integer(value.to_s, 10, exception: false)
186
- i if i && i >= 1
247
+ i if i && i >= MINIMUMS.fetch(sym)
187
248
  end
188
249
  end
189
250
 
@@ -11,8 +11,7 @@ require_relative "../version"
11
11
  require_relative "../errors"
12
12
  require_relative "../git"
13
13
  require_relative "../source_files"
14
- require_relative "../units/ruby"
15
- require_relative "../units/erb"
14
+ require_relative "../units"
16
15
  require_relative "../contract"
17
16
  require_relative "normalizer"
18
17
  require_relative "fingerprints"
@@ -38,8 +37,7 @@ module Exhale
38
37
  end
39
38
 
40
39
  def run
41
- @parse_errors = []
42
- @units = read_units
40
+ @units, @parse_errors = Units.read(@root, files: @files, include_tests: @include_tests)
43
41
  @contract = Contract.load(@root, dir: @contract_dir)
44
42
  @resolver = Contract::Resolver.new(@contract, @units)
45
43
  @index = Index.new(entries)
@@ -55,28 +53,6 @@ module Exhale
55
53
 
56
54
  private
57
55
 
58
- # A file that can't be read, or isn't UTF-8, is a parse error like any
59
- # other: the gate never passes code it didn't read.
60
- def read_units
61
- SourceFiles.list(@root, files: @files, include_tests: @include_tests).flat_map do |path, language|
62
- source = read_source(path)
63
- language == :ruby ? Units::Ruby.extract(source, path) : Units::Erb.extract(source, path)
64
- rescue ParseError => e
65
- @parse_errors << e
66
- []
67
- end
68
- end
69
-
70
- def read_source(path)
71
- source = File.read(File.join(@root, path), encoding: "UTF-8")
72
- return source if source.valid_encoding?
73
-
74
- line = source.each_line.find_index { |text| !text.valid_encoding? }.to_i + 1
75
- raise ParseError.new(path, line, "isn't valid UTF-8")
76
- rescue SystemCallError, IOError => e
77
- raise ParseError.new(path, 1, "can't be read: #{e.message}")
78
- end
79
-
80
56
  def entries
81
57
  @units.each_with_index.map do |unit, id|
82
58
  tree = Fingerprints.build(Normalizer.normalize(unit))