warp-dir 1.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (61) hide show
  1. checksums.yaml +7 -0
  2. data/.atom-build.json +22 -0
  3. data/.codeclimate.yml +22 -0
  4. data/.gitignore +40 -0
  5. data/.idea/encodings.xml +6 -0
  6. data/.idea/misc.xml +14 -0
  7. data/.idea/modules.xml +8 -0
  8. data/.idea/runConfigurations/All_Specs.xml +33 -0
  9. data/.idea/vcs.xml +6 -0
  10. data/.idea/warp-dir.iml +224 -0
  11. data/.rspec +4 -0
  12. data/.rubocop.yml +1156 -0
  13. data/.travis.yml +13 -0
  14. data/Gemfile +4 -0
  15. data/Guardfile +14 -0
  16. data/LICENSE +22 -0
  17. data/README.md +114 -0
  18. data/ROADMAP.md +96 -0
  19. data/Rakefile +24 -0
  20. data/bin/console +11 -0
  21. data/bin/setup +8 -0
  22. data/bin/warp-dir +13 -0
  23. data/bin/warp-dir.bash +25 -0
  24. data/lib/warp.rb +4 -0
  25. data/lib/warp/dir.rb +65 -0
  26. data/lib/warp/dir/app/cli.rb +162 -0
  27. data/lib/warp/dir/app/response.rb +133 -0
  28. data/lib/warp/dir/command.rb +120 -0
  29. data/lib/warp/dir/command/add.rb +16 -0
  30. data/lib/warp/dir/command/help.rb +80 -0
  31. data/lib/warp/dir/command/install.rb +78 -0
  32. data/lib/warp/dir/command/list.rb +13 -0
  33. data/lib/warp/dir/command/ls.rb +31 -0
  34. data/lib/warp/dir/command/remove.rb +16 -0
  35. data/lib/warp/dir/command/warp.rb +24 -0
  36. data/lib/warp/dir/commander.rb +71 -0
  37. data/lib/warp/dir/config.rb +87 -0
  38. data/lib/warp/dir/errors.rb +60 -0
  39. data/lib/warp/dir/formatter.rb +77 -0
  40. data/lib/warp/dir/point.rb +53 -0
  41. data/lib/warp/dir/serializer.rb +14 -0
  42. data/lib/warp/dir/serializer/base.rb +43 -0
  43. data/lib/warp/dir/serializer/dotfile.rb +36 -0
  44. data/lib/warp/dir/store.rb +129 -0
  45. data/lib/warp/dir/version.rb +6 -0
  46. data/spec/fixtures/warprc +2 -0
  47. data/spec/spec_helper.rb +71 -0
  48. data/spec/support/cli_expectations.rb +118 -0
  49. data/spec/warp/dir/app/cli_spec.rb +225 -0
  50. data/spec/warp/dir/app/response_spec.rb +131 -0
  51. data/spec/warp/dir/command_spec.rb +62 -0
  52. data/spec/warp/dir/commands/add_spec.rb +40 -0
  53. data/spec/warp/dir/commands/install_spec.rb +20 -0
  54. data/spec/warp/dir/commands/list_spec.rb +37 -0
  55. data/spec/warp/dir/config_spec.rb +45 -0
  56. data/spec/warp/dir/errors_spec.rb +16 -0
  57. data/spec/warp/dir/formatter_spec.rb +38 -0
  58. data/spec/warp/dir/point_spec.rb +35 -0
  59. data/spec/warp/dir/store_spec.rb +105 -0
  60. data/warp-dir.gemspec +56 -0
  61. metadata +228 -0
@@ -0,0 +1,43 @@
1
+ module Warp
2
+ module Dir
3
+ module Serializer
4
+ class Base
5
+ attr_accessor :store
6
+
7
+ def initialize store
8
+ self.store = store
9
+ end
10
+
11
+ def config
12
+ self.store.config
13
+ end
14
+
15
+ def self.inherited subclass
16
+ Warp::Dir::SERIALIZERS[subclass.name] = subclass
17
+ end
18
+
19
+ #
20
+ # restore method should read the values from somewhere (i.e. database?)
21
+ # and perform the following operation:
22
+ #
23
+ # for each [ shortcut, path ] do
24
+ # self.store.add(shortcut, path)
25
+ # end
26
+
27
+ def restore!
28
+ raise NotImplementedError.new('Abstract Method')
29
+ end
30
+
31
+ #
32
+ # save shortcuts to the persistence layer
33
+ #
34
+ # store.points.each_pair |shortcut, path| do
35
+ # save(shortcut, path)
36
+ # end
37
+ def persist!
38
+ raise NotImplementedError.new('Abstract Method')
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,36 @@
1
+ require_relative '../errors'
2
+ require_relative '../../dir'
3
+ module Warp
4
+ module Dir
5
+ module Serializer
6
+ class Dotfile < Base
7
+
8
+ def restore!
9
+ File.open(Warp::Dir.absolute(config.warprc), "r") do |f|
10
+ f.each_line do |line|
11
+ line = line.chomp
12
+ next if line.blank?
13
+ name, path = line.split(/:/)
14
+ if name.nil? || path.nil?
15
+ raise Warp::Dir::Errors::StoreFormatError.new("File may be corrupt - #{config.warprc}:#{line}", line)
16
+ end
17
+ store.add point_name: name, point_path: path
18
+ end
19
+ end
20
+ end
21
+
22
+ def persist!
23
+ File.open(Warp::Dir.absolute(config.warprc), 'w') do |file|
24
+ buffer = ''
25
+ store.points.each do |point|
26
+ buffer << "#{point.name}:#{point.relative_path}\n"
27
+ end
28
+ file.write(buffer)
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
35
+
36
+
@@ -0,0 +1,129 @@
1
+ require_relative 'point'
2
+ require_relative 'errors'
3
+ require_relative 'serializer'
4
+ require 'forwardable'
5
+
6
+ module Warp
7
+ module Dir
8
+
9
+ # We want to keep around only one store, so we follow the Singleton patter.
10
+ # Due to us wanting to pass parameters to the singleton class's #new method,
11
+ # using standard Singleton becomes more hassle than it's worth.
12
+ class Store
13
+ extend Forwardable
14
+
15
+ def_delegators :@points_collection, :size, :clear, :each, :map
16
+ def_delegators :@config, :warprc, :shell
17
+
18
+ attr_reader :config, :serializer, :points_collection
19
+
20
+ def initialize(config, serializer_class = Warp::Dir::Serializer.default)
21
+ @config = config
22
+ serializer_class ||= Warp::Dir::Serializer.default
23
+ @serializer = serializer_class.new(self)
24
+ restore!
25
+ end
26
+
27
+ def restore!
28
+ @points_collection = Set.new
29
+ self.serializer.restore!
30
+ end
31
+
32
+ def [](name)
33
+ find_point(name)
34
+ end
35
+
36
+ def first
37
+ points_collection.to_a.sort.first
38
+ end
39
+
40
+ def last
41
+ points_collection.to_a.sort.last
42
+ end
43
+
44
+ def <<(value)
45
+ raise ArgumentError.new("#{value} is not a Point") unless value.is_a?(Point)
46
+ self.add(point: value)
47
+ end
48
+
49
+ def remove(point_name: nil)
50
+ point = point_name.is_a?(Warp::Dir::Point) ? point_name : self[point_name]
51
+ self.points_collection.delete(point) if point
52
+ save!
53
+ end
54
+
55
+ def points
56
+ points_collection.to_a
57
+ end
58
+
59
+ def find_point(name_or_point)
60
+ return if name_or_point.nil?
61
+ result = if name_or_point.is_a?(Warp::Dir::Point)
62
+ self.find_point(name_or_point.name)
63
+ else
64
+ matching_set = self.points_collection.classify { |p| p.name.to_sym }[name_or_point.to_sym]
65
+ (matching_set && !matching_set.empty?) ? matching_set.first : nil
66
+ end
67
+ raise ::Warp::Dir::Errors::PointNotFound.new(name_or_point) unless result
68
+ result
69
+ end
70
+
71
+ def save!
72
+ serializer.persist!
73
+ end
74
+
75
+ # a version of add that save right after.
76
+ def insert(*args)
77
+ add(*args)
78
+ save!
79
+ end
80
+
81
+ # add to memory representation only
82
+ def add(point: nil,
83
+ point_name: nil,
84
+ point_path: nil,
85
+ overwrite: false)
86
+ unless point
87
+ if !(point_name && point_path)
88
+ raise ArgumentError.new('invalid arguments')
89
+ end
90
+ point = Warp::Dir::Point.new(point_name, point_path)
91
+ end
92
+
93
+ # Three use-cases here.
94
+ # if we found this WarpPoint by name, and it's path is different from the incoming...
95
+ existing = begin
96
+ self[point]
97
+ rescue Warp::Dir::Errors::PointNotFound
98
+ nil
99
+ end
100
+
101
+ if existing.eql?(point) # found, but it's identical
102
+ if config.debug
103
+ puts "Point being added #{point} is identical to existing #{existing}, ignore."
104
+ end
105
+ return
106
+ elsif existing # found, but it's different
107
+ if overwrite # replace it
108
+ if config.debug
109
+ puts "Point being added #{point} is replacing the existing #{existing}."
110
+ end
111
+ replace(point, existing)
112
+ else # reject it
113
+ if config.debug
114
+ puts "Point being added #{point} already exists, but no overwrite was set"
115
+ end
116
+ raise Warp::Dir::Errors::PointAlreadyExists.new(point)
117
+ end
118
+ else # no lookup found
119
+ self.points_collection << point # add it
120
+ end
121
+ end
122
+
123
+ def replace(point, existing_point)
124
+ remove(point_name: existing_point)
125
+ insert(point: point)
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,6 @@
1
+ module Warp
2
+ module Dir
3
+ VERSION = '1.1.0'
4
+ end
5
+ end
6
+
@@ -0,0 +1,2 @@
1
+ tmp:/tmp
2
+ log:/var/log
@@ -0,0 +1,71 @@
1
+ require 'codeclimate-test-reporter'
2
+ require 'warp/dir'
3
+ require 'rspec/core'
4
+
5
+ CodeClimate::TestReporter.start
6
+
7
+ module Warp
8
+ module Dir
9
+ module App
10
+ class Response
11
+ class << self
12
+ attr_accessor :exit_disabled
13
+
14
+ def enable_exit!
15
+ self.exit_disabled = false
16
+ end
17
+
18
+ def disable_exit!
19
+ self.exit_disabled = true
20
+ end
21
+
22
+ def exit_disabled?
23
+ self.exit_disabled
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
30
+
31
+ RSpec.configure do |config|
32
+ config.before do
33
+ Warp::Dir::App::Response.disable_exit!
34
+ end
35
+ end
36
+
37
+ RSpec.shared_context :fake_serializer do
38
+ let(:file) { @file ||= ::Tempfile.new('warp-dir') }
39
+ let(:config) { Warp::Dir::Config.new(config: file.path) }
40
+ let(:serializer) {
41
+ @initialized_store ||= FakeSerializer ||= Class.new(Warp::Dir::Serializer::Base) do
42
+ def persist!;
43
+ end
44
+
45
+ def restore!;
46
+ end
47
+ end
48
+ }
49
+
50
+ after do
51
+ file.close
52
+ file.unlink
53
+ end
54
+ end
55
+
56
+ RSpec.shared_context :fixture_file do
57
+ let(:fixture_file) { 'spec/fixtures/warprc'}
58
+ let(:config_path) { '/tmp/warprc' }
59
+ let(:file) {
60
+ FileUtils.cp(fixture_file, config_path)
61
+ File.new(config_path)
62
+ }
63
+ let(:config) { Warp::Dir::Config.new(config: file.path) }
64
+ end
65
+
66
+ RSpec.shared_context :initialized_store do
67
+ let(:store) { Warp::Dir::Store.new(config) }
68
+ let(:wp_path) { ENV['HOME'] + '/workspace/tinker-mania' }
69
+ let(:wp_name) { 'harro' }
70
+ let(:point) { Warp::Dir::Point.new(wp_name, wp_path) }
71
+ end
@@ -0,0 +1,118 @@
1
+ require 'rspec/expectations'
2
+ require 'warp/dir/app/cli'
3
+ require 'rspec/expectations'
4
+
5
+ module Warp
6
+ module Dir
7
+ module CLIHelper
8
+
9
+ def run_command!(arguments)
10
+ argv = arguments.is_a?(Array) ? arguments : arguments.split(' ')
11
+ cli = Warp::Dir::App::CLI.new(argv)
12
+ cli.run
13
+ end
14
+
15
+ def validate!(arguments, yield_before_validation: false)
16
+ argv = arguments.is_a?(Array) ? arguments : arguments.split(' ')
17
+ cli = Warp::Dir::App::CLI.new(argv)
18
+ if yield_before_validation
19
+ yield(cli) if block_given?
20
+ end
21
+ cli.validate
22
+ unless yield_before_validation
23
+ yield(cli) if block_given?
24
+ end
25
+ cli.run
26
+ end
27
+
28
+ def output_matches(output, expected)
29
+ if expected.is_a?(Regexp)
30
+ expected.match(output)
31
+ elsif expected.is_a?(String)
32
+ output.include?(expected)
33
+ else
34
+ nil
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
40
+
41
+ RSpec::Matchers.define :output do |*expectations|
42
+ include Warp::Dir::CLIHelper
43
+ match do |actual|
44
+ @response = nil
45
+ @command = "wd #{actual.is_a?(Array) ? actual.join(' ') : actual}"
46
+ expectations.all? do |expected|
47
+ @response = run_command!(actual)
48
+ @response.messages.any? { |m| output_matches(m, expected) }
49
+ end
50
+ end
51
+ failure_message do |actual|
52
+ "#{@command} was supposed to produce something matching or containing:\nexpected: '#{expected}',\n actual: #{@response.messages}"
53
+ end
54
+ match_when_negated do |actual|
55
+ @response = nil
56
+ @command = "wd #{actual.is_a?(Array) ? actual.join(' ') : actual}"
57
+ expectations.none? do |expected|
58
+ @response = run_command!(actual)
59
+ @response.messages.any? { |m| output_matches(m, expected) }
60
+ end
61
+ end
62
+ failure_message_when_negated do |actual|
63
+ "expected #{actual} not to contain #{expected}, got #{@response}"
64
+ end
65
+ end
66
+
67
+ RSpec::Matchers.define :validate do |expected|
68
+ include Warp::Dir::CLIHelper
69
+ match do |actual|
70
+ if expected == true || expected == false
71
+ yield_before_validation = expected
72
+ end
73
+ expected = block_arg
74
+ if expected.is_a?(Proc)
75
+ begin
76
+ @response = validate!(actual, yield_before_validation: yield_before_validation) do |cli|
77
+ expected.call(cli)
78
+ end
79
+ rescue Exception => e
80
+ STDERR.puts(e.inspect)
81
+ STDERR.puts(e.backtrace.join("\n"))
82
+ raise
83
+ end
84
+ else
85
+ raise TypeError.new('Expected must be a block')
86
+ end
87
+ end
88
+ failure_message do |actual|
89
+ "expected #{actual} to validate that the block evaluates to true"
90
+ end
91
+
92
+ end
93
+
94
+ RSpec::Matchers.define :exit_with do |expected|
95
+ include Warp::Dir::CLIHelper
96
+
97
+ match do |actual|
98
+ response = run_command!(actual)
99
+ response.code == expected
100
+ end
101
+ match_when_negated do |actual|
102
+ response = run_command!(actual)
103
+ response.code != expected
104
+ end
105
+ end
106
+
107
+ # RSpec::Matchers.define :eval_to_true_after_validate do |expected|
108
+ # match do |actual|
109
+ # expected_type = expected.is_a?(Symbol) ?
110
+ # Warp::Dir::App::Response::RETURN_TYPE[expected_type] :
111
+ # expected
112
+ # response = run_and_yield(expected)
113
+ # response.type == expected_type
114
+ # end
115
+ # failure_message_for_should_not do |actual|
116
+ # "expected #{expected} to produce return type #{}"
117
+ # end
118
+ # end
@@ -0,0 +1,225 @@
1
+ require 'spec_helper'
2
+ require 'support/cli_expectations'
3
+ require 'warp/dir'
4
+ require 'warp/dir/config'
5
+ require 'warp/dir/app/cli'
6
+ require 'pp'
7
+ require 'fileutils'
8
+
9
+ RSpec.describe Warp::Dir::App::CLI do
10
+ include_context :fixture_file
11
+ include_context :initialized_store
12
+
13
+ let(:config_args) { ['--config', config.warprc] }
14
+ let(:warprc) { config_args.join(' ') }
15
+
16
+ describe 'when parsing argument list' do
17
+ let(:cli) { Warp::Dir::App::CLI.new(argv) }
18
+ before do
19
+ cli.config = config
20
+ end
21
+
22
+ describe 'with suffix flags' do
23
+ subject { cli.send(:extract_suffix_flags, argv) }
24
+ describe 'and with at leats two arguments' do
25
+ let(:argv) { 'command argument --flag1 --flag2 -- --suffix1 --suffix2 suffix-argument'.split(' ')}
26
+ it 'extracts them well' do
27
+ should eql(%w(--suffix1 --suffix2 suffix-argument))
28
+ end
29
+ end
30
+ end
31
+
32
+ describe 'with only one argument' do
33
+ let(:result) { cli.send(:shift_non_flag_commands) }
34
+
35
+ describe "that's a list command" do
36
+ let(:argv) { %w(list --verbose) }
37
+
38
+ it 'should assign the command' do
39
+ expect(cli.argv).to eql(%w(list --verbose))
40
+ expect(result[:command]).to eql(:list)
41
+ expect(cli.argv).to eql(['--verbose'])
42
+ end
43
+ end
44
+
45
+ describe "that's a warp point" do
46
+ let(:argv) { %w(awesome-point) }
47
+
48
+ it 'should default to the :warp command' do
49
+ expect(result[:command]).to eql(:warp)
50
+ expect(result[:point]).to eql(:'awesome-point')
51
+ expect(cli.argv).to be_empty
52
+ end
53
+ end
54
+ end
55
+
56
+ describe 'with two command args' do
57
+ let(:argv) { %w(add mypoint) }
58
+ let(:result) { cli.send(:shift_non_flag_commands) }
59
+
60
+ it 'should interpret as a command and a point' do
61
+ expect(result[:command]).to eql(:add)
62
+ expect(result[:point]).to eql(:mypoint)
63
+ expect(cli.argv).to be_empty
64
+ end
65
+ end
66
+ end
67
+
68
+ describe 'when parsing flags' do
69
+ describe 'and found --help' do
70
+ let(:argv) { ['--help', *config_args] }
71
+ it 'should print the help message' do
72
+ expect(argv).to output(/<point>/, /Usage:/)
73
+ expect(argv).not_to output(/^cd /)
74
+ end
75
+ it 'should exit with zero status' do
76
+ expect(argv).to exit_with(0)
77
+ end
78
+ end
79
+
80
+ describe 'and a flag is no found' do
81
+ let(:argv) { [ '--boo mee --moo', *config_args ] }
82
+ it 'should report invalid option' do
83
+ expect(argv).to output( /unknown option/)
84
+ end
85
+ end
86
+
87
+ describe 'when an exception error occurs' do
88
+ let(:argv) { [ %w(boo dkk --debug), *config_args ].flatten }
89
+ context 'and --debug is given' do
90
+ it 'should print backtrace' do
91
+ expect(argv.join(' ')).to eql('boo dkk --debug --config /tmp/warprc')
92
+ expect(STDERR).to receive(:puts).twice
93
+ expect(argv).to validate(false) { |cli|
94
+ expect(cli.config.debug).to be_truthy
95
+ }
96
+ end
97
+ end
98
+ end
99
+ end
100
+
101
+ describe 'when running command' do
102
+ describe 'without a parameter' do
103
+ describe 'such as list' do
104
+ let(:argv) { ['list', *config_args] }
105
+
106
+ it 'should return listing of all points' do
107
+ expect("list #{warprc}").to output %r{log -> /var/log}
108
+ expect("list #{warprc}").to output %r{tmp -> /tmp}
109
+ end
110
+
111
+ it 'should exit with zero status' do
112
+ expect("list #{warprc}").to exit_with(0)
113
+ end
114
+ end
115
+ end
116
+
117
+ describe 'with a point arg, such as ' do
118
+ let(:wp_name) { store.last.name }
119
+ let(:wp_path) { store.last.path }
120
+ let(:warp_args) { "#{wp_name} #{warprc}" }
121
+
122
+ describe 'warp <point>' do
123
+ it "should return response with a 'cd' to a warp point" do
124
+ warp_point = wp_name
125
+ expect(warp_args).to validate { |cli|
126
+ expect(cli.config.point).to eql(warp_point)
127
+ expect(cli.config.command).to eql(:warp)
128
+ expect(cli.store[warp_point]).to eql(Warp::Dir::Point.new(wp_name, wp_path))
129
+ }
130
+ expect(warp_args).to output("cd #{wp_path}")
131
+ end
132
+ end
133
+
134
+ describe 'remove <point>' do
135
+ let(:warp_args) { "remove #{wp_name} #{warprc}" }
136
+
137
+ it 'should show that point is removed ' do
138
+ expect(warp_args).to output(/has been removed/)
139
+ end
140
+ it 'should change warp point count ' do
141
+ expect(store.size).to eq(2)
142
+ expect {
143
+ expect(warp_args).to validate { |cli|
144
+ expect(cli.config.point).to eql(point.name)
145
+ expect(cli.config.command).to eql(:remove)
146
+ }
147
+ store.restore!
148
+ }.to change(store, :size).by(-1)
149
+ expect(store.size).to eq(1)
150
+ end
151
+ end
152
+
153
+ describe 'add <point>' do
154
+ context 'when point exists' do
155
+ context 'without --force flag' do
156
+ let(:warp_args) { "add #{wp_name} #{warprc}" }
157
+
158
+ it 'should show error without' do
159
+ expect(warp_args).to output(/already exists/)
160
+ expect(warp_args).to exit_with(1)
161
+ end
162
+ end
163
+ context 'with --force' do
164
+ let(:warp_args) { "add #{wp_name} #{warprc} --force" }
165
+ it 'should overwrite existing point' do
166
+
167
+ expect(Warp::Dir.pwd).to_not eql(wp_path)
168
+
169
+ existing_point = store[wp_name]
170
+ expect(existing_point).to be_kind_of(Warp::Dir::Point)
171
+ expect(existing_point.path).to eql(wp_path)
172
+
173
+ expect {
174
+ response = expect(warp_args).to validate { |cli|
175
+ expect(cli.config.point).to eql(point.name)
176
+ expect(cli.config.command).to eql(:add)
177
+ expect(cli.store[point.name]).to_not be_nil
178
+ }
179
+ expect(response.type).to eql(Warp::Dir::App::Response::INFO), response.message
180
+ store.restore!
181
+ updated_point = store[wp_name]
182
+ expect(updated_point.relative_path).to eql(Warp::Dir::pwd)
183
+ }.to_not change(store, :size)
184
+
185
+ end
186
+ end
187
+ end
188
+ end
189
+
190
+ describe 'ls <point>' do
191
+ context 'no flags' do
192
+ let(:warp_args) { "ls #{wp_name} #{warprc}" }
193
+ it 'should default to -al' do
194
+ expect(warp_args).to output(%r{total \d+\n}, %r{ warprc\n})
195
+ end
196
+ end
197
+ context '-- -l' do
198
+ let(:warp_args) { "ls #{wp_name} #{warprc} -- -l" }
199
+ it 'should extract flags' do
200
+ expect(warp_args).to validate { |cli|
201
+ expect(cli.flags).to eql(['-l'])
202
+ }
203
+ end
204
+ it 'should ls folder in long format' do
205
+ expect(warp_args).to output(%r{total \d+\n}, %r{ warprc\n})
206
+ end
207
+ end
208
+ context '-- -1' do
209
+ let(:warp_args) { "ls #{wp_name} #{warprc} -- -1" }
210
+ it 'should not list directory in long format' do
211
+ expect(warp_args).not_to output(%r{total \d+\n}, %r{ warprc\n})
212
+ end
213
+ end
214
+ context '-- -elf' do
215
+ let(:warp_args) { "ls #{wp_name} #{warprc} -- -alF" }
216
+ [%r{total \d+\n}, %r{ warprc\n}].each do |reg|
217
+ it "should list directory and match #{reg}" do
218
+ expect(warp_args).to output(reg)
219
+ end
220
+ end
221
+ end
222
+ end
223
+ end
224
+ end
225
+ end