condition_coverage 0.0.1 → 0.0.3

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,159 @@
1
+ require 'cgi/escape'
2
+ require_relative 'html_template'
3
+
4
+ module ConditionCoverage
5
+ # One covered file's HTML page: its source, with the numbers
6
+ # of the lines holding a condition as the one button that
7
+ # opens the condition's tree.
8
+ module HtmlFile
9
+ # The whole page for the file at path (relative to root),
10
+ # written at files/<path>.html, so it links back to the
11
+ # index one folder up for each folder in that.
12
+ def self.page(path, lines, trees)
13
+ HtmlTemplate.fill(
14
+ HtmlTemplate.asset('file.html'),
15
+ 'path' => CGI.escapeHTML(path),
16
+ 'index' => "#{'../' * (path.count('/') + 1)}index.html",
17
+ 'style' => HtmlTemplate.asset('style.css'),
18
+ 'script' => HtmlTemplate.asset('toggle.js'),
19
+ 'listing' => listing(lines, trees)
20
+ )
21
+ end
22
+
23
+ # The source listing of lines, as SourceLines.of gives them:
24
+ # each run of lines sharing a span is one block, followed
25
+ # by its hidden tree. trees is { span's first line =>
26
+ # [SiteTree.of, ...] }.
27
+ def self.listing(lines, trees = {})
28
+ blocks = lines.chunk_while do |line, following|
29
+ line[:span] && line[:span] == following[:span]
30
+ end
31
+ body = blocks.map { |block| block(block, trees) }.join("\n")
32
+ %(<div class="code">\n#{body}\n</div>)
33
+ end
34
+
35
+ # A plain line, or the lines of one span behind one button
36
+ # with the span's tree straight after them.
37
+ def self.block(lines, trees)
38
+ first = lines[0]
39
+ return plain_line(first) if first[:span].nil?
40
+
41
+ number = first[:number]
42
+ site_trees = trees.fetch(number, [])
43
+ span_block(lines, site_trees.size) + tree(number, site_trees)
44
+ end
45
+
46
+ # The lines of one span: one button over all their numbers,
47
+ # and how many sites it opens when that is two or more,
48
+ # beside their code.
49
+ def self.span_block(lines, sites)
50
+ numbers = lines.map { |line| number(line[:number]) }.join
51
+ codes = lines.map { |line| code(line) }.join
52
+ srcs = %(<div class="srcs">#{codes}</div>)
53
+ content = site_count(sites) + numbers
54
+ %(<div class="block">#{button(lines[0], content)}#{srcs}</div>)
55
+ end
56
+
57
+ # The count of a button's sites, shown only for two or more.
58
+ def self.site_count(sites)
59
+ return '' if sites < 2
60
+
61
+ %(<span class="site-count" data-site-count="">#{sites}</span>)
62
+ end
63
+
64
+ HEADINGS = %w[condition true false gap].freeze
65
+
66
+ # The hidden tree of a span: a heading row, then each site's
67
+ # rows in a group of their own.
68
+ def self.tree(number, site_trees)
69
+ groups = site_trees.map do |site|
70
+ rows = site_rows(site).join
71
+ %(<div class="site" data-site="">#{rows}</div>)
72
+ end
73
+ id = "tree-#{number}"
74
+ body = [heading_row, *groups].join
75
+ %(<div class="tree" id="#{id}" hidden="hidden">#{body}</div>)
76
+ end
77
+
78
+ # The row naming each column of a tree.
79
+ def self.heading_row
80
+ cells = HEADINGS.map { |name| %(<span>#{name}</span>) }.join
81
+ %(<div class="row headings" data-heading="">#{cells}</div>)
82
+ end
83
+
84
+ # A site's operator row, then the rows of its operands.
85
+ def self.site_rows(site)
86
+ row = %(<div class="row">#{operator(site)}</div>)
87
+ [row, *operand_rows(site, '')]
88
+ end
89
+
90
+ # The rows of a condition's operands, each branching from
91
+ # its operator after indent; a nested condition's operands
92
+ # follow its own row, indented under its branch.
93
+ def self.operand_rows(condition, indent)
94
+ last = condition[:operands].size - 1
95
+ condition[:operands].each_with_index.flat_map do |operand, i|
96
+ tip = i == last
97
+ branch = indent + (tip ? '└─ ' : '├─ ')
98
+ next [operand_row(operand, branch)] unless operand[:operator]
99
+
100
+ nested = operand_rows(operand, indent + (tip ? ' ' : '│ '))
101
+ [subtree_row(operand, branch), *nested]
102
+ end
103
+ end
104
+
105
+ # A nested condition's row: its branch and its operator.
106
+ def self.subtree_row(condition, branch)
107
+ cells = branch_cell(branch) + operator(condition)
108
+ %(<div class="row" data-subtree="">#{cells}</div>)
109
+ end
110
+
111
+ # The cell naming a condition's operator.
112
+ def self.operator(condition)
113
+ name = CGI.escapeHTML(condition[:operator])
114
+ %(<span class="operator" data-operator="">#{name}</span>)
115
+ end
116
+
117
+ # One operand's row: its branch, text, true and false
118
+ # counts, and gap.
119
+ def self.operand_row(operand, branch)
120
+ cells = %i[text truthy falsy gap].map do |name|
121
+ value = CGI.escapeHTML(operand[name].to_s)
122
+ %(<span class="#{name}" data-#{name}="">#{value}</span>)
123
+ end
124
+ lead = branch_cell(branch)
125
+ %(<div class="row" data-operand="">#{lead}#{cells.join}</div>)
126
+ end
127
+
128
+ # The cell drawing a row's branch from its operator.
129
+ def self.branch_cell(branch)
130
+ %(<span class="branch" data-branch="">#{branch}</span>)
131
+ end
132
+
133
+ # A line with no condition: its number, then its code.
134
+ def self.plain_line(line)
135
+ %(<div class="line">#{number(line[:number])}#{code(line)}</div>)
136
+ end
137
+
138
+ # The closed button over a span's numbers, opening the tree
139
+ # named after its first line.
140
+ def self.button(first, numbers)
141
+ [
142
+ %(<button type="button" class="lnum"),
143
+ %( data-status="#{first[:status]}" aria-expanded="false"),
144
+ %( aria-controls="tree-#{first[:number]}">#{numbers}</button>)
145
+ ].join
146
+ end
147
+
148
+ # One line's number.
149
+ def self.number(number)
150
+ %(<span class="lineno" data-line="#{number}">#{number}</span>)
151
+ end
152
+
153
+ # One line's code, escaped.
154
+ def self.code(line)
155
+ text = CGI.escapeHTML(line[:text])
156
+ %(<span class="src" data-src="">#{text}</span>)
157
+ end
158
+ end
159
+ end
@@ -0,0 +1,43 @@
1
+ require 'cgi/escape'
2
+ require_relative 'html_template'
3
+
4
+ module ConditionCoverage
5
+ # The HTML index page: one sortable row per covered file,
6
+ # each linking to that file's own page.
7
+ module HtmlIndex
8
+ # The whole index page: the files table, sortable by any
9
+ # column, with one row per Summary.files row.
10
+ def self.page(rows)
11
+ HtmlTemplate.fill(
12
+ HtmlTemplate.asset('index.html'),
13
+ 'style' => HtmlTemplate.asset('style.css'),
14
+ 'script' => HtmlTemplate.asset('sort.js'),
15
+ 'rows' => rows.map { |row| row(row) }.join("\n")
16
+ )
17
+ end
18
+
19
+ # The table row of one Summary.files row; each cell has a
20
+ # data-sort value for the page's column sorting.
21
+ def self.row(row)
22
+ "<tr>#{cells(row).join}</tr>"
23
+ end
24
+
25
+ # The four cells: file, sites, outcomes seen, missing.
26
+ def self.cells(row)
27
+ path = CGI.escapeHTML(row[:path])
28
+ link = %(<a href="files/#{path}.html">#{path}</a>)
29
+ seen = "#{row[:seen]} / #{row[:outcomes]} · #{row[:percent]}%"
30
+ [
31
+ cell(path, link),
32
+ cell(row[:sites], row[:sites]),
33
+ cell(row[:percent], seen),
34
+ cell(row[:missing], row[:missing])
35
+ ]
36
+ end
37
+
38
+ # One table cell, sorted by sort_value, showing content.
39
+ def self.cell(sort_value, content)
40
+ %(<td data-sort="#{sort_value}">#{content}</td>)
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,46 @@
1
+ require 'fileutils'
2
+ require_relative 'html_file'
3
+ require_relative 'html_index'
4
+ require_relative 'site_tree'
5
+ require_relative 'source_lines'
6
+ require_relative 'summary'
7
+
8
+ module ConditionCoverage
9
+ # The :html reporter: index.html, and files/<path>.html for
10
+ # each covered file, written to the configuration's
11
+ # output_dir.
12
+ module HtmlReport
13
+ # Writes every page. files is { full path => {source:, sites:} }
14
+ # for each covered file, as the kernel parsed it.
15
+ def self.write(result, files, config)
16
+ dir = config.output_dir
17
+ index = HtmlIndex.page(Summary.files(result))
18
+ File.write("#{dir}/index.html", index)
19
+ files.each do |full_path, parsed|
20
+ path = config.relative(full_path)
21
+ write_page("#{dir}/files/#{path}.html", path, parsed, result)
22
+ end
23
+ end
24
+
25
+ # Writes one file's page to out.
26
+ def self.write_page(out, path, parsed, result)
27
+ source = parsed[:source]
28
+ lines = SourceLines.of(source, parsed[:sites], result.operands)
29
+ trees = trees(source, parsed[:sites], lines, result.operands)
30
+ FileUtils.mkdir_p(File.dirname(out))
31
+ File.write(out, HtmlFile.page(path, lines, trees))
32
+ end
33
+
34
+ # { first line of a span => [SiteTree.of, ...] } for the
35
+ # sites of one file, each under the span its line is in.
36
+ def self.trees(source, sites, lines, operands)
37
+ trees = Hash.new { |all, first| all[first] = [] }
38
+ sites.each do |site, condition|
39
+ first = lines[SourceLines.line_of(site) - 1][:span].begin
40
+ tree = SiteTree.of(source, condition, operands.fetch(site))
41
+ trees[first] << tree
42
+ end
43
+ trees
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,20 @@
1
+ module ConditionCoverage
2
+ # The HTML report's page templates, styles and scripts, kept
3
+ # as files beside this one, and the filling of their slots.
4
+ module HtmlTemplate
5
+ DIR = File.join(__dir__, 'html')
6
+
7
+ # The text of one of the report's template, style or script
8
+ # files.
9
+ def self.asset(name)
10
+ File.read(File.join(DIR, name))
11
+ end
12
+
13
+ # template with each {{name}} replaced by values[name].
14
+ def self.fill(template, values)
15
+ template.gsub(/\{\{(\w+)\}\}/) do
16
+ values.fetch(Regexp.last_match(1))
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,13 @@
1
+ require 'json'
2
+
3
+ module ConditionCoverage
4
+ # The base reporter: the result as raw JSON, which every
5
+ # other reporter can read back.
6
+ module JsonWriter
7
+ # Writes result to path as {"sites": {site: [counts, ...]}}.
8
+ def self.write(result, path)
9
+ json = JSON.pretty_generate('sites' => result.operands)
10
+ File.write(path, json)
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,111 @@
1
+ require 'fileutils'
2
+ require_relative 'configuration'
3
+ require_relative 'gaps'
4
+ require_relative 'html_report'
5
+ require_relative 'json_writer'
6
+ require_relative 'manifest'
7
+ require_relative 'recorder'
8
+ require_relative 'result'
9
+ require_relative 'rewriter'
10
+
11
+ # Condition coverage for Ruby. This file is the kernel: it loads
12
+ # before any covered file and is never itself instrumented, since
13
+ # an instrumented recorder would record itself.
14
+ module ConditionCoverage
15
+ @recorder = Recorder.new
16
+ @config = nil
17
+ # { full path => {source:, sites:} } of each covered file
18
+ # parsed as it loaded, so the exit report need not read it
19
+ # again.
20
+ @parsed = {}
21
+
22
+ # The one recorder that every instrumented operand reports to.
23
+ def self.recorder
24
+ @recorder
25
+ end
26
+
27
+ # The configuration of the latest start, which the load
28
+ # hook consults; nil before the first start.
29
+ def self.config
30
+ @config
31
+ end
32
+
33
+ # The call rewritten code makes around each operand; returns
34
+ # value unchanged.
35
+ def self.record(site, index, value)
36
+ @recorder.record(site, index, value)
37
+ end
38
+
39
+ # Instruments every file later required that the settings
40
+ # block covers, and reports when the process exits. on_exit
41
+ # is Kernel#at_exit except in tests, which must not print
42
+ # a report as their own process exits.
43
+ def self.start(on_exit: method(:at_exit), &)
44
+ config = Configuration.new(&)
45
+ install_hook if @config.nil?
46
+ @config = config
47
+ on_exit.call { report(config) }
48
+ end
49
+
50
+ # Routes every later load through instrumented, under
51
+ # whichever configuration is current. Installed once: a
52
+ # second definition would warn that it was redefined.
53
+ def self.install_hook
54
+ iseq = RubyVM::InstructionSequence
55
+ iseq.singleton_class.define_method(:load_iseq) do |path|
56
+ ConditionCoverage.instrumented(path, ConditionCoverage.config)
57
+ end
58
+ end
59
+
60
+ # The compiled, rewritten code of path, or nil for a path
61
+ # config does not cover, which Ruby then loads as normal.
62
+ def self.instrumented(path, config)
63
+ return unless config.covers?(path)
64
+
65
+ parsed = @parsed[path] = parse(path, config)
66
+ rewritten = Rewriter.rewrite(parsed[:source], parsed[:sites])
67
+ RubyVM::InstructionSequence.compile(rewritten, path, path)
68
+ end
69
+
70
+ # The source and sites of the covered file at path.
71
+ def self.parse(path, config)
72
+ source = File.read(path)
73
+ { source:, sites: Manifest.sites(source, config.relative(path)) }
74
+ end
75
+
76
+ # Writes the JSON report, then runs the chosen reporters, for
77
+ # every covered file whether or not the run loaded it, into
78
+ # coverage_dir, created first if need be.
79
+ def self.report(config)
80
+ files = parsed_files(config)
81
+ result = Result.new(merged_sites(files), @recorder.counts)
82
+ FileUtils.mkdir_p(config.output_dir)
83
+ JsonWriter.write(result, config.json_path)
84
+ run_reporters(result, files, config)
85
+ end
86
+
87
+ # Runs each reporter chosen in the start block.
88
+ def self.run_reporters(result, files, config)
89
+ reporters = config.reporters
90
+ if reporters.include?(:gaps)
91
+ Gaps.lines(result).each { |line| puts line }
92
+ end
93
+ return unless reporters.include?(:html)
94
+
95
+ HtmlReport.write(result, files, config)
96
+ end
97
+
98
+ # { full path => {source:, sites:} } for every covered file:
99
+ # as parsed when it loaded, or parsed now for a file the run
100
+ # never loaded.
101
+ def self.parsed_files(config)
102
+ config.files.to_h do |path|
103
+ [path, @parsed.fetch(path) { parse(path, config) }]
104
+ end
105
+ end
106
+
107
+ # The sites of every covered file, in one manifest.
108
+ def self.merged_sites(files)
109
+ files.values.map { |parsed| parsed[:sites] }.reduce({}, :merge)
110
+ end
111
+ end
@@ -0,0 +1,107 @@
1
+ require 'prism'
2
+ require_relative 'condition'
3
+ require_relative 'site'
4
+
5
+ module ConditionCoverage
6
+ # Finds every condition site in source without running it: each
7
+ # outermost &&, ||, and or or, with the conditions nested in it.
8
+ module Manifest
9
+ # { site => Condition } for each site in source.
10
+ def self.sites(source, path)
11
+ collector = ConditionSites.new(path)
12
+ Prism.parse(source).value.accept(collector)
13
+ collector.sites
14
+ end
15
+
16
+ # Collects each site and its Condition.
17
+ class ConditionSites < Prism::Visitor
18
+ # Starts with no sites found.
19
+ def initialize(path)
20
+ @path = path
21
+ @sites = {}
22
+ @commands = []
23
+ super()
24
+ end
25
+
26
+ # The sites found so far.
27
+ attr_reader :sites
28
+
29
+ OPERATOR_TYPES = %i[and_node or_node].freeze
30
+
31
+ # Notes an && or and site.
32
+ def visit_and_node(node)
33
+ add_site(node)
34
+ end
35
+
36
+ # Notes an || or or site.
37
+ def visit_or_node(node)
38
+ add_site(node)
39
+ end
40
+
41
+ # Notes node as a site. It claims its place first, so a
42
+ # site found inside one of its leaves follows it, keeping
43
+ # the sites in source order.
44
+ def add_site(node)
45
+ site = Site.of(@path, node)
46
+ @sites[site] = nil
47
+ outer = @commands
48
+ @commands = []
49
+ condition = condition_of(node)
50
+ @sites[site] = condition.with(commands: @commands)
51
+ @commands = outer
52
+ end
53
+
54
+ # node as a Condition, operands and all, named by its
55
+ # operator as written.
56
+ def condition_of(node)
57
+ operator = node.operator
58
+ Condition.new(operator:, operands: operands_of(node))
59
+ end
60
+
61
+ # node's operands: a chain of the same operator flattened
62
+ # into one list, the other operator nested as a Condition,
63
+ # anything else a leaf's byte range.
64
+ def operands_of(node)
65
+ [node.left, node.right].flat_map do |written|
66
+ operand = grouped(written)
67
+ next operands_of(operand) if operand.type == node.type
68
+ next [condition_of(operand)] if operator?(operand)
69
+
70
+ [leaf(operand)]
71
+ end
72
+ end
73
+
74
+ # True for an &&, ||, and or or node.
75
+ def operator?(node)
76
+ OPERATOR_TYPES.include?(node.type)
77
+ end
78
+
79
+ # The byte range of a leaf operand, once any site inside
80
+ # it is noted too, and the range among its site's commands
81
+ # if it is a command call.
82
+ def leaf(operand)
83
+ operand.accept(self)
84
+ loc = operand.location
85
+ range = loc.start_offset...loc.end_offset
86
+ @commands << range if command?(operand)
87
+ range
88
+ end
89
+
90
+ # True for a call to a named method with arguments and no
91
+ # parentheses, eg `id.include? 'N'`, but not `id.size == 6`.
92
+ def command?(node)
93
+ node.type == :call_node && node.arguments &&
94
+ node.opening_loc.nil? && node.name.match?(/\A[a-z_]/i)
95
+ end
96
+
97
+ # The one statement inside a parenthesised operand, or the
98
+ # operand itself.
99
+ def grouped(operand)
100
+ return operand unless operand.type == :parentheses_node
101
+
102
+ statements = operand.body&.body || []
103
+ statements.size == 1 ? statements.first : operand
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,24 @@
1
+ module ConditionCoverage
2
+ # Records the outcome of each instrumented operand.
3
+ class Recorder
4
+ # Starts with no outcomes counted.
5
+ def initialize
6
+ @counts = Hash.new do |counts, key|
7
+ counts[key] = { truthy: 0, falsy: 0 }
8
+ end
9
+ end
10
+
11
+ # The truthy and falsy counts of every operand recorded so
12
+ # far, keyed by [site, index]; an operand that never ran
13
+ # has no entry.
14
+ attr_reader :counts
15
+
16
+ # Counts value's outcome and returns it unchanged, so the
17
+ # condition around it behaves exactly as uninstrumented.
18
+ def record(site, index, value)
19
+ outcome = value ? :truthy : :falsy
20
+ @counts[[site, index]][outcome] += 1
21
+ value
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,31 @@
1
+ module ConditionCoverage
2
+ # What a run found: every site the manifest knows of, with
3
+ # the counts its operands were recorded with. Every
4
+ # reporter builds on this.
5
+ class Result
6
+ NEVER_RAN = { truthy: 0, falsy: 0 }.freeze
7
+
8
+ # sites is { site => Condition }, from the manifest;
9
+ # counts is { [site, index] => counts }, from the recorder.
10
+ def initialize(sites, counts)
11
+ @sites = sites
12
+ @counts = counts
13
+ end
14
+
15
+ # { site => [counts of operand 0, counts of operand 1, ...] },
16
+ # with zero counts for an operand that never ran.
17
+ def operands
18
+ @sites.to_h do |site, condition|
19
+ size = condition.leaves.size
20
+ [site, Array.new(size) { |i| counts_of(site, i) }]
21
+ end
22
+ end
23
+
24
+ private
25
+
26
+ # The recorded counts of one operand, or zeros.
27
+ def counts_of(site, index)
28
+ @counts.fetch([site, index], NEVER_RAN)
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,39 @@
1
+ module ConditionCoverage
2
+ # Instruments source text so that each leaf operand records its
3
+ # outcome when the code runs. It inserts text at the byte
4
+ # ranges the manifest found, and never parses.
5
+ module Rewriter
6
+ # Returns source with each operand in sites wrapped in a
7
+ # record call. Only text is inserted, never a newline, so
8
+ # backtrace line numbers still match the file on disk.
9
+ def self.rewrite(source, sites)
10
+ inserts = sites.flat_map do |site, condition|
11
+ condition.leaves.each_with_index.flat_map do |range, index|
12
+ command = condition.commands.include?(range)
13
+ wrap(site, index, range, command)
14
+ end
15
+ end
16
+ apply(source.dup, inserts)
17
+ end
18
+
19
+ # The two inserts that wrap one operand in a record call. A
20
+ # command call is parenthesised, as it is no bare argument;
21
+ # nothing else is, since parentheses make a statement, which
22
+ # gives its line a line-coverage count of its own.
23
+ def self.wrap(site, index, range, command)
24
+ open, close = command ? ['(', '))'] : ['', ')']
25
+ call = "ConditionCoverage.record(#{site.inspect}, #{index}, "
26
+ [[range.begin, call + open], [range.end, close]]
27
+ end
28
+
29
+ # Splices each [offset, text] insert into source,
30
+ # last offset first so earlier offsets stay valid.
31
+ def self.apply(source, inserts)
32
+ sorted = inserts.sort_by { |offset, _| -offset }
33
+ sorted.each do |offset, text|
34
+ source.bytesplice(offset, 0, text)
35
+ end
36
+ source
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,11 @@
1
+ module ConditionCoverage
2
+ # Names a condition by where it starts in its file, so the
3
+ # rewriter and the manifest agree on which site is which.
4
+ module Site
5
+ # "path:line:column" of node, eg "k.rb:2:2".
6
+ def self.of(path, node)
7
+ loc = node.location
8
+ "#{path}:#{loc.start_line}:#{loc.start_column}"
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,39 @@
1
+ require_relative 'condition'
2
+ require_relative 'gaps'
3
+
4
+ module ConditionCoverage
5
+ # The tree shown under a site's line on its file's page.
6
+ module SiteTree
7
+ # { operator:, operands: [{text:, truthy:, falsy:, gap:}] }
8
+ # for one site, a nested condition being an operand of the
9
+ # same { operator:, operands: } shape. Each leaf has its
10
+ # text cut from source at its manifest byte range, its
11
+ # counts, and its gap label in the same words as the
12
+ # terminal gaps list (nil for none).
13
+ def self.of(source, condition, counts)
14
+ node(source, condition, counts.each)
15
+ end
16
+
17
+ # The tree of condition, each leaf taking the next of
18
+ # counts, so leaves pair with counts left to right.
19
+ def self.node(source, condition, counts)
20
+ operands = condition.operands.map do |operand|
21
+ if operand.is_a?(Condition)
22
+ node(source, operand, counts)
23
+ else
24
+ leaf(source, operand, counts.next)
25
+ end
26
+ end
27
+ { operator: condition.operator, operands: }
28
+ end
29
+
30
+ # One leaf's text, counts and gap.
31
+ def self.leaf(source, range, counts)
32
+ {
33
+ text: source.byteslice(range),
34
+ truthy: counts[:truthy], falsy: counts[:falsy],
35
+ gap: Gaps.operand_gap(counts).first
36
+ }
37
+ end
38
+ end
39
+ end