exhale 0.1.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,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../unit"
4
+ require_relative "../errors"
5
+ require_relative "reference"
6
+
7
+ module Exhale
8
+ class Contract
9
+ # Maps Contract references onto code units. Units are indexed once (by
10
+ # identity and by every namespace prefix) so each reference costs a hash
11
+ # lookup instead of a scan.
12
+ class Resolver
13
+ def initialize(contract, units)
14
+ @contract = contract
15
+ @units = units
16
+ @order = {}.compare_by_identity
17
+ units.each_with_index { |u, i| @order[u] = i }
18
+ @matches = {}
19
+ build_indexes
20
+ end
21
+
22
+ def errors
23
+ @errors ||= all_references.sort_by { |r| [r.path, r.line] }.filter_map do |ref|
24
+ next unless units_for(ref).empty?
25
+
26
+ ContractError.new(ref.path, ref.line, "reference names no unit: #{ref.text}")
27
+ end
28
+ end
29
+
30
+ def units_for(reference)
31
+ @units_for ||= {}
32
+ @units_for[reference.text] ||= matches(reference).keys.sort_by { |u| @order[u] }
33
+ end
34
+
35
+ def keeping_clause(unit_a, unit_b)
36
+ return nil if unit_a.equal?(unit_b)
37
+
38
+ @contract.clauses.find { |clause| keeps?(clause, unit_a, unit_b) }
39
+ end
40
+
41
+ def primitive_for(unit)
42
+ pairs = covering(unit)
43
+ top = pairs.map { |_, ref| ref.specificity }.max
44
+ pairs.select { |_, ref| ref.specificity == top }.map(&:first).min_by(&:name)
45
+ end
46
+
47
+ def settings_for(unit, defaults)
48
+ prim = primitive_for(unit)
49
+ defaults.merge(prim ? @contract.settings.fetch(prim.name, {}) : {})
50
+ end
51
+
52
+ def settings_for_pair(unit_a, unit_b, defaults)
53
+ a = settings_for(unit_a, defaults)
54
+ b = settings_for(unit_b, defaults)
55
+ a.merge(b) { |_key, x, y| [x, y].min }
56
+ end
57
+
58
+ private
59
+
60
+ def build_indexes
61
+ @by_identity = Hash.new { |h, k| h[k] = [] }
62
+ @by_prefix = Hash.new { |h, k| h[k] = [] }
63
+ @templates = []
64
+ @units.each { |unit| index(unit) }
65
+ end
66
+
67
+ def index(unit)
68
+ @templates << unit if unit.kind == :template
69
+ @by_identity[unit.identity] << unit if %i[method dsl].include?(unit.kind)
70
+ return unless unit.namespace
71
+
72
+ segments = unit.namespace.split("::")
73
+ (1..segments.size).each { |n| @by_prefix[segments.first(n).join("::")] << unit }
74
+ end
75
+
76
+ def all_references
77
+ @contract.primitives.flat_map(&:covers) + @contract.clauses.flat_map(&:references)
78
+ end
79
+
80
+ # {unit => match key}, cached per reference text.
81
+ def matches(ref)
82
+ @matches[ref.text] ||= case ref.kind
83
+ when :method then method_matches(ref)
84
+ when :constant then constant_matches(ref)
85
+ when :const_glob then const_glob_matches(ref)
86
+ else template_matches(ref)
87
+ end
88
+ end
89
+
90
+ def method_matches(ref)
91
+ @by_identity.fetch(ref.text, []).to_h { |u| [u, ref.text] }.compare_by_identity
92
+ end
93
+
94
+ def constant_matches(ref)
95
+ @by_prefix.fetch(ref.text, []).to_h { |u| [u, ref.text] }.compare_by_identity
96
+ end
97
+
98
+ # A unit under several matching prefixes keys on the longest one.
99
+ def const_glob_matches(ref)
100
+ result = {}.compare_by_identity
101
+ @by_prefix.each do |prefix, units|
102
+ next unless ref.const_glob_match?(prefix.split("::"))
103
+
104
+ units.each { |u| result[u] = prefix if !result.key?(u) || result[u].length < prefix.length }
105
+ end
106
+ result
107
+ end
108
+
109
+ def template_matches(ref)
110
+ regex = ref.template_regex
111
+ @templates.select { |u| regex.match?(u.identity) }.to_h { |u| [u, u.identity] }.compare_by_identity
112
+ end
113
+
114
+ def keeps?(clause, unit_a, unit_b)
115
+ a = assignment(clause, unit_a)
116
+ b = assignment(clause, unit_b)
117
+ a && b && a != b
118
+ end
119
+
120
+ # The one reference (and match key) a unit belongs to within a clause:
121
+ # the most specific of those that match it.
122
+ def assignment(clause, unit)
123
+ hits = clause.references.uniq(&:text).filter_map { |r| (k = matches(r)[unit]) && [r, k] }
124
+ ref, key = hits.max_by { |r, _| r.specificity }
125
+ ref && [ref.text, key]
126
+ end
127
+
128
+ def covering(unit)
129
+ @covered_by ||= build_covered_by
130
+ @covered_by.fetch(unit, [])
131
+ end
132
+
133
+ def build_covered_by
134
+ map = {}.compare_by_identity
135
+ @contract.primitives.each do |prim|
136
+ prim.covers.each { |ref| matches(ref).each_key { |u| (map[u] ||= []) << [prim, ref] } }
137
+ end
138
+ map
139
+ end
140
+ end
141
+ end
142
+ end
@@ -0,0 +1,194 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "find"
5
+ require "pathname"
6
+ require_relative "unit"
7
+ require_relative "errors"
8
+ require_relative "contract/markdown"
9
+ require_relative "contract/reference"
10
+ require_relative "contract/resolver"
11
+
12
+ module Exhale
13
+ # The Contract: Markdown under <root>/contract/<primitive>/ that declares which
14
+ # code belongs to a primitive and which units are deliberately parallel.
15
+ class Contract
16
+ 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
21
+
22
+ attr_reader :primitives, :clauses, :settings, :errors
23
+
24
+ def self.load(root, dir: "contract")
25
+ new(root, dir).tap(&:parse)
26
+ end
27
+
28
+ def initialize(root, dir)
29
+ @root = Pathname.new(root.to_s)
30
+ @dir = dir
31
+ @primitives = []
32
+ @clauses = []
33
+ @settings = {}
34
+ @errors = []
35
+ end
36
+
37
+ def parse
38
+ base = @root.join(@dir)
39
+ return self unless base.directory? || base.symlink?
40
+
41
+ flag_symlinks(base)
42
+ return self if base.symlink?
43
+
44
+ check_root_files(base)
45
+ base.children.select { |d| real_dir?(d) }.reject { |d| d.basename.to_s.start_with?(".") }
46
+ .sort_by { |d| d.basename.to_s }.each { |d| parse_primitive(d) }
47
+ @clauses.sort_by! { |c| [c.path, c.line] }
48
+ self
49
+ end
50
+
51
+ private
52
+
53
+ # Symlinks could point outside the repo and change the verdict without
54
+ # changing the commit, so every one is an error and none is followed.
55
+ def flag_symlinks(base)
56
+ Find.find(base.to_s) do |path|
57
+ next unless File.symlink?(path)
58
+
59
+ @errors << ContractError.new(rel(path), 1, "symlinks are not allowed in the Contract")
60
+ Find.prune
61
+ end
62
+ end
63
+
64
+ def real_dir?(path)
65
+ path.directory? && !path.symlink?
66
+ end
67
+
68
+ def real_file?(path)
69
+ path.file? && !path.symlink?
70
+ end
71
+
72
+ def parse_primitive(dir)
73
+ name = dir.basename.to_s
74
+ covers = []
75
+ readme = dir.join("README.md")
76
+ each_block(readme) { |block, path| readme_block(covers, block, path) } if real_file?(readme)
77
+ @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) }
80
+ end
81
+ 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) }
83
+ end
84
+ end
85
+
86
+ def stray_files(dir)
87
+ known = [dir.join("README.md"), dir.join("duplication.md")].map(&:to_s)
88
+ markdown_files(dir).reject { |f| known.include?(f.to_s) || f.to_s.start_with?(dir.join("duplication/").to_s) }
89
+ end
90
+
91
+ def markdown_files(dir)
92
+ Dir.glob("**/*.md", base: dir.to_s).sort.map { |f| dir.join(f) }.reject { |f| f.symlink? || !f.file? }
93
+ end
94
+
95
+ def readme_block(covers, block, path)
96
+ case block.info
97
+ when "covers" then covers.concat(references(block, path))
98
+ when "parallel", "settings" then misplaced(block, path, "belongs in duplication.md")
99
+ end
100
+ end
101
+
102
+ def misplaced(block, path, message)
103
+ @errors << ContractError.new(path, block.line, "#{block.info} block #{message}")
104
+ end
105
+
106
+ def check_root_files(base)
107
+ base.children.select { |f| real_file?(f) }.select { |f| f.extname == ".md" }.sort_by(&:to_s).each do |file|
108
+ each_block(file) do |block, path|
109
+ misplaced(block, path, "found at the contract root; Contract content must live in a primitive directory") if CONTENT.include?(block.info)
110
+ end
111
+ end
112
+ end
113
+
114
+ def duplication_files(dir)
115
+ files = []
116
+ single = dir.join("duplication.md")
117
+ files << single if real_file?(single)
118
+ nested = dir.join("duplication")
119
+ files.concat(markdown_files(nested)) if real_dir?(nested)
120
+ files.sort_by(&:to_s)
121
+ end
122
+
123
+ def each_block(file)
124
+ path = rel(file)
125
+ text = File.read(file, encoding: "UTF-8")
126
+ return @errors << ContractError.new(path, 1, "file is not valid UTF-8") unless text.valid_encoding?
127
+
128
+ Markdown.blocks(text).each { |block| yield block, path }
129
+ end
130
+
131
+ def duplication_block(primitive, block, path)
132
+ case block.info
133
+ 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")
136
+ end
137
+ end
138
+
139
+ def references(block, path)
140
+ block.body.filter_map do |text, no|
141
+ ref = Markdown.strip_comment(text)
142
+ Reference.new(ref, path, no) unless ref.empty?
143
+ end
144
+ end
145
+
146
+ def add_clause(primitive, block, path)
147
+ refs = references(block, path)
148
+ return @errors << ContractError.new(path, block.line, "empty parallel block") if refs.empty?
149
+
150
+ key = Digest::SHA256.hexdigest("#{primitive}|parallel|#{refs.map(&:text).sort.join("\n")}")
151
+ @clauses << Clause.new(primitive, :parallel, path, block.line, block.heading, block.reason, refs, key)
152
+ end
153
+
154
+ def add_settings(primitive, block, path)
155
+ if @settings.key?(primitive)
156
+ return @errors << ContractError.new(path, block.line, "second settings block for #{primitive}")
157
+ end
158
+
159
+ @settings[primitive] = block.body.each_with_object({}) do |(text, no), acc|
160
+ line = Markdown.strip_comment(text)
161
+ next if line.empty?
162
+
163
+ parse_setting(line, path, no, acc)
164
+ end
165
+ end
166
+
167
+ def parse_setting(line, path, no, acc)
168
+ key, value = line.split(":", 2).map(&:strip)
169
+ sym = SETTING_KEYS[key]
170
+ return @errors << ContractError.new(path, no, "unknown setting: #{key}") unless sym
171
+
172
+ parsed = setting_value(sym, value)
173
+ return @errors << ContractError.new(path, no, "bad value for #{key}: #{value.inspect}") if parsed.nil?
174
+
175
+ return @errors << ContractError.new(path, no, "repeated setting: #{key}") if acc.key?(sym)
176
+
177
+ acc[sym] = parsed
178
+ end
179
+
180
+ def setting_value(sym, value)
181
+ if sym == :threshold
182
+ f = Float(value, exception: false)
183
+ f if f && f > 0 && f <= 1
184
+ else
185
+ i = Integer(value.to_s, 10, exception: false)
186
+ i if i && i >= 1
187
+ end
188
+ end
189
+
190
+ def rel(path)
191
+ Pathname.new(path.to_s).relative_path_from(@root).to_s
192
+ end
193
+ end
194
+ end
@@ -0,0 +1,266 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "fileutils"
5
+ require "json"
6
+ require "securerandom"
7
+ require "tmpdir"
8
+ require "prism"
9
+ require "herb"
10
+ require_relative "../version"
11
+ require_relative "../errors"
12
+ require_relative "../git"
13
+ require_relative "../source_files"
14
+ require_relative "../units/ruby"
15
+ require_relative "../units/erb"
16
+ require_relative "../contract"
17
+ require_relative "normalizer"
18
+ require_relative "fingerprints"
19
+ require_relative "index"
20
+ require_relative "matcher"
21
+ require_relative "gate"
22
+
23
+ module Exhale
24
+ module Dry
25
+ DEFAULTS = { threshold: Rational(80, 100), min_lines: 4, min_nodes: 20 }.freeze
26
+
27
+ # Everything one tree yields: its units, their index, the Contract read
28
+ # from the same tree, and every match over the threshold.
29
+ class Sweep
30
+ attr_reader :root, :units, :parse_errors, :contract, :resolver, :index, :matches
31
+
32
+ def initialize(root, files: nil, include_tests: false, contract_dir: "contract", overrides: {})
33
+ @root = root
34
+ @files = files
35
+ @include_tests = include_tests
36
+ @contract_dir = contract_dir
37
+ @overrides = overrides
38
+ end
39
+
40
+ def run
41
+ @parse_errors = []
42
+ @units = read_units
43
+ @contract = Contract.load(@root, dir: @contract_dir)
44
+ @resolver = Contract::Resolver.new(@contract, @units)
45
+ @index = Index.new(entries)
46
+ @matches = Matcher.new(@index, floors: floors, settings_for_pair: method(:settings_for_pair)).matches
47
+ self
48
+ end
49
+
50
+ def settings_for_pair(a, b)
51
+ return DEFAULTS.merge(@overrides) unless @overrides.empty?
52
+
53
+ exact(@resolver.settings_for_pair(a, b, DEFAULTS))
54
+ end
55
+
56
+ private
57
+
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
+ def entries
81
+ @units.each_with_index.map do |unit, id|
82
+ tree = Fingerprints.build(Normalizer.normalize(unit))
83
+ Entry.new(id: id, unit: unit, tree: tree, set: tree.digests)
84
+ end
85
+ end
86
+
87
+ # Candidates are generated at the loosest floors any primitive asks
88
+ # for; each pair is then judged by its own settings.
89
+ def floors
90
+ all = [DEFAULTS.merge(@overrides)]
91
+ all.concat(@contract.settings.values.map { |settings| DEFAULTS.merge(settings) }) if @overrides.empty?
92
+ { min_lines: all.map { |s| s[:min_lines] }.min, min_nodes: all.map { |s| s[:min_nodes] }.min,
93
+ threshold: all.map { |s| Rational(s[:threshold].to_s) }.min }
94
+ end
95
+
96
+ # Contract thresholds arrive as Floats; scores are Rationals. Reading
97
+ # the Float's decimal text keeps 0.75 exactly 3/4.
98
+ def exact(settings)
99
+ settings.merge(threshold: Rational(settings[:threshold].to_s))
100
+ end
101
+ end
102
+
103
+ # The duplication check: sweeps the tree on disk, sweeps the merge base
104
+ # for labels, and hands both to the Gate for the verdict.
105
+ class Check
106
+ def initialize(root:, base: nil, include_tests: false, contract_dir: "contract", overrides: {},
107
+ introduced_only: false, paths: [], cache_dir: nil)
108
+ @root = File.expand_path(root)
109
+ @base_ref = base
110
+ @include_tests = include_tests
111
+ @contract_dir = contract_dir
112
+ @overrides = overrides
113
+ @introduced_only = introduced_only
114
+ @paths = paths
115
+ @cache_dir = cache_dir || File.join(@root, "tmp", "exhale")
116
+ @git = Git.new(@root)
117
+ end
118
+
119
+ def run
120
+ base_sha = validate!
121
+ head = Sweep.new(@root, files: (@git.files if @git.repo?), include_tests: @include_tests,
122
+ contract_dir: @contract_dir, overrides: @overrides).run
123
+ Gate.new(head: head, base: base_sha && base_summary(base_sha), base_sha: base_sha, git: (@git if @git.repo?),
124
+ changed_lines: base_sha ? @git.changed_lines(base_sha) : {},
125
+ introduced_only: @introduced_only, paths: @paths, overrides: @overrides).verdict
126
+ end
127
+
128
+ # Checks the root and the base before anything is read, and returns
129
+ # the base's SHA (nil when there's no base to label against). The CLI
130
+ # calls it for explain too, so a typo there fails the same way.
131
+ def validate!
132
+ raise Error, "--root #{@root} isn't a directory" unless File.directory?(@root)
133
+
134
+ find_base
135
+ end
136
+
137
+ private
138
+
139
+ # A base the caller named and git can't find is an error: running on
140
+ # unlabeled would hide the mistake. A value that reads as an option
141
+ # never reaches git, where `--octopus` would resolve to HEAD.
142
+ def find_base
143
+ raise Error, "--base #{@base_ref} looks like an option, not a commit" if @base_ref&.start_with?("-")
144
+ raise Error, "--base #{@base_ref} needs a git repository, and #{@root} isn't in one" if @base_ref && !@git.repo?
145
+ return unless @git.repo?
146
+
147
+ ref = @base_ref || @git.default_branch_ref
148
+ sha = ref && @git.merge_base(ref)
149
+ raise Error, "--base #{@base_ref} names no commit with a merge base with HEAD" if @base_ref && sha.nil?
150
+
151
+ sha
152
+ end
153
+
154
+ # What the Gate needs from the base, cached on disk. The key covers
155
+ # everything that changes a base sweep, so a warm cache can never
156
+ # answer differently from a cold run.
157
+ def base_summary(sha)
158
+ path = File.join(@cache_dir, "base-#{sha}-#{VERSION}-n#{Normalizer::VERSION}-#{cache_key(sha)}.json")
159
+ cached = read_cache(path)
160
+ return cached if cached
161
+
162
+ summary = Dir.mktmpdir("exhale-base") do |dir|
163
+ files = base_files(sha)
164
+ @git.export_files(sha, files, dir)
165
+ # The same overrides as the head, so a pair that only the flags
166
+ # surface is labeled against a base swept the same way.
167
+ Gate.summarize(Sweep.new(dir, files: files, include_tests: @include_tests, contract_dir: @contract_dir,
168
+ overrides: @overrides).run)
169
+ end
170
+ write_cache(path, summary)
171
+ summary
172
+ end
173
+
174
+ def cache_key(sha)
175
+ parts = [sha, VERSION, Normalizer::VERSION, Prism::VERSION, Herb::VERSION, @include_tests, @git.prefix,
176
+ @contract_dir, @overrides.sort.inspect]
177
+ Digest::SHA256.hexdigest(parts.join("\u0000"))[0, 16]
178
+ end
179
+
180
+ # Only the files a sweep can read: source and the Contract.
181
+ def base_files(sha)
182
+ contract = "#{@contract_dir.chomp('/')}/"
183
+ @git.files_at(sha).select { |path| SourceFiles.language(path) || path.start_with?(contract) }
184
+ end
185
+
186
+ # Plain JSON, never Marshal: loading a Marshal dump runs whatever it
187
+ # names. Anything at the path that isn't a regular file, or doesn't
188
+ # hold exactly the shape below, is rebuilt.
189
+ def read_cache(path)
190
+ return unless File.lstat(path).file?
191
+
192
+ BaseCache.load(File.read(path, encoding: "UTF-8"))
193
+ rescue SystemCallError, IOError
194
+ nil
195
+ end
196
+
197
+ # Written beside the target and renamed into place, so a reader never
198
+ # sees half a file. A symlink at the path is left alone: writing
199
+ # through it would overwrite whatever it points at.
200
+ def write_cache(path, summary)
201
+ FileUtils.mkdir_p(File.dirname(path))
202
+ return if File.symlink?(path)
203
+
204
+ temp = "#{path}.#{Process.pid}.#{SecureRandom.hex(4)}.tmp"
205
+ File.open(temp, File::WRONLY | File::CREAT | File::EXCL, 0o644) { |f| f.write(BaseCache.dump(summary)) }
206
+ File.rename(temp, path)
207
+ rescue SystemCallError, IOError
208
+ FileUtils.rm_f(temp) if temp
209
+ nil
210
+ end
211
+ end
212
+
213
+ # The base summary as JSON, checked field by field on the way back in.
214
+ module BaseCache
215
+ FORMAT = 1
216
+ RATIONAL = %r{\A(-?\d+)/(\d+)\z}
217
+ KEYS = %w[format clusters structures pairs clause_keys].freeze
218
+
219
+ module_function
220
+
221
+ def dump(summary)
222
+ JSON.generate(
223
+ "format" => FORMAT,
224
+ "clusters" => summary.clusters.sort.to_h,
225
+ "structures" => summary.structures.sort.to_h { |key, ids| [key, ids.to_a.sort] },
226
+ "pairs" => summary.pairs.map { |row| [*row[0, 2], "#{row[2].numerator}/#{row[2].denominator}", *row[3..]] },
227
+ "clause_keys" => summary.clause_keys.to_a.sort
228
+ )
229
+ end
230
+
231
+ # nil for anything that isn't exactly what dump writes.
232
+ def load(text)
233
+ data = JSON.parse(text)
234
+ return unless data.is_a?(Hash) && data.keys.sort == KEYS.sort && data["format"] == FORMAT
235
+
236
+ clusters = data["clusters"]
237
+ structures = data["structures"]
238
+ pairs = data["pairs"]
239
+ clause_keys = data["clause_keys"]
240
+ return unless string_hash?(clusters) { |id| id.is_a?(Integer) }
241
+ return unless string_hash?(structures) { |ids| ids.is_a?(Array) && ids.all?(Integer) }
242
+ return unless clause_keys.is_a?(Array) && clause_keys.all?(String)
243
+ return unless pairs.is_a?(Array) && pairs.all? { |row| pair?(row) }
244
+
245
+ BaseSummary.new(clusters: clusters, structures: structures, clause_keys: Set.new(clause_keys),
246
+ pairs: pairs.map { |row| [*row[0, 2], rational(row[2]), *row[3..]] })
247
+ rescue JSON::ParserError, EncodingError
248
+ nil
249
+ end
250
+
251
+ def string_hash?(value, &valid)
252
+ value.is_a?(Hash) && value.all? { |key, item| key.is_a?(String) && valid.call(item) }
253
+ end
254
+
255
+ def pair?(row)
256
+ row.is_a?(Array) && row.size == 7 && row.all?(String) && RATIONAL.match?(row[2]) &&
257
+ !RATIONAL.match(row[2])[2].to_i.zero?
258
+ end
259
+
260
+ def rational(text)
261
+ numerator, denominator = RATIONAL.match(text).captures.map(&:to_i)
262
+ Rational(numerator, denominator)
263
+ end
264
+ end
265
+ end
266
+ end