hotspots 1.1.0 → 1.2.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 7f43a6b0f28f054b6f4b3799ccabb18ebe2327f6709c325c4c7a2c07f16d0c8d
4
+ data.tar.gz: c165186a7d68e49e3bd6a3507d30f22ade3513d7f1182ba0f7b9eabcdc6739b0
5
+ SHA512:
6
+ metadata.gz: 589499e787b881d179b267e3d2ff468db0af16b5d7aef558cd4799807288474d8cef5e1c3c4a2998cbd568d27681ace4cb8c409f3ca607d290a80136737b7d6f
7
+ data.tar.gz: 742faf60cca8d31eeda8e42e66f13d7a6d771bd75fd61cbc8820834a5423f1d12b15367da64ccc00084314d50c6c27a59f4d54a9b1f987fb45bc0c23b911eb68
data/CHANGELOG.md CHANGED
@@ -1,3 +1,12 @@
1
+ v1.2.0
2
+ -------
3
+
4
+ * Use ruby's built-in logger instead of a custom one.
5
+ * Introduce the concept of Configuration. Configurations have defaults and are overridden by command-line options.
6
+ * Remove compatibility layers.
7
+ * Always enable colour for verbose output.
8
+ * Minimum supported version of ruby is 3.0.0.
9
+
1
10
  v1.1.0
2
11
  ------
3
12
 
@@ -23,7 +32,7 @@ v0.2.0
23
32
  v0.1.1
24
33
  ------
25
34
 
26
- * Sort for an array of array via spaceship operator returns different result on each run on ruby 1.8.7. Store has a string representation breaks intermittently on 1.8.7. Tests fail intermittently on 1.8.7. So support for ruby 1.9.x only
35
+ * Sort for an array of array via spaceship operator returns different result on each run on ruby 1.8.7. Store has a string representation that breaks intermittently on 1.8.7. Tests fail intermittently on 1.8.7. So support for ruby 1.9.x only
27
36
  * Simplify Rakefile
28
37
  * Pull out a minitest_helper
29
38
  * Allow gem to be used as a library
data/LICENSE CHANGED
@@ -1,4 +1,4 @@
1
- Copyright (c) 2011-2013 Chirantan Mitra
1
+ Copyright (c) 2011-2024 Chirantan Mitra
2
2
 
3
3
  Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4
4
 
data/README.md CHANGED
@@ -1,10 +1,10 @@
1
- [![Build Status](https://secure.travis-ci.org/chiku/hotspots.png?branch=master)](https://travis-ci.org/chiku/hotspots)
2
- [![Code Climate](https://codeclimate.com/github/chiku/hotspots.png)](https://codeclimate.com/github/chiku/hotspots)
1
+ ![Build Status](https://github.com/chiku/r0man/actions/workflows/build.yml/badge.svg)
2
+ [![Gem Version](https://badge.fury.io/rb/hotspots.svg)](http://badge.fury.io/rb/hotspots)
3
3
 
4
4
  Overview
5
5
  --------
6
6
 
7
- This program helps in identifying files with maximum churn in a git repository. The more the number of changes made to a file, the more likelyhood of the file being a source of bug. If the same file is modified many times for bug fixes, it indicates that the file needs some refactoring or redesign love.
7
+ This program helps in identifying files with maximum churn in a git repository. The more the number of changes made to a file, the more likelyhood of the file being a source of bugs. If the same file is modified many times for bug fixes, it indicates that the file needs some refactoring or redesign love.
8
8
 
9
9
 
10
10
  Dependencies
@@ -30,11 +30,12 @@ Specific options:
30
30
  -r, --repository [PATH] Path to the repository to scan. Defaults to current path
31
31
  -f, --file-filter [REGEX] Regular expression to filtering file names. All files are allowed when not specified
32
32
  -m, --message-filter [PIPE SEPARATED] Pipe separated values to filter files names against each commit message.
33
- All files are allowed when not specified
33
+ All commit messages are allowed when not specified
34
34
  -c, --cutoff [CUTOFF] The minimum occurrence to consider for a file to appear in the list. Defaults to zero
35
+ --log [LOG LEVEL] Log level (debug, info, warn, error, fatal)
35
36
  -v, --verbose Show verbose output
36
- -C, --colour, --color Show verbose output in colours
37
- --version Show version information
37
+ -C, --colour, --color Show output in colours. The log level should be info or debug for colours
38
+ --version Show version
38
39
  -h, --help Show this message
39
40
  ```
40
41
 
@@ -65,15 +66,6 @@ gem install simplecov
65
66
  rake
66
67
  ```
67
68
 
68
- Contributing
69
- ------------
70
-
71
- * Fork the project.
72
- * Make your feature addition or bug fix.
73
- * Add tests for it. This is important so I don't break it in a future version unintentionally.
74
- * Commit, but do not mess with the VERSION. If you want to have your own version, that is fine but bump the version in a commit by itself in another branch so I can ignore it when I pull.
75
- * Send me a pull request.
76
-
77
69
  License
78
70
  -------
79
71
 
data/TODO.md CHANGED
@@ -1,5 +1,3 @@
1
1
  * Allow better use as a library - add documentation
2
- * Use builtin logger instead of a custom one
3
2
  * Reduce conditionals
4
- * Split responsibilities of OptionsParser. It shouldn't be responsible for holding default options. It should just setting overrides.
5
3
  * Group files based on their extensions
data/bin/hotspots CHANGED
@@ -1,7 +1,13 @@
1
1
  #!/usr/bin/env ruby
2
2
 
3
- require 'hotspots'
3
+ lib = File.expand_path(File.join("..", "..", "lib"), __FILE__)
4
+ $:.unshift lib unless $:.include?(lib)
4
5
 
5
- options = Hotspots::OptionsParser.new.parse(*ARGV)
6
+ require "logger"
7
+ require "hotspots"
6
8
 
7
- Hotspots.new(options).output
9
+ default_configuration = Hotspots::Configuration.new
10
+ user_options = Hotspots::OptionsParser.new(:configuration => default_configuration)
11
+ overridden_configuration = user_options.parse(*ARGV)
12
+
13
+ Hotspots.new(overridden_configuration).output
@@ -0,0 +1,36 @@
1
+ class Hotspots
2
+ class Configuration #:nodoc: all
3
+ attr_accessor :repository, :time, :message_filters, :file_filter, :cutoff
4
+ attr_accessor :logger
5
+ attr_accessor :exit_strategy
6
+
7
+ def initialize(options = {})
8
+ @repository = "."
9
+ @time = 15
10
+ @message_filters = [""]
11
+ @file_filter = ""
12
+ @cutoff = 0
13
+ @info_log_level = options[:info_log_level] || ::Logger::INFO
14
+ @error_log_level = options[:error_log_level] || ::Logger::ERROR
15
+ @logger = options[:logger] || default_logger
16
+ @exit_strategy = Exit::Noop.new
17
+ @logger.level = @error_log_level
18
+ end
19
+
20
+ def log_level
21
+ @logger.level
22
+ end
23
+
24
+ def enable_verbosity
25
+ @logger.level = @info_log_level
26
+ end
27
+
28
+ private
29
+
30
+ def default_logger
31
+ ::Logger.new(STDOUT).tap do |logger|
32
+ logger.formatter = proc { |severity, datetime, progname, msg| "#{datetime}: #{msg}\n" }
33
+ end
34
+ end
35
+ end
36
+ end
@@ -1,5 +1,5 @@
1
1
  class Hotspots
2
- module OptionBasedExit #:nodoc: all
2
+ module Exit #:nodoc: all
3
3
  class Error
4
4
  attr_reader :code, :message
5
5
 
@@ -9,7 +9,7 @@ class Hotspots
9
9
  end
10
10
 
11
11
  def perform
12
- puts @message
12
+ $stderr.puts @message
13
13
  exit @code
14
14
  end
15
15
  end
@@ -23,7 +23,7 @@ class Hotspots
23
23
  end
24
24
 
25
25
  def perform
26
- puts @message
26
+ $stdout.puts @message
27
27
  exit @code
28
28
  end
29
29
  end
@@ -31,7 +31,7 @@ class Hotspots
31
31
  class Noop
32
32
  attr_reader :code, :message
33
33
 
34
- def initialize(options = {})
34
+ def initialize
35
35
  @message = ""
36
36
  @code = nil
37
37
  end
@@ -1,37 +1,19 @@
1
- require 'optparse'
2
-
3
- require 'hotspots/version'
4
- require 'hotspots/option_based_exit'
1
+ require "optparse"
5
2
 
6
3
  class Hotspots
7
4
  class OptionsParser #:nodoc: all
8
- class << self
9
- def default_options
10
- {
11
- :time => 15,
12
- :repository => ".",
13
- :file_filter => "",
14
- :message_filters => [""],
15
- :cutoff => 0,
16
- :verbose => false,
17
- :exit_strategy => OptionBasedExit::Noop.new,
18
- :colour => false,
19
- }
20
- end
21
- end
22
-
23
- def initialize
24
- @options = self.class.default_options
5
+ def initialize(opts)
6
+ @configuration = opts[:configuration].dup
25
7
  end
26
8
 
27
9
  def parse(*args)
28
10
  parser = new_option_parser
29
11
  begin
30
- parser.parse args
12
+ parser.parse(args)
31
13
  rescue ::OptionParser::InvalidOption, ::OptionParser::InvalidArgument => ex
32
- @options[:exit_strategy] = OptionBasedExit::Error.new(:code => 1, :message => (ex.to_s << "\nUse -h for help\n"))
14
+ @configuration.exit_strategy = Exit::Error.new(:code => 1, :message => (ex.to_s + "\nUse -h for help\n"))
33
15
  end
34
- @options
16
+ @configuration
35
17
  end
36
18
 
37
19
  private
@@ -47,7 +29,7 @@ class Hotspots
47
29
  handle_message_filter_on(opts)
48
30
  handle_cutoff_on(opts)
49
31
  handle_verbosity_on(opts)
50
- handle_colours_on(opts)
32
+ handle_version_on(opts)
51
33
  handle_help_on(opts)
52
34
  end
53
35
  end
@@ -62,7 +44,7 @@ class Hotspots
62
44
  opts.separator "Version #{::Hotspots::VERSION}"
63
45
  opts.separator "Copyright (C) 2011-2013 Chirantan Mitra"
64
46
  opts.separator ""
65
- opts.separator "Usage: ruby hotspots [options]"
47
+ opts.separator "Usage: hotspots [options]"
66
48
  opts.separator ""
67
49
  opts.separator "Specific options:"
68
50
  end
@@ -70,14 +52,14 @@ class Hotspots
70
52
  def handle_time_on(opts)
71
53
  opts.on("-t", "--time [TIME]", OptionParser::DecimalInteger,
72
54
  "Time in days to scan the repository for. Defaults to fifteen") do |o|
73
- @options[:time] = o.to_i
55
+ @configuration.time = o.to_i
74
56
  end
75
57
  end
76
58
 
77
59
  def handle_path_on(opts)
78
60
  opts.on("-r", "--repository [PATH]", String,
79
61
  "Path to the repository to scan. Defaults to current path") do |o|
80
- @options[:repository] = o.to_s
62
+ @configuration.repository = o.to_s
81
63
  end
82
64
  end
83
65
 
@@ -85,43 +67,43 @@ class Hotspots
85
67
  opts.on("-f", "--file-filter [REGEX]", String,
86
68
  "Regular expression to filtering file names.",
87
69
  "All files are allowed when not specified") do |o|
88
- @options[:file_filter] = o.to_s
70
+ @configuration.file_filter = o.to_s
89
71
  end
90
72
  end
91
73
 
92
74
  def handle_message_filter_on(opts)
93
75
  opts.on("-m", "--message-filter [PIPE SEPARATED]", String,
94
76
  "Pipe separated values to filter files names against each commit message.",
95
- "All files are allowed when not specified") do |o|
96
- @options[:message_filters] = o.to_s.split("|")
77
+ "All commit messages are allowed when not specified") do |o|
78
+ @configuration.message_filters = o.to_s.split("|")
97
79
  end
98
80
  end
99
81
 
100
82
  def handle_cutoff_on(opts)
101
83
  opts.on("-c", "--cutoff [CUTOFF]", OptionParser::DecimalInteger,
102
- "The minimum occurance to consider for a file to appear in the list. Defaults to zero") do |o|
103
- @options[:cutoff] = o.to_i
84
+ "The minimum occurrence to consider for a file to appear in the list. Defaults to zero") do |o|
85
+ @configuration.cutoff = o.to_i
104
86
  end
105
87
  end
106
88
 
107
89
  def handle_verbosity_on(opts)
108
90
  opts.on("-v", "--verbose",
109
91
  "Show verbose output") do
110
- @options[:verbose] = true
92
+ @configuration.enable_verbosity
111
93
  end
112
94
  end
113
95
 
114
- def handle_colours_on(opts)
115
- opts.on("-C", "--colour", "--color",
116
- "Show verbose output in colours") do
117
- @options[:colour] = true
96
+ def handle_version_on(opts)
97
+ opts.on_tail("--version",
98
+ "Show version") do
99
+ @configuration.exit_strategy = Exit::Safe.new(:message => "hotspots #{::Hotspots::VERSION}\n")
118
100
  end
119
101
  end
120
102
 
121
103
  def handle_help_on(opts)
122
104
  opts.on_tail("-h", "--help",
123
105
  "Show this message") do
124
- @options[:exit_strategy] = OptionBasedExit::Safe.new(:message => opts.to_s)
106
+ @configuration.exit_strategy = Exit::Safe.new(:message => opts.to_s)
125
107
  end
126
108
  end
127
109
  end
@@ -0,0 +1,17 @@
1
+ class Hotspots
2
+ module Repository #:nodoc: all
3
+ module Git
4
+ class << self
5
+ def installed?
6
+ `git --help 2>&1`
7
+ $? == 0
8
+ end
9
+
10
+ def inside_valid_repository?
11
+ `git status 2>&1`
12
+ $? == 0
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,38 @@
1
+ class Hotspots
2
+ module Repository #:nodoc: all
3
+ module GitCommand
4
+ class Log
5
+ def initialize(options)
6
+ @since_days = options[:since_days]
7
+ @message_filter = options[:message_filter].to_s
8
+ end
9
+
10
+ def to_s
11
+ "git log --pretty=\"%H\" #{since_clause}#{grep_clause}"
12
+ end
13
+
14
+ private
15
+
16
+ def since_clause
17
+ "--since=\"#{@since_days} days ago\""
18
+ end
19
+
20
+ def grep_clause
21
+ @message_filter.empty? ? "" : " --grep \"#{@message_filter}\""
22
+ end
23
+ end
24
+
25
+ class Show
26
+ attr_reader :commit_hash
27
+
28
+ def initialize(options)
29
+ @commit_hash = options[:commit_hash]
30
+ end
31
+
32
+ def to_s
33
+ "git show --oneline --name-only #{commit_hash}"
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,35 @@
1
+ require "ansi/code"
2
+ require "open3"
3
+
4
+ class Hotspots
5
+ module Repository #:nodoc: all
6
+ class GitDriver
7
+ def initialize(options)
8
+ @logger = options[:logger]
9
+ end
10
+
11
+ def pretty_log(options)
12
+ execute GitCommand::Log.new(:since_days => options[:since_days], :message_filter => options[:message_filter]).to_s
13
+ end
14
+
15
+ def show_one_line_names(options)
16
+ execute GitCommand::Show.new(:commit_hash => options[:commit_hash]).to_s
17
+ end
18
+
19
+ private
20
+
21
+ def execute(command)
22
+ @logger.info { ::ANSI::Code.blue("[input]\n#{command}") }
23
+ output = ""
24
+ Open3.popen3(command) do |stdin, stdout, stderr, wait_thr|
25
+ error = stderr.gets(nil)
26
+ output = stdout.gets(nil)
27
+ @logger.error { ::ANSI::Code.red("[error]\n#{error}") } if error
28
+ @logger.info { ::ANSI::Code.green("[output]\n#{output}") } if output
29
+ Exit::Error.new(:message => "Error encountered while running git", :code => 1).perform unless wait_thr.value.success?
30
+ end
31
+ output || ""
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,25 @@
1
+ class Hotspots
2
+ module Repository #:nodoc: all
3
+ class GitParser
4
+ attr_reader :driver, :time, :message_filters
5
+
6
+ def initialize(driver, options)
7
+ @driver = driver
8
+ @time = options[:time]
9
+ @message_filters = options[:message_filters]
10
+ end
11
+
12
+ def files
13
+ filtered_commit_hashes.reduce([]) do |acc, commit_hash|
14
+ acc + driver.show_one_line_names({:commit_hash => commit_hash}).lines.map(&:strip)[1..-1]
15
+ end
16
+ end
17
+
18
+ def filtered_commit_hashes
19
+ message_filters.reduce([]) do |acc, filter|
20
+ acc + driver.pretty_log({:since_days => time, :message_filter => filter}).lines.map(&:strip)
21
+ end.uniq
22
+ end
23
+ end
24
+ end
25
+ end
@@ -1,3 +1,5 @@
1
- require 'hotspots/repository/command'
2
- require 'hotspots/repository/driver'
3
- require 'hotspots/repository/parser'
1
+ require "hotspots/repository/git"
2
+ require "hotspots/repository/git_command"
3
+ require "hotspots/repository/git_command"
4
+ require "hotspots/repository/git_driver"
5
+ require "hotspots/repository/git_parser"
@@ -7,7 +7,7 @@ class Hotspots
7
7
  @filter = options[:file_filter] || ""
8
8
 
9
9
  @lines.map { |line| line.strip }.
10
- select{ |line| not line.empty? and line =~ Regexp.new(@filter) }.
10
+ select{ |line| !line.empty? && line =~ Regexp.new(@filter) }.
11
11
  each { |line| @store[line] += 1 }
12
12
  end
13
13
 
@@ -23,7 +23,7 @@ class Hotspots
23
23
  private
24
24
 
25
25
  def sorted_array
26
- @sorted_array ||= @store.sort do |(key1, value1), (key2, value2)|
26
+ @sorted_array ||= @store.sort do |(_, value1), (_, value2)|
27
27
  value2 <=> value1
28
28
  end
29
29
  end
@@ -1,3 +1,3 @@
1
1
  class Hotspots
2
- VERSION = "1.1.0"
2
+ VERSION = "1.2.0"
3
3
  end
data/lib/hotspots.rb CHANGED
@@ -1,82 +1,61 @@
1
- require 'hotspots/version'
2
- require 'hotspots/logger'
3
- require 'hotspots/store'
4
- require 'hotspots/options_parser'
5
- require 'hotspots/repository'
6
- require 'hotspots/compatibility'
1
+ require "hotspots/version"
2
+ require "hotspots/exit"
3
+ require "hotspots/configuration"
4
+ require "hotspots/store"
5
+ require "hotspots/options_parser"
6
+ require "hotspots/repository"
7
7
 
8
8
  class Hotspots
9
- attr_reader :logger, :repository, :verbose, :colour,
10
- :exit_strategy, :driver, :parser, :store,
11
- :time, :message_filters, :file_filter, :cutoff
9
+ attr_reader :configuration
12
10
 
13
- def initialize(opts)
14
- options = Hotspots::OptionsParser.default_options.merge(opts)
15
-
16
- @logger = Hotspots::Logger.new
17
- @repository = options[:repository]
18
- @verbose = options[:verbose]
19
- @colour = options[:colour]
20
- @exit_strategy = options[:exit_strategy]
21
-
22
- @time = options[:time]
23
- @message_filters = options[:message_filters]
24
- @file_filter = options[:file_filter]
25
- @cutoff = options[:cutoff]
11
+ def initialize(configuration)
12
+ @configuration = configuration
26
13
  end
27
14
 
28
15
  def output
29
16
  validate
30
- set
31
- run
17
+ inside_repository do
18
+ run
19
+ end
32
20
  end
33
21
 
34
22
  private
35
23
 
36
- def validate #:nodoc:
37
- exit_if_options_are_for_help
38
- exit_if_not_git_repository
24
+ def validate
25
+ configuration.exit_strategy.perform
26
+ ensure_git_installed
27
+ ensure_git_repository
39
28
  end
40
29
 
41
- def set #:nodoc:
42
- configure_logger
43
- set_path
44
- assign
30
+ def inside_repository
31
+ yield Dir.chdir(repository)
45
32
  end
46
33
 
47
- def run #:nodoc:
34
+ def run
48
35
  puts store.to_s
49
36
  end
50
37
 
51
- def exit_if_options_are_for_help
52
- exit_strategy.perform
38
+ def ensure_git_installed
39
+ Exit::Error.new(:message => "git not installed or not present in PATH!", :code => 10).perform unless Repository::Git.installed?
53
40
  end
54
41
 
55
- def exit_if_not_git_repository
56
- output = `git status 2>&1`
57
- unless $? == 0
58
- puts "'#{repository}' doesn't seem to be a git repository!"
59
- exit 10
60
- end
42
+ def ensure_git_repository
43
+ Exit::Error.new(:message => "'#{@repository}' doesn't seem to be a git repository!", :code => 10).perform unless Repository::Git.inside_valid_repository?
61
44
  end
62
45
 
63
- def configure_logger
64
- if verbose
65
- logger.as_console
66
- end
46
+ def repository
47
+ configuration.repository
48
+ end
67
49
 
68
- if colour
69
- logger.colourize
70
- end
50
+ def store
51
+ Store.new(parser.files, :cutoff => configuration.cutoff, :file_filter => configuration.file_filter)
71
52
  end
72
53
 
73
- def set_path
74
- Dir.chdir(repository)
54
+ def parser
55
+ Repository::GitParser.new(driver, :time => configuration.time, :message_filters => configuration.message_filters)
75
56
  end
76
57
 
77
- def assign
78
- @driver = Hotspots::Repository::Driver::Git.new logger
79
- @parser = Hotspots::Repository::Parser::Git.new driver, :time => time, :message_filters => message_filters
80
- @store = Hotspots::Store.new parser.files, :cutoff => cutoff, :file_filter => file_filter
58
+ def driver
59
+ Repository::GitDriver.new(:logger => configuration.logger)
81
60
  end
82
61
  end
@@ -0,0 +1,65 @@
1
+ require File.join(File.expand_path(File.dirname(__FILE__)), '..', 'minitest_helper')
2
+
3
+ class Hotspots
4
+ class StubLogger
5
+ attr_accessor :level
6
+ def initialize
7
+ @level = :uninitialized
8
+ end
9
+ end
10
+
11
+ describe "Configuration" do
12
+ describe "#initialize" do
13
+ let(:stub_logger) { StubLogger.new }
14
+ let(:configuration) { Configuration.new(:logger => stub_logger, :info_log_level => :info_level, :error_log_level => :error_level) }
15
+
16
+ it "defaults repository to the current path" do
17
+ expect(configuration.repository).must_equal "."
18
+ end
19
+
20
+ it "defaults time to 15" do
21
+ expect(configuration.time).must_equal 15
22
+ end
23
+
24
+ it "defaults file filter to empty string" do
25
+ expect(configuration.file_filter).must_equal ""
26
+ end
27
+
28
+ it "defaults message filters to array with an empty string" do
29
+ expect(configuration.message_filters).must_equal [""]
30
+ end
31
+
32
+ it "defaults cutoff to 0" do
33
+ expect(configuration.cutoff).must_equal 0
34
+ end
35
+
36
+ it "set a logger" do
37
+ expect(configuration.logger).wont_be :nil?
38
+ end
39
+
40
+ it "defaults the logger level to error" do
41
+ expect(configuration.logger.level).must_equal :error_level
42
+ end
43
+
44
+ it "defaults exit code to nil" do
45
+ expect(configuration.exit_strategy.code).must_be :nil?
46
+ end
47
+
48
+ it "defaults exit message to empty string" do
49
+ expect(configuration.exit_strategy.message).must_equal ""
50
+ end
51
+ end
52
+
53
+ describe "#initialize without default logger" do
54
+ let(:configuration) { Configuration.new }
55
+
56
+ it "set a logger" do
57
+ expect(configuration.logger).wont_be :nil?
58
+ end
59
+
60
+ it "defaults the logger level to error" do
61
+ expect(configuration.logger.level).must_equal ::Logger::ERROR
62
+ end
63
+ end
64
+ end
65
+ end