tarsier 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.
Files changed (47) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +175 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +984 -0
  5. data/exe/tarsier +7 -0
  6. data/lib/tarsier/application.rb +336 -0
  7. data/lib/tarsier/cli/commands/console.rb +87 -0
  8. data/lib/tarsier/cli/commands/generate.rb +85 -0
  9. data/lib/tarsier/cli/commands/help.rb +50 -0
  10. data/lib/tarsier/cli/commands/new.rb +59 -0
  11. data/lib/tarsier/cli/commands/routes.rb +139 -0
  12. data/lib/tarsier/cli/commands/server.rb +123 -0
  13. data/lib/tarsier/cli/commands/version.rb +14 -0
  14. data/lib/tarsier/cli/generators/app.rb +528 -0
  15. data/lib/tarsier/cli/generators/base.rb +93 -0
  16. data/lib/tarsier/cli/generators/controller.rb +91 -0
  17. data/lib/tarsier/cli/generators/middleware.rb +81 -0
  18. data/lib/tarsier/cli/generators/migration.rb +109 -0
  19. data/lib/tarsier/cli/generators/model.rb +109 -0
  20. data/lib/tarsier/cli/generators/resource.rb +27 -0
  21. data/lib/tarsier/cli/loader.rb +18 -0
  22. data/lib/tarsier/cli.rb +46 -0
  23. data/lib/tarsier/controller.rb +282 -0
  24. data/lib/tarsier/database.rb +588 -0
  25. data/lib/tarsier/errors.rb +77 -0
  26. data/lib/tarsier/middleware/base.rb +47 -0
  27. data/lib/tarsier/middleware/compression.rb +113 -0
  28. data/lib/tarsier/middleware/cors.rb +101 -0
  29. data/lib/tarsier/middleware/csrf.rb +88 -0
  30. data/lib/tarsier/middleware/logger.rb +74 -0
  31. data/lib/tarsier/middleware/rate_limit.rb +110 -0
  32. data/lib/tarsier/middleware/stack.rb +143 -0
  33. data/lib/tarsier/middleware/static.rb +124 -0
  34. data/lib/tarsier/model.rb +590 -0
  35. data/lib/tarsier/params.rb +269 -0
  36. data/lib/tarsier/query.rb +495 -0
  37. data/lib/tarsier/request.rb +274 -0
  38. data/lib/tarsier/response.rb +282 -0
  39. data/lib/tarsier/router/compiler.rb +173 -0
  40. data/lib/tarsier/router/node.rb +97 -0
  41. data/lib/tarsier/router/route.rb +119 -0
  42. data/lib/tarsier/router.rb +272 -0
  43. data/lib/tarsier/version.rb +5 -0
  44. data/lib/tarsier/websocket.rb +275 -0
  45. data/lib/tarsier.rb +167 -0
  46. data/sig/tarsier.rbs +485 -0
  47. metadata +230 -0
@@ -0,0 +1,528 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tarsier
4
+ class CLI
5
+ module Commands
6
+ class New
7
+ class Generator < Generators::Base
8
+ def initialize(name, options = {})
9
+ super(name, options)
10
+ @app_path = name
11
+ end
12
+
13
+ def generate
14
+ puts "Creating new Tarsier application: #{name}"
15
+ puts
16
+
17
+ create_directory(@app_path)
18
+ create_structure
19
+ create_files
20
+ initialize_git unless options[:skip_git]
21
+ run_bundle unless options[:skip_bundle]
22
+
23
+ print_success_message
24
+ end
25
+
26
+ private
27
+
28
+ def create_structure
29
+ dirs = %w[
30
+ app/controllers
31
+ app/models
32
+ app/middleware
33
+ app/views
34
+ bin
35
+ config
36
+ db/migrations
37
+ lib
38
+ public
39
+ spec/controllers
40
+ spec/models
41
+ spec/support
42
+ ]
43
+
44
+ dirs.reject! { |d| d.start_with?("app/views") } if options[:api]
45
+ dirs = %w[app/controllers bin config lib spec] if options[:minimal]
46
+
47
+ dirs.each { |dir| create_directory("#{@app_path}/#{dir}") }
48
+ end
49
+
50
+ def create_files
51
+ create_file("#{@app_path}/Gemfile", gemfile_content)
52
+ create_file("#{@app_path}/config.ru", config_ru_content)
53
+ create_file("#{@app_path}/config/application.rb", application_content)
54
+ create_file("#{@app_path}/config/routes.rb", routes_content)
55
+ create_file("#{@app_path}/app/controllers/application_controller.rb", app_controller)
56
+ create_file("#{@app_path}/app/controllers/home_controller.rb", home_controller)
57
+ create_file("#{@app_path}/spec/spec_helper.rb", spec_helper_content)
58
+ create_file("#{@app_path}/bin/server", server_script)
59
+ create_file("#{@app_path}/bin/console", console_script)
60
+ create_file("#{@app_path}/.rspec", rspec_content)
61
+ create_file("#{@app_path}/.gitignore", gitignore_content)
62
+ create_file("#{@app_path}/README.md", readme_content)
63
+
64
+ # Make scripts executable
65
+ FileUtils.chmod(0o755, "#{@app_path}/bin/server") if File.exist?("#{@app_path}/bin/server")
66
+ FileUtils.chmod(0o755, "#{@app_path}/bin/console") if File.exist?("#{@app_path}/bin/console")
67
+ end
68
+
69
+ def gemfile_content
70
+ <<~RUBY
71
+ # frozen_string_literal: true
72
+
73
+ source "https://rubygems.org"
74
+
75
+ gem "tarsier", "~> #{Tarsier::VERSION}"
76
+ gem "puma", "~> 6.0"
77
+ gem "rackup", "~> 2.1"
78
+
79
+ group :development do
80
+ gem "rerun"
81
+ end
82
+
83
+ group :test do
84
+ gem "rspec", "~> 3.12"
85
+ gem "rack-test", "~> 2.1"
86
+ end
87
+ RUBY
88
+ end
89
+
90
+ def config_ru_content
91
+ <<~RUBY
92
+ # frozen_string_literal: true
93
+
94
+ require_relative "config/application"
95
+
96
+ run Tarsier.current_app
97
+ RUBY
98
+ end
99
+
100
+ def application_content
101
+ <<~RUBY
102
+ # frozen_string_literal: true
103
+
104
+ require "bundler/setup"
105
+ require "tarsier"
106
+
107
+ # Load application files
108
+ Dir[File.expand_path("../app/**/*.rb", __dir__)].sort.each { |f| require f }
109
+
110
+ Tarsier.application do
111
+ configure do
112
+ self.secret_key = ENV.fetch("SECRET_KEY", "development_secret_\#{SecureRandom.hex(32)}")
113
+ end
114
+
115
+ # Middleware
116
+ use Tarsier::Middleware::Logger
117
+ #{options[:api] ? "" : "use Tarsier::Middleware::Static, root: 'public'"}
118
+
119
+ # Routes
120
+ routes do
121
+ root to: "home#index"
122
+
123
+ # Add your routes here
124
+ # resources :users
125
+ # namespace :api do
126
+ # resources :posts
127
+ # end
128
+ end
129
+ end
130
+ RUBY
131
+ end
132
+
133
+ def routes_content
134
+ <<~RUBY
135
+ # frozen_string_literal: true
136
+
137
+ # This file documents your routes.
138
+ # Routes are defined in config/application.rb within the routes block.
139
+ #
140
+ # Example routes:
141
+ # root to: "home#index"
142
+ # get "/about", to: "pages#about"
143
+ # resources :users
144
+ # namespace :api do
145
+ # resources :posts
146
+ # end
147
+ RUBY
148
+ end
149
+
150
+ def app_controller
151
+ <<~RUBY
152
+ # frozen_string_literal: true
153
+
154
+ class ApplicationController < Tarsier::Controller
155
+ # Add shared controller logic here
156
+ end
157
+ RUBY
158
+ end
159
+
160
+ def home_controller
161
+ <<~RUBY
162
+ # frozen_string_literal: true
163
+
164
+ class HomeController < ApplicationController
165
+ def index
166
+ #{options[:api] ? 'render json: { message: "Welcome to Tarsier!" }' : 'render html: welcome_html'}
167
+ end
168
+ #{options[:api] ? "" : home_html_method}
169
+ end
170
+ RUBY
171
+ end
172
+
173
+ def home_html_method
174
+ <<~RUBY
175
+
176
+ private
177
+
178
+ def welcome_html
179
+ <<~HTML
180
+ <!DOCTYPE html>
181
+ <html lang="en">
182
+ <head>
183
+ <meta charset="UTF-8">
184
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
185
+ <title>#{name} | Tarsier</title>
186
+ <style>
187
+ * { margin: 0; padding: 0; box-sizing: border-box; }
188
+ body {
189
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
190
+ background: #2d3748;
191
+ color: #e2e8f0;
192
+ min-height: 100vh;
193
+ display: flex;
194
+ align-items: center;
195
+ justify-content: center;
196
+ }
197
+ .container {
198
+ text-align: center;
199
+ padding: 2rem;
200
+ }
201
+ .logo {
202
+ width: 180px;
203
+ height: 180px;
204
+ margin-bottom: 2rem;
205
+ }
206
+ .logo svg {
207
+ width: 100%;
208
+ height: 100%;
209
+ }
210
+ h1 {
211
+ font-size: 2.5rem;
212
+ font-weight: 300;
213
+ margin-bottom: 0.5rem;
214
+ color: #fff;
215
+ }
216
+ .app-name {
217
+ color: #e57373;
218
+ font-weight: 600;
219
+ }
220
+ .tagline {
221
+ font-size: 1.1rem;
222
+ color: #a0aec0;
223
+ margin-bottom: 2.5rem;
224
+ }
225
+ .status {
226
+ display: inline-flex;
227
+ align-items: center;
228
+ gap: 0.5rem;
229
+ background: rgba(72, 187, 120, 0.15);
230
+ color: #68d391;
231
+ padding: 0.5rem 1rem;
232
+ border-radius: 2rem;
233
+ font-size: 0.9rem;
234
+ margin-bottom: 2rem;
235
+ }
236
+ .status-dot {
237
+ width: 8px;
238
+ height: 8px;
239
+ background: #68d391;
240
+ border-radius: 50%;
241
+ animation: pulse 2s infinite;
242
+ }
243
+ @keyframes pulse {
244
+ 0%, 100% { opacity: 1; }
245
+ 50% { opacity: 0.5; }
246
+ }
247
+ .links {
248
+ display: flex;
249
+ gap: 1rem;
250
+ justify-content: center;
251
+ flex-wrap: wrap;
252
+ }
253
+ .link {
254
+ color: #e57373;
255
+ text-decoration: none;
256
+ padding: 0.75rem 1.5rem;
257
+ border: 1px solid #e57373;
258
+ border-radius: 0.5rem;
259
+ transition: all 0.2s;
260
+ font-size: 0.9rem;
261
+ }
262
+ .link:hover {
263
+ background: #e57373;
264
+ color: #2d3748;
265
+ }
266
+ .footer {
267
+ margin-top: 3rem;
268
+ font-size: 0.85rem;
269
+ color: #718096;
270
+ }
271
+ code {
272
+ background: rgba(0,0,0,0.3);
273
+ padding: 0.2rem 0.5rem;
274
+ border-radius: 0.25rem;
275
+ font-family: 'SF Mono', Monaco, monospace;
276
+ font-size: 0.85rem;
277
+ }
278
+ </style>
279
+ </head>
280
+ <body>
281
+ <div class="container">
282
+ <div class="logo">
283
+ <svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
284
+ <!-- Ears -->
285
+ <polygon points="35,85 55,45 75,85" fill="#e57373"/>
286
+ <polygon points="125,85 145,45 165,85" fill="#e57373"/>
287
+ <!-- Left Eye Outer -->
288
+ <circle cx="65" cy="110" r="45" fill="#1a1a1a"/>
289
+ <circle cx="65" cy="110" r="40" fill="#fff"/>
290
+ <circle cx="65" cy="110" r="32" fill="#e57373"/>
291
+ <circle cx="65" cy="110" r="24" fill="#fff" fill-opacity="0.3"/>
292
+ <circle cx="65" cy="110" r="16" fill="#8b1a1a"/>
293
+ <!-- Diamond in left eye -->
294
+ <polygon points="65,100 72,110 65,120 58,110" fill="#fff" fill-opacity="0.8"/>
295
+ <!-- Right Eye Outer -->
296
+ <circle cx="135" cy="110" r="45" fill="#1a1a1a"/>
297
+ <circle cx="135" cy="110" r="40" fill="#fff"/>
298
+ <circle cx="135" cy="110" r="32" fill="#e57373"/>
299
+ <circle cx="135" cy="110" r="24" fill="#fff" fill-opacity="0.3"/>
300
+ <circle cx="135" cy="110" r="16" fill="#8b1a1a"/>
301
+ <!-- Highlight in right eye -->
302
+ <circle cx="140" cy="105" r="4" fill="#fff"/>
303
+ <!-- Nose -->
304
+ <polygon points="100,145 108,160 92,160" fill="#e57373"/>
305
+ </svg>
306
+ </div>
307
+ <h1>Welcome to <span class="app-name">#{name}</span></h1>
308
+ <p class="tagline">Powered by Tarsier Framework</p>
309
+ <div class="status">
310
+ <span class="status-dot"></span>
311
+ Application running
312
+ </div>
313
+ <div class="links">
314
+ <a href="https://github.com/tarsier-rb/tarsier" class="link" target="_blank">Documentation</a>
315
+ <a href="https://github.com/tarsier-rb/tarsier" class="link" target="_blank">GitHub</a>
316
+ </div>
317
+ <p class="footer">
318
+ Edit <code>app/controllers/home_controller.rb</code> to customize this page
319
+ </p>
320
+ </div>
321
+ </body>
322
+ </html>
323
+ HTML
324
+ end
325
+ RUBY
326
+ end
327
+
328
+ def spec_helper_content
329
+ <<~RUBY
330
+ # frozen_string_literal: true
331
+
332
+ require_relative "../config/application"
333
+ require "rack/test"
334
+
335
+ RSpec.configure do |config|
336
+ config.include Rack::Test::Methods
337
+
338
+ def app
339
+ Tarsier.app
340
+ end
341
+ end
342
+ RUBY
343
+ end
344
+
345
+ def rspec_content
346
+ <<~TEXT
347
+ --require spec_helper
348
+ --format documentation
349
+ --color
350
+ TEXT
351
+ end
352
+
353
+ def gitignore_content
354
+ <<~TEXT
355
+ # Dependencies
356
+ /.bundle/
357
+ /vendor/bundle/
358
+
359
+ # Environment
360
+ .env
361
+ .env.local
362
+ .env.development.local
363
+ .env.test.local
364
+ .env.production.local
365
+
366
+ # Logs
367
+ /log/*.log
368
+ /tmp/
369
+
370
+ # Database
371
+ *.sqlite3
372
+ *.sqlite3-journal
373
+ /db/*.sqlite3
374
+ /db/*.sqlite3-*
375
+
376
+ # IDE and editors
377
+ /.idea/
378
+ /.vscode/
379
+ *.swp
380
+ *.swo
381
+ *~
382
+ .project
383
+ .settings/
384
+
385
+ # OS files
386
+ .DS_Store
387
+ .DS_Store?
388
+ ._*
389
+ Thumbs.db
390
+ ehthumbs.db
391
+
392
+ # Test and coverage
393
+ /coverage/
394
+ /spec/examples.txt
395
+
396
+ # Build artifacts
397
+ *.gem
398
+ /pkg/
399
+
400
+ # Temporary files
401
+ *.tmp
402
+ *.bak
403
+ *.pid
404
+
405
+ # Secrets
406
+ /config/secrets.yml
407
+ /config/master.key
408
+ /config/credentials.yml.enc
409
+ TEXT
410
+ end
411
+
412
+ def readme_content
413
+ <<~MARKDOWN
414
+ # #{camelize(name)}
415
+
416
+ A Tarsier web application.
417
+
418
+ ## Setup
419
+
420
+ ```bash
421
+ bundle install
422
+ ```
423
+
424
+ ## Development
425
+
426
+ ```bash
427
+ bin/server
428
+ # or
429
+ bundle exec puma
430
+ # or
431
+ bundle exec rackup
432
+ ```
433
+
434
+ ## Console
435
+
436
+ ```bash
437
+ bin/console
438
+ ```
439
+
440
+ ## Testing
441
+
442
+ ```bash
443
+ bundle exec rspec
444
+ ```
445
+ MARKDOWN
446
+ end
447
+
448
+ def server_script
449
+ <<~BASH
450
+ #!/usr/bin/env ruby
451
+ # frozen_string_literal: true
452
+
453
+ require "bundler/setup"
454
+
455
+ port = ENV.fetch("PORT", 7827)
456
+ host = ENV.fetch("HOST", "0.0.0.0")
457
+ env = ENV.fetch("TARSIER_ENV", "development")
458
+
459
+ puts "Tarsier Development Server"
460
+ puts "=" * 40
461
+ puts "Environment: \#{env}"
462
+ puts "Listening on: http://\#{host}:\#{port}"
463
+ puts "Press Ctrl+C to stop"
464
+ puts "=" * 40
465
+ puts
466
+
467
+ if system("which puma > /dev/null 2>&1")
468
+ exec "bundle", "exec", "puma", "-p", port.to_s, "-b", "tcp://\#{host}:\#{port}"
469
+ else
470
+ exec "bundle", "exec", "rackup", "-p", port.to_s, "-o", host
471
+ end
472
+ BASH
473
+ end
474
+
475
+ def console_script
476
+ <<~BASH
477
+ #!/usr/bin/env ruby
478
+ # frozen_string_literal: true
479
+
480
+ require "bundler/setup"
481
+ require_relative "../config/application"
482
+
483
+ puts "Tarsier Console (\#{ENV.fetch('TARSIER_ENV', 'development')})"
484
+ puts "Type 'exit' to quit"
485
+ puts
486
+
487
+ require "irb"
488
+ IRB.start
489
+ BASH
490
+ end
491
+
492
+ def initialize_git
493
+ Dir.chdir(@app_path) do
494
+ system("git init -q")
495
+ puts " \e[32mcreate\e[0m .git"
496
+ end
497
+ rescue StandardError
498
+ puts " \e[33mskip\e[0m git init (git not available)"
499
+ end
500
+
501
+ def run_bundle
502
+ puts "\nInstalling dependencies..."
503
+ Dir.chdir(@app_path) do
504
+ system("bundle install")
505
+ end
506
+ rescue StandardError
507
+ puts " \e[33mskip\e[0m bundle install"
508
+ end
509
+
510
+ def print_success_message
511
+ puts
512
+ puts "=" * 50
513
+ puts "Success! Created #{name}"
514
+ puts "=" * 50
515
+ puts
516
+ puts "Next steps:"
517
+ puts " cd #{name}"
518
+ puts " bundle install"
519
+ puts " bin/server"
520
+ puts
521
+ puts "Then open http://localhost:7827"
522
+ puts
523
+ end
524
+ end
525
+ end
526
+ end
527
+ end
528
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tarsier
4
+ class CLI
5
+ module Generators
6
+ class Base
7
+ attr_reader :name, :options
8
+
9
+ def initialize(name, options = {})
10
+ @name = name
11
+ @options = options
12
+ end
13
+
14
+ def generate
15
+ raise NotImplementedError
16
+ end
17
+
18
+ protected
19
+
20
+ def create_file(path, content)
21
+ dir = File.dirname(path)
22
+ FileUtils.mkdir_p(dir) unless File.directory?(dir)
23
+
24
+ if File.exist?(path)
25
+ puts " \e[33mskip\e[0m #{path} (already exists)"
26
+ return false
27
+ end
28
+
29
+ File.write(path, content)
30
+ puts " \e[32mcreate\e[0m #{path}"
31
+ true
32
+ end
33
+
34
+ def create_directory(path)
35
+ if File.directory?(path)
36
+ puts " \e[33mskip\e[0m #{path}/ (already exists)"
37
+ return false
38
+ end
39
+
40
+ FileUtils.mkdir_p(path)
41
+ puts " \e[32mcreate\e[0m #{path}/"
42
+ true
43
+ end
44
+
45
+ def append_to_file(path, content)
46
+ unless File.exist?(path)
47
+ puts " \e[31merror\e[0m #{path} does not exist"
48
+ return false
49
+ end
50
+
51
+ File.open(path, "a") { |f| f.puts content }
52
+ puts " \e[32mappend\e[0m #{path}"
53
+ true
54
+ end
55
+
56
+ def camelize(string)
57
+ string.to_s.split(/[_-]/).map(&:capitalize).join
58
+ end
59
+
60
+ def underscore(string)
61
+ string.to_s.gsub(/([A-Z])/, '_\1').downcase.sub(/^_/, "")
62
+ end
63
+
64
+ def pluralize(string)
65
+ word = string.to_s
66
+ # Don't pluralize if already plural (simple check)
67
+ return word if word.end_with?("s") && !word.end_with?("ss")
68
+
69
+ if word.end_with?("y") && !%w[a e i o u].include?(word[-2])
70
+ word[0..-2] + "ies"
71
+ elsif word.end_with?("s", "x", "ch", "sh")
72
+ word + "es"
73
+ else
74
+ word + "s"
75
+ end
76
+ end
77
+
78
+ def singularize(string)
79
+ word = string.to_s
80
+ if word.end_with?("ies")
81
+ word[0..-4] + "y"
82
+ elsif word.end_with?("es")
83
+ word[0..-3]
84
+ elsif word.end_with?("s")
85
+ word[0..-2]
86
+ else
87
+ word
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tarsier
4
+ class CLI
5
+ module Generators
6
+ class Controller < Base
7
+ def initialize(name, actions = [])
8
+ super(name)
9
+ @actions = actions
10
+ end
11
+
12
+ def generate
13
+ create_file(controller_path, controller_content)
14
+ create_file(spec_path, spec_content)
15
+ puts "\nController generated successfully!"
16
+ puts "Add routes to config/routes.rb:"
17
+ puts route_suggestion
18
+ end
19
+
20
+ private
21
+
22
+ def controller_path
23
+ "app/controllers/#{underscore(name)}_controller.rb"
24
+ end
25
+
26
+ def spec_path
27
+ "spec/controllers/#{underscore(name)}_controller_spec.rb"
28
+ end
29
+
30
+ def controller_content
31
+ <<~RUBY
32
+ # frozen_string_literal: true
33
+
34
+ class #{camelize(name)}Controller < Tarsier::Controller
35
+ #{action_methods}
36
+ end
37
+ RUBY
38
+ end
39
+
40
+ def action_methods
41
+ return " # Add your actions here" if @actions.empty?
42
+
43
+ @actions.map do |action|
44
+ <<~RUBY.chomp
45
+ def #{action}
46
+ render json: { action: "#{action}" }
47
+ end
48
+ RUBY
49
+ end.join("\n\n").gsub(/^/, " ")
50
+ end
51
+
52
+ def spec_content
53
+ <<~RUBY
54
+ # frozen_string_literal: true
55
+
56
+ require "spec_helper"
57
+
58
+ RSpec.describe #{camelize(name)}Controller do
59
+ #{action_specs}
60
+ end
61
+ RUBY
62
+ end
63
+
64
+ def action_specs
65
+ return ' pending "add some examples"' if @actions.empty?
66
+
67
+ @actions.map do |action|
68
+ <<~RUBY.chomp
69
+ describe "##{action}" do
70
+ it "returns success" do
71
+ # Add your test here
72
+ end
73
+ end
74
+ RUBY
75
+ end.join("\n\n").gsub(/^/, " ")
76
+ end
77
+
78
+ def route_suggestion
79
+ resource_name = pluralize(underscore(name))
80
+ if @actions.empty?
81
+ " resources :#{resource_name}"
82
+ else
83
+ @actions.map do |action|
84
+ " get '/#{resource_name}/#{action}', to: '#{underscore(name)}##{action}'"
85
+ end.join("\n")
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end