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,173 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tarsier
4
+ class Router
5
+ # Compiles routes into an optimized radix tree structure
6
+ # Routes are compiled at boot time for zero runtime overhead
7
+ class Compiler
8
+ attr_reader :trees
9
+
10
+ def initialize
11
+ # Separate tree per HTTP method for faster lookups
12
+ @trees = {}
13
+ @named_routes = {}
14
+ @compiled = false
15
+ end
16
+
17
+ # Add a route to the compiler
18
+ # @param route [Route] the route to add
19
+ def add(route)
20
+ raise InvalidRouteError, "Cannot add routes after compilation" if @compiled
21
+
22
+ tree = (@trees[route.method] ||= Node.new)
23
+ insert_route(tree, route, route.segments)
24
+
25
+ @named_routes[route.name] = route if route.name
26
+ end
27
+
28
+ # Compile all routes (call after all routes are added)
29
+ # Optimizes the tree structure for matching
30
+ def compile!
31
+ return if @compiled
32
+
33
+ @trees.each_value { |tree| optimize_tree(tree) }
34
+ @compiled = true
35
+ freeze_trees
36
+ end
37
+
38
+ # Check if routes have been compiled
39
+ # @return [Boolean]
40
+ def compiled?
41
+ @compiled
42
+ end
43
+
44
+ # Match a path against the compiled routes
45
+ # @param method [Symbol] HTTP method
46
+ # @param path [String] the path to match
47
+ # @return [Array<Route, Hash>, nil] route and params or nil
48
+ def match(method, path)
49
+ tree = @trees[method.to_s.upcase.to_sym]
50
+ return nil unless tree
51
+
52
+ segments = path == "/" ? [""] : path.split("/").drop(1)
53
+ match_segments(tree, segments, {})
54
+ end
55
+
56
+ # Get a named route
57
+ # @param name [Symbol] route name
58
+ # @return [Route, nil]
59
+ def named_route(name)
60
+ @named_routes[name]
61
+ end
62
+
63
+ # Generate a path for a named route
64
+ # @param name [Symbol] route name
65
+ # @param params [Hash] parameters
66
+ # @return [String]
67
+ def path_for(name, params = {})
68
+ route = @named_routes[name]
69
+ raise InvalidRouteError, "Unknown route: #{name}" unless route
70
+ route.generate(params)
71
+ end
72
+
73
+ # Get all routes
74
+ # @return [Array<Route>]
75
+ def routes
76
+ @trees.flat_map { |_method, tree| collect_routes(tree) }
77
+ end
78
+
79
+ private
80
+
81
+ def insert_route(node, route, segments, index = 0)
82
+ if index >= segments.length
83
+ raise InvalidRouteError, "Duplicate route: #{route.method} #{route.path}" if node.route
84
+ node.route = route
85
+ return
86
+ end
87
+
88
+ segment = segments[index]
89
+ child = find_or_create_child(node, segment)
90
+ insert_route(child, route, segments, index + 1)
91
+ end
92
+
93
+ def find_or_create_child(node, segment)
94
+ key = segment_key(segment)
95
+ existing = node.find_child(key)
96
+ return existing if existing
97
+
98
+ child = create_node(segment)
99
+ node.add_child(key, child)
100
+ node.invalidate_cache!
101
+ child
102
+ end
103
+
104
+ def segment_key(segment)
105
+ case segment[:type]
106
+ when :static then segment[:value]
107
+ when :param then ":#{segment[:name]}"
108
+ when :wildcard then "*#{segment[:name]}"
109
+ end
110
+ end
111
+
112
+ def create_node(segment)
113
+ case segment[:type]
114
+ when :static
115
+ Node.new(segment[:value], type: Node::STATIC)
116
+ when :param
117
+ Node.new("", type: Node::PARAM, param_name: segment[:name], constraint: segment[:constraint])
118
+ when :wildcard
119
+ Node.new("", type: Node::WILDCARD, param_name: segment[:name])
120
+ end
121
+ end
122
+
123
+ def match_segments(node, segments, params, index = 0)
124
+ # Terminal case: all segments consumed
125
+ if index >= segments.length
126
+ return [node.route, params] if node.terminal?
127
+ return nil
128
+ end
129
+
130
+ segment = segments[index]
131
+
132
+ # Try children in priority order (static > param > wildcard)
133
+ node.sorted_children.each do |child|
134
+ case child.type
135
+ when Node::STATIC
136
+ next unless child.segment == segment
137
+ result = match_segments(child, segments, params, index + 1)
138
+ return result if result
139
+ when Node::PARAM
140
+ captured = child.match?(segment)
141
+ next unless captured
142
+ result = match_segments(child, segments, params.merge(captured), index + 1)
143
+ return result if result
144
+ when Node::WILDCARD
145
+ # Wildcard captures rest of path
146
+ remaining = segments[index..].join("/")
147
+ new_params = params.merge(child.param_name => remaining)
148
+ return [child.route, new_params] if child.terminal?
149
+ end
150
+ end
151
+
152
+ nil
153
+ end
154
+
155
+ def optimize_tree(node)
156
+ # Sort children by priority and cache
157
+ node.sorted_children
158
+ node.children.each_value { |child| optimize_tree(child) }
159
+ end
160
+
161
+ def freeze_trees
162
+ @trees.freeze
163
+ @named_routes.freeze
164
+ end
165
+
166
+ def collect_routes(node, routes = [])
167
+ routes << node.route if node.route
168
+ node.children.each_value { |child| collect_routes(child, routes) }
169
+ routes
170
+ end
171
+ end
172
+ end
173
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tarsier
4
+ class Router
5
+ # Radix tree node for efficient route matching
6
+ # Supports static segments, parameters, wildcards, and constraints
7
+ class Node
8
+ # Node types for optimized matching
9
+ STATIC = :static
10
+ PARAM = :param
11
+ WILDCARD = :wildcard
12
+
13
+ attr_reader :segment, :type, :param_name, :constraint, :children
14
+ attr_accessor :route, :priority
15
+
16
+ # @param segment [String] the path segment this node represents
17
+ # @param type [Symbol] node type (:static, :param, :wildcard)
18
+ # @param param_name [String, nil] parameter name for dynamic segments
19
+ # @param constraint [Regexp, nil] constraint pattern for parameters
20
+ def initialize(segment = "", type: STATIC, param_name: nil, constraint: nil)
21
+ @segment = segment.freeze
22
+ @type = type
23
+ @param_name = param_name&.to_sym
24
+ @constraint = constraint
25
+ @children = {}
26
+ @route = nil
27
+ @priority = calculate_priority
28
+ end
29
+
30
+ # Add a child node
31
+ # @param key [String] the key for the child (segment or param marker)
32
+ # @param node [Node] the child node
33
+ # @return [Node] the added child node
34
+ def add_child(key, node)
35
+ @children[key] = node
36
+ end
37
+
38
+ # Find a child node by key
39
+ # @param key [String] the key to search for
40
+ # @return [Node, nil]
41
+ def find_child(key)
42
+ @children[key]
43
+ end
44
+
45
+ # Check if this node matches a segment
46
+ # @param segment [String] the segment to match
47
+ # @return [Boolean, Hash] false or hash with captured params
48
+ def match?(segment)
49
+ case @type
50
+ when STATIC
51
+ segment == @segment ? {} : false
52
+ when PARAM
53
+ match_param(segment)
54
+ when WILDCARD
55
+ { @param_name => segment }
56
+ end
57
+ end
58
+
59
+ # Check if this is a terminal node (has a route)
60
+ # @return [Boolean]
61
+ def terminal?
62
+ !@route.nil?
63
+ end
64
+
65
+ # Get sorted children for matching (static first, then params, then wildcards)
66
+ # @return [Array<Node>]
67
+ def sorted_children
68
+ @sorted_children ||= @children.values.sort_by(&:priority)
69
+ end
70
+
71
+ # Clear cached sorted children (call after adding children)
72
+ def invalidate_cache!
73
+ @sorted_children = nil
74
+ end
75
+
76
+ private
77
+
78
+ def match_param(segment)
79
+ return false if segment.nil? || segment.empty?
80
+
81
+ if @constraint
82
+ return false unless @constraint.match?(segment)
83
+ end
84
+
85
+ { @param_name => segment }
86
+ end
87
+
88
+ def calculate_priority
89
+ case @type
90
+ when STATIC then 0
91
+ when PARAM then 1
92
+ when WILDCARD then 2
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tarsier
4
+ class Router
5
+ # Represents a compiled route with handler and metadata
6
+ class Route
7
+ attr_reader :method, :path, :handler, :name, :constraints, :middleware, :options
8
+
9
+ # @param method [Symbol] HTTP method (:get, :post, etc.)
10
+ # @param path [String] the route path pattern
11
+ # @param handler [Object] the route handler (controller.action or proc)
12
+ # @param name [Symbol, nil] optional route name for URL generation
13
+ # @param constraints [Hash] parameter constraints
14
+ # @param middleware [Array] route-specific middleware
15
+ # @param options [Hash] additional route options
16
+ def initialize(method:, path:, handler:, name: nil, constraints: {}, middleware: [], **options)
17
+ @method = method.to_s.upcase.to_sym
18
+ @path = normalize_path(path)
19
+ @handler = handler
20
+ @name = name
21
+ @constraints = constraints
22
+ @middleware = middleware
23
+ @options = options
24
+ @segments = nil
25
+ @param_names = nil
26
+ end
27
+
28
+ # Parse path into segments for tree insertion
29
+ # @return [Array<Hash>] array of segment info hashes
30
+ def segments
31
+ @segments ||= parse_segments
32
+ end
33
+
34
+ # Get parameter names from the path
35
+ # @return [Array<Symbol>]
36
+ def param_names
37
+ @param_names ||= segments.select { |s| s[:type] != :static }.map { |s| s[:name] }
38
+ end
39
+
40
+ # Check if route has parameters
41
+ # @return [Boolean]
42
+ def dynamic?
43
+ segments.any? { |s| s[:type] != :static }
44
+ end
45
+
46
+ # Generate a path with given parameters
47
+ # @param params [Hash] parameters to interpolate
48
+ # @return [String] the generated path
49
+ def generate(params = {})
50
+ segments.map do |seg|
51
+ case seg[:type]
52
+ when :static
53
+ seg[:value]
54
+ when :param, :wildcard
55
+ value = params[seg[:name]] || params[seg[:name].to_s]
56
+ raise ArgumentError, "Missing required parameter: #{seg[:name]}" unless value
57
+ value.to_s
58
+ end
59
+ end.join("/").then { |p| "/#{p}" }
60
+ end
61
+
62
+ # Get the controller class if handler is controller-based
63
+ # @return [Class, nil]
64
+ def controller_class
65
+ return nil unless @handler.is_a?(Hash)
66
+ @handler[:controller]
67
+ end
68
+
69
+ # Get the action name if handler is controller-based
70
+ # @return [Symbol, nil]
71
+ def action_name
72
+ return nil unless @handler.is_a?(Hash)
73
+ @handler[:action]
74
+ end
75
+
76
+ # Check if handler is a proc/lambda
77
+ # @return [Boolean]
78
+ def proc_handler?
79
+ @handler.is_a?(Proc)
80
+ end
81
+
82
+ private
83
+
84
+ def normalize_path(path)
85
+ path = "/#{path}" unless path.start_with?("/")
86
+ path = path.chomp("/") unless path == "/"
87
+ path
88
+ end
89
+
90
+ def parse_segments
91
+ return [{ type: :static, value: "" }] if @path == "/"
92
+
93
+ @path.split("/").reject(&:empty?).map do |segment|
94
+ parse_segment(segment)
95
+ end
96
+ end
97
+
98
+ def parse_segment(segment)
99
+ case segment
100
+ when /\A:(\w+)(?:\{(.+)\})?\z/
101
+ # Parameter with optional constraint: :id or :id{\d+}
102
+ name = ::Regexp.last_match(1).to_sym
103
+ constraint = ::Regexp.last_match(2)
104
+ {
105
+ type: :param,
106
+ name: name,
107
+ constraint: constraint ? Regexp.new("\\A#{constraint}\\z") : @constraints[name]
108
+ }
109
+ when /\A\*(\w+)\z/
110
+ # Wildcard: *path
111
+ { type: :wildcard, name: ::Regexp.last_match(1).to_sym }
112
+ else
113
+ # Static segment
114
+ { type: :static, value: segment }
115
+ end
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,272 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tarsier
4
+ # High-performance router with compiled radix tree matching
5
+ # Supports RESTful resources, nested routes, and constraints
6
+ class Router
7
+ HTTP_METHODS = %i[get post put patch delete head options].freeze
8
+
9
+ attr_reader :compiler
10
+
11
+ def initialize
12
+ @compiler = Compiler.new
13
+ @scope_stack = []
14
+ end
15
+
16
+ # Define routes in a block
17
+ # @yield [self]
18
+ def draw(&block)
19
+ instance_eval(&block)
20
+ compile!
21
+ self
22
+ end
23
+
24
+ # Compile routes for optimized matching
25
+ def compile!
26
+ @compiler.compile!
27
+ end
28
+
29
+ # Match a request to a route
30
+ # @param method [Symbol, String] HTTP method
31
+ # @param path [String] request path
32
+ # @return [Array<Route, Hash>, nil] matched route and params
33
+ def match(method, path)
34
+ @compiler.match(method, path)
35
+ end
36
+
37
+ # Generate path for a named route
38
+ # @param name [Symbol] route name
39
+ # @param params [Hash] route parameters
40
+ # @return [String]
41
+ def path_for(name, params = {})
42
+ @compiler.path_for(name, params)
43
+ end
44
+
45
+ # Get all defined routes
46
+ # @return [Array<Route>]
47
+ def routes
48
+ @compiler.routes
49
+ end
50
+
51
+ # HTTP method helpers
52
+ HTTP_METHODS.each do |method|
53
+ define_method(method) do |path, to: nil, as: nil, constraints: {}, **options, &block|
54
+ add_route(method, path, to: to, as: as, constraints: constraints, **options, &block)
55
+ end
56
+ end
57
+
58
+ # Define RESTful resources
59
+ # @param name [Symbol] resource name
60
+ # @param only [Array<Symbol>] actions to include
61
+ # @param except [Array<Symbol>] actions to exclude
62
+ # @param controller [Symbol] controller name override
63
+ def resources(name, only: nil, except: nil, controller: nil, **options, &block)
64
+ controller_name = controller || name
65
+ actions = determine_actions(only, except)
66
+
67
+ resource_routes(name, controller_name, actions, **options)
68
+
69
+ if block_given?
70
+ scope(path: "#{name}/:#{singular(name)}_id") do
71
+ instance_eval(&block)
72
+ end
73
+ end
74
+ end
75
+
76
+ # Define a singular resource
77
+ # @param name [Symbol] resource name
78
+ def resource(name, only: nil, except: nil, controller: nil, **options, &block)
79
+ controller_name = controller || name.to_s.pluralize.to_sym
80
+ actions = determine_singular_actions(only, except)
81
+
82
+ singular_resource_routes(name, controller_name, actions, **options)
83
+
84
+ if block_given?
85
+ scope(path: name.to_s) do
86
+ instance_eval(&block)
87
+ end
88
+ end
89
+ end
90
+
91
+ # Create a route scope
92
+ # @param path [String] path prefix
93
+ # @param module_name [Symbol] controller module
94
+ # @param as [Symbol] name prefix
95
+ def scope(path: nil, module: nil, as: nil, **options, &block)
96
+ @scope_stack.push({
97
+ path: path,
98
+ module: binding.local_variable_get(:module),
99
+ as: as,
100
+ **options
101
+ })
102
+ instance_eval(&block)
103
+ @scope_stack.pop
104
+ end
105
+
106
+ # Namespace routes under a module
107
+ # @param name [Symbol] namespace name
108
+ def namespace(name, **options, &block)
109
+ scope(path: name.to_s, module: name, as: name, **options, &block)
110
+ end
111
+
112
+ # Mount a Rack application
113
+ # @param app [Object] Rack application
114
+ # @param at [String] mount path
115
+ def mount(app, at:)
116
+ path = "#{at.chomp('/')}/*path"
117
+ add_route(:get, path, to: app, mount: true)
118
+ add_route(:post, path, to: app, mount: true)
119
+ add_route(:put, path, to: app, mount: true)
120
+ add_route(:patch, path, to: app, mount: true)
121
+ add_route(:delete, path, to: app, mount: true)
122
+ end
123
+
124
+ # Root route helper
125
+ # @param to [Object] handler
126
+ def root(to:, as: :root)
127
+ get("/", to: to, as: as)
128
+ end
129
+
130
+ private
131
+
132
+ def add_route(method, path, to:, as: nil, constraints: {}, middleware: [], **options, &block)
133
+ full_path = build_path(path)
134
+ handler = resolve_handler(to, &block)
135
+ route_name = build_name(as)
136
+
137
+ route = Route.new(
138
+ method: method,
139
+ path: full_path,
140
+ handler: handler,
141
+ name: route_name,
142
+ constraints: constraints,
143
+ middleware: current_middleware + middleware,
144
+ **options
145
+ )
146
+
147
+ @compiler.add(route)
148
+ end
149
+
150
+ def build_path(path)
151
+ parts = @scope_stack.map { |s| s[:path] }.compact
152
+ parts << path
153
+ "/" + parts.join("/").gsub(%r{/+}, "/").gsub(%r{^/|/$}, "")
154
+ end
155
+
156
+ def build_name(name)
157
+ return nil unless name
158
+
159
+ parts = @scope_stack.map { |s| s[:as] }.compact
160
+ parts << name
161
+ parts.join("_").to_sym
162
+ end
163
+
164
+ def resolve_handler(to, &block)
165
+ return block if block_given?
166
+ return to if to.is_a?(Proc) || to.respond_to?(:call)
167
+
168
+ case to
169
+ when Hash
170
+ to
171
+ when String
172
+ # "controller#action" format
173
+ controller, action = to.split("#")
174
+ controller_class = resolve_controller(controller)
175
+ { controller: controller_class, action: action.to_sym }
176
+ else
177
+ raise InvalidRouteError, "Invalid handler: #{to.inspect}"
178
+ end
179
+ end
180
+
181
+ def resolve_controller(name)
182
+ parts = @scope_stack.map { |s| s[:module] }.compact
183
+ parts << name
184
+
185
+ class_name = parts.map { |p| camelize(p.to_s) }.join("::") + "Controller"
186
+
187
+ # Return the class name as a string for lazy loading
188
+ class_name
189
+ end
190
+
191
+ def current_middleware
192
+ @scope_stack.flat_map { |s| s[:middleware] || [] }
193
+ end
194
+
195
+ def determine_actions(only, except)
196
+ all = %i[index show new create edit update destroy]
197
+ actions = only ? (all & Array(only)) : all
198
+ except ? (actions - Array(except)) : actions
199
+ end
200
+
201
+ def determine_singular_actions(only, except)
202
+ all = %i[show new create edit update destroy]
203
+ actions = only ? (all & Array(only)) : all
204
+ except ? (actions - Array(except)) : actions
205
+ end
206
+
207
+ def resource_routes(name, controller, actions, **options)
208
+ path_name = name.to_s
209
+ singular_name = singular(name)
210
+
211
+ actions.each do |action|
212
+ case action
213
+ when :index
214
+ get(path_name, to: "#{controller}#index", as: name, **options)
215
+ when :show
216
+ get("#{path_name}/:id", to: "#{controller}#show", as: singular_name, **options)
217
+ when :new
218
+ get("#{path_name}/new", to: "#{controller}#new", as: :"new_#{singular_name}", **options)
219
+ when :create
220
+ post(path_name, to: "#{controller}#create", **options)
221
+ when :edit
222
+ get("#{path_name}/:id/edit", to: "#{controller}#edit", as: :"edit_#{singular_name}", **options)
223
+ when :update
224
+ put("#{path_name}/:id", to: "#{controller}#update", **options)
225
+ patch("#{path_name}/:id", to: "#{controller}#update", **options)
226
+ when :destroy
227
+ delete("#{path_name}/:id", to: "#{controller}#destroy", **options)
228
+ end
229
+ end
230
+ end
231
+
232
+ def singular_resource_routes(name, controller, actions, **options)
233
+ path_name = name.to_s
234
+
235
+ actions.each do |action|
236
+ case action
237
+ when :show
238
+ get(path_name, to: "#{controller}#show", as: name, **options)
239
+ when :new
240
+ get("#{path_name}/new", to: "#{controller}#new", as: :"new_#{name}", **options)
241
+ when :create
242
+ post(path_name, to: "#{controller}#create", **options)
243
+ when :edit
244
+ get("#{path_name}/edit", to: "#{controller}#edit", as: :"edit_#{name}", **options)
245
+ when :update
246
+ put(path_name, to: "#{controller}#update", **options)
247
+ patch(path_name, to: "#{controller}#update", **options)
248
+ when :destroy
249
+ delete(path_name, to: "#{controller}#destroy", **options)
250
+ end
251
+ end
252
+ end
253
+
254
+ def singular(name)
255
+ # Simple singularization - can be enhanced
256
+ word = name.to_s
257
+ if word.end_with?("ies")
258
+ word[0..-4] + "y"
259
+ elsif word.end_with?("es")
260
+ word[0..-3]
261
+ elsif word.end_with?("s")
262
+ word[0..-2]
263
+ else
264
+ word
265
+ end
266
+ end
267
+
268
+ def camelize(string)
269
+ string.split("_").map(&:capitalize).join
270
+ end
271
+ end
272
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tarsier
4
+ VERSION = "0.1.0"
5
+ end