rage-rb 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,249 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ class Rage::Router::Backend
6
+ OPTIONAL_PARAM_REGEXP = /\/?\(\/?(:\w+)\/?\)/
7
+ STRING_HANDLER_REGEXP = /^([a-z0-9_\/]+)#([a-z_]+)$/
8
+
9
+ def initialize
10
+ @routes = []
11
+ @trees = {}
12
+ @constrainer = Rage::Router::Constrainer.new({})
13
+ end
14
+
15
+ def on(method, path, handler, constraints: {})
16
+ raise "Path could not be empty" if path&.empty?
17
+
18
+ if match_index = (path =~ OPTIONAL_PARAM_REGEXP)
19
+ raise "Optional Parameter has to be the last parameter of the path" if path.length != match_index + $&.length
20
+
21
+ path_full = path.sub(OPTIONAL_PARAM_REGEXP, "/#{$1}")
22
+ path_optional = path.sub(OPTIONAL_PARAM_REGEXP, "")
23
+
24
+ on(method, path_full, handler)
25
+ on(method, path_optional, handler)
26
+ return
27
+ end
28
+
29
+ if handler.is_a?(String)
30
+ raise "Invalid route handler format, expected to match the 'controller#action' pattern" unless handler =~ STRING_HANDLER_REGEXP
31
+
32
+ controller, action = to_controller_class($1), $2
33
+ run_action_method_name = controller.__register_action(action.to_sym)
34
+
35
+ handler = eval("->(env, params) { #{controller}.new(env, params).#{run_action_method_name} }")
36
+ else
37
+ raise "Non-string route handler should respond to `call`" unless handler.respond_to?(:call)
38
+ # while regular handlers are expected to be called with the `env` and `params` objects,
39
+ # lambda handlers expect just `env` as an argument;
40
+ # TODO: come up with something nicer?
41
+ orig_handler = handler
42
+ handler = ->(env, _params) { orig_handler.call(env) }
43
+ end
44
+
45
+ __on(method, path, handler, constraints)
46
+ end
47
+
48
+ def lookup(env)
49
+ constraints = @constrainer.derive_constraints(env)
50
+ find(env, constraints)
51
+ end
52
+
53
+ private
54
+
55
+ def __on(method, path, handler, constraints)
56
+ @constrainer.validate_constraints(constraints)
57
+ # Let the constrainer know if any constraints are being used now
58
+ @constrainer.note_usage(constraints)
59
+
60
+ # Boot the tree for this method if it doesn't exist yet
61
+ @trees[method] ||= Rage::Router::StaticNode.new("/")
62
+
63
+ pattern = path
64
+ if pattern == "*" && !@trees[method].prefix.empty?
65
+ current_root = @trees[method]
66
+ @trees[method] = Rage::Router::StaticNode.new("")
67
+ @trees[method].static_children["/"] = current_root
68
+ end
69
+
70
+ current_node = @trees[method]
71
+ parent_node_path_index = current_node.prefix.length
72
+
73
+ i, params = 0, []
74
+ while i <= pattern.length
75
+ if pattern[i] == ":" && pattern[i + 1] == ":"
76
+ # It's a double colon
77
+ i += 2
78
+ next
79
+ end
80
+
81
+ is_parametric_node = pattern[i] == ":" && pattern[i + 1] != ":"
82
+ is_wildcard_node = pattern[i] == "*"
83
+
84
+ if is_parametric_node || is_wildcard_node || (i == pattern.length && i != parent_node_path_index)
85
+ static_node_path = pattern[parent_node_path_index, i - parent_node_path_index]
86
+ static_node_path = static_node_path.split("::").join(":")
87
+ static_node_path = static_node_path.split("%").join("%25")
88
+ # add the static part of the route to the tree
89
+ current_node = current_node.create_static_child(static_node_path)
90
+ end
91
+
92
+ if is_parametric_node
93
+ last_param_start_index = i + 1
94
+
95
+ j = last_param_start_index
96
+ while true
97
+ char = pattern[j]
98
+ is_end_of_node = (char == "/" || j == pattern.length)
99
+
100
+ if is_end_of_node
101
+ param_name = pattern[last_param_start_index, j - last_param_start_index]
102
+ params << param_name
103
+
104
+ static_part_start_index = j
105
+ while j < pattern.length
106
+ j_char = pattern[j]
107
+ break if j_char == "/"
108
+ if j_char == ":"
109
+ next_char = pattern[j + 1]
110
+ next_char == ":" ? j += 1 : break
111
+ end
112
+ j += 1
113
+ end
114
+
115
+ static_part = pattern[static_part_start_index, j - static_part_start_index]
116
+ unless static_part.empty?
117
+ static_part = static_part.split("::").join(":")
118
+ static_part = static_part.split("%").join("%25")
119
+ end
120
+
121
+ last_param_start_index = j + 1
122
+
123
+ if is_end_of_node || pattern[j] == "/" || j == pattern.length
124
+ node_path = pattern[i, j - i]
125
+
126
+ pattern = "#{pattern[0, i + 1]}#{static_part}#{pattern[j, pattern.length - j]}"
127
+ i += static_part.length
128
+
129
+ current_node = current_node.create_parametric_child(static_part == "" ? nil : static_part, node_path)
130
+ parent_node_path_index = i + 1
131
+ break
132
+ end
133
+ end
134
+
135
+ j += 1
136
+ end
137
+ elsif is_wildcard_node
138
+ # add the wildcard parameter
139
+ params << "*"
140
+ current_node = current_node.create_wildcard_child
141
+ parent_node_path_index = i + 1
142
+ raise "Wildcard must be the last character in the route" if i != pattern.length - 1
143
+ end
144
+
145
+ i += 1
146
+ end
147
+
148
+ if pattern == "*"
149
+ pattern = "/*"
150
+ end
151
+
152
+ @routes.each do |existing_route|
153
+ if (
154
+ existing_route[:method] == method &&
155
+ existing_route[:pattern] == pattern &&
156
+ existing_route[:constraints] == constraints
157
+ )
158
+ raise "Method '#{method}' already declared for route '#{pattern}' with constraints '#{constraints.inspect}'"
159
+ end
160
+ end
161
+
162
+ route = { method: method, path: path, pattern: pattern, params: params, constraints: constraints, handler: handler }
163
+ @routes << route
164
+ current_node.add_route(route, @constrainer)
165
+ end
166
+
167
+ def find(env, derived_constraints)
168
+ method, path = env["REQUEST_METHOD"], env["PATH_INFO"]
169
+
170
+ current_node = @trees[method]
171
+ return nil unless current_node
172
+
173
+ origin_path = path
174
+
175
+ path_index = current_node.prefix.length
176
+ url_params = []
177
+ path_len = path.length
178
+
179
+ brothers_nodes_stack = []
180
+
181
+ while true
182
+ if path_index == path_len && current_node.is_leaf_node
183
+ handle = current_node.handler_storage.get_matching_handler(derived_constraints)
184
+ if handle
185
+ return {
186
+ handler: handle[:handler],
187
+ params: handle[:create_params_object].call(url_params)
188
+ }
189
+ end
190
+ end
191
+
192
+ node = current_node.get_next_node(path, path_index, brothers_nodes_stack, url_params.length)
193
+
194
+ unless node
195
+ return if brothers_nodes_stack.length == 0
196
+
197
+ brother_node_state = brothers_nodes_stack.pop
198
+ path_index = brother_node_state[:brother_path_index]
199
+ url_params.slice!(brother_node_state[:params_count], url_params.length)
200
+ node = brother_node_state[:brother_node]
201
+ end
202
+
203
+ current_node = node
204
+
205
+ if current_node.kind == Rage::Router::Node::STATIC
206
+ path_index += current_node.prefix.length
207
+ next
208
+ end
209
+
210
+ if current_node.kind == Rage::Router::Node::WILDCARD
211
+ param = origin_path[path_index, origin_path.length - path_index]
212
+ param = Rack::Utils.unescape(param) if param.include?("%")
213
+
214
+ url_params << param
215
+ path_index = path_len
216
+ next
217
+ end
218
+
219
+ if current_node.kind == Rage::Router::Node::PARAMETRIC
220
+ param_end_index = origin_path.index("/", path_index)
221
+ param_end_index = path_len unless param_end_index
222
+
223
+ param = origin_path.slice(path_index, param_end_index - path_index)
224
+ param = Rack::Utils.unescape(param) if param.include?("%")
225
+
226
+ url_params << param
227
+ path_index = param_end_index
228
+ end
229
+ end
230
+ end
231
+
232
+ def to_controller_class(str)
233
+ str.capitalize!
234
+ str.gsub!(/([\/_])([a-zA-Z0-9]+)/) do
235
+ if $1 == "/"
236
+ "::#{$2.capitalize}"
237
+ else
238
+ $2.capitalize
239
+ end
240
+ end
241
+
242
+ klass = "#{str}Controller"
243
+ if Object.const_defined?(klass)
244
+ Object.const_get(klass)
245
+ else
246
+ raise "Routing error: could not find the #{klass} class"
247
+ end
248
+ end
249
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ class Rage::Router::Constrainer
6
+ attr_reader :strategies
7
+
8
+ def initialize(custom_strategies)
9
+ @strategies = {
10
+ host: Rage::Router::Strategies::Host.new
11
+ }
12
+
13
+ @strategies_in_use = Set.new
14
+ end
15
+
16
+ def strategy_used?(strategy_name)
17
+ @strategies_in_use.include?(strategy_name)
18
+ end
19
+
20
+ def has_constraint_strategy(strategy_name)
21
+ custom_constraint_strategy = @strategies[strategy_name]
22
+ if custom_constraint_strategy
23
+ return custom_constraint_strategy.custom? || strategy_used?(strategy_name)
24
+ end
25
+
26
+ false
27
+ end
28
+
29
+ def derive_constraints(env)
30
+ end
31
+
32
+ # When new constraints start getting used, we need to rebuild the deriver to derive them. Do so if we see novel constraints used.
33
+ def note_usage(constraints)
34
+ if constraints
35
+ before_size = @strategies_in_use.size
36
+
37
+ constraints.each_key do |key|
38
+ @strategies_in_use.add(key)
39
+ end
40
+
41
+ if before_size != @strategies_in_use.size
42
+ __build_derive_constraints
43
+ end
44
+ end
45
+ end
46
+
47
+ def new_store_for_constraint(constraint)
48
+ raise "No strategy registered for constraint key '#{constraint}'" unless @strategies[constraint]
49
+ @strategies[constraint].storage
50
+ end
51
+
52
+ def validate_constraints(constraints)
53
+ constraints.each do |key, value|
54
+ strategy = @strategies[key]
55
+ raise "No strategy registered for constraint key '#{key}'" unless strategy
56
+
57
+ strategy.validate(value)
58
+ end
59
+ end
60
+
61
+ # Optimization: build a fast function for deriving the constraints for all the strategies at once. We inline the definitions of the version constraint and the host constraint for performance.
62
+ # If no constraining strategies are in use (no routes constrain on host, or version, or any custom strategies) then we don't need to derive constraints for each route match, so don't do anything special, and just return undefined
63
+ # This allows us to not allocate an object to hold constraint values if no constraints are defined.
64
+ def __build_derive_constraints
65
+ return if @strategies_in_use.empty?
66
+
67
+ lines = ["{"]
68
+
69
+ @strategies_in_use.each do |key|
70
+ strategy = @strategies[key]
71
+ # Optimization: inline the derivation for the common built in constraints
72
+ if !strategy.custom?
73
+ if key == :host
74
+ lines << " host: env['HTTP_HOST'.freeze],"
75
+ else
76
+ raise 'unknown non-custom strategy for compiling constraint derivation function'
77
+ end
78
+ else
79
+ lines << " #{strategy.name}: @strategies[#{key}].derive_constraint(env),"
80
+ end
81
+ end
82
+
83
+ lines << "}"
84
+
85
+ instance_eval <<-RUBY
86
+ def derive_constraints(env)
87
+ #{lines.join("\n")}
88
+ end
89
+ RUBY
90
+ end
91
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Rage::Router::DSL
4
+ def initialize(router)
5
+ @router = router
6
+ end
7
+
8
+ def draw(&block)
9
+ Handler.new(@router).instance_eval(&block)
10
+ end
11
+
12
+ class Handler
13
+ def initialize(router)
14
+ @router = router
15
+
16
+ @path_prefixes = []
17
+ @module_prefixes = []
18
+ end
19
+
20
+ # Register a new GET route.
21
+ #
22
+ # @param path [String] the path for the route handler
23
+ # @param to [String] the route handler in the format of "controller#action"
24
+ # @param constraints [Hash] a hash of constraints for the route
25
+ # @example
26
+ # get "/photos/:id", to: "photos#show", constraints: { host: /myhost/ }
27
+ def get(path, to:, constraints: nil)
28
+ __on("GET", path, to, constraints)
29
+ end
30
+
31
+ # Register a new POST route.
32
+ #
33
+ # @param path [String] the path for the route handler
34
+ # @param to [String] the route handler in the format of "controller#action"
35
+ # @param constraints [Hash] a hash of constraints for the route
36
+ # @example
37
+ # post "/photos", to: "photos#create", constraints: { host: /myhost/ }
38
+ def post(path, to:, constraints: nil)
39
+ __on("POST", path, to, constraints)
40
+ end
41
+
42
+ # Register a new PUT route.
43
+ #
44
+ # @param path [String] the path for the route handler
45
+ # @param to [String] the route handler in the format of "controller#action"
46
+ # @param constraints [Hash] a hash of constraints for the route
47
+ # @example
48
+ # put "/photos/:id", to: "photos#update", constraints: { host: /myhost/ }
49
+ def put(path, to:, constraints: nil)
50
+ __on("PUT", path, to, constraints)
51
+ end
52
+
53
+ # Register a new PATCH route.
54
+ #
55
+ # @param path [String] the path for the route handler
56
+ # @param to [String] the route handler in the format of "controller#action"
57
+ # @param constraints [Hash] a hash of constraints for the route
58
+ # @example
59
+ # patch "/photos/:id", to: "photos#update", constraints: { host: /myhost/ }
60
+ def patch(path, to:, constraints: nil)
61
+ __on("PATCH", path, to, constraints)
62
+ end
63
+
64
+ # Register a new DELETE route.
65
+ #
66
+ # @param path [String] the path for the route handler
67
+ # @param to [String] the route handler in the format of "controller#action"
68
+ # @param constraints [Hash] a hash of constraints for the route
69
+ # @example
70
+ # delete "/photos/:id", to: "photos#destroy", constraints: { host: /myhost/ }
71
+ def delete(path, to:, constraints: nil)
72
+ __on("DELETE", path, to, constraints)
73
+ end
74
+
75
+ # Register a new route pointing to '/'.
76
+ #
77
+ # @param to [String] the route handler in the format of "controller#action"
78
+ # @example
79
+ # root to: "photos#index"
80
+ def root(to:)
81
+ __on("GET", "/", to, nil)
82
+ end
83
+
84
+ # Scopes a set of routes to the given default options.
85
+ #
86
+ # @param [Hash] opts scope options.
87
+ # @option opts [String] :module module option
88
+ # @option opts [String] :path path option
89
+ # @example Route `/photos` to `Api::PhotosController`
90
+ # scope module: "api" do
91
+ # get "photos", to: "photos#index"
92
+ # end
93
+ # @example Route `admin/photos` to `PhotosController`
94
+ # scope path: "admin" do
95
+ # get "photos", to: "photos#index"
96
+ # end
97
+ # @example Nested calls
98
+ # scope module: "admin" do
99
+ # get "photos", to: "photos#index"
100
+ #
101
+ # scope path: "api", module: "api" do
102
+ # get "photos/:id", to: "photos#show"
103
+ # end
104
+ # end
105
+ def scope(opts, &block)
106
+ raise ArgumentError, "only 'module' and 'path' options are accepted" if (opts.keys - %i(module path)).any?
107
+
108
+ @path_prefixes << opts[:path].delete_prefix("/").delete_suffix("/") if opts[:path]
109
+ @module_prefixes << opts[:module] if opts[:module]
110
+
111
+ instance_eval &block
112
+
113
+ @path_prefixes.pop if opts[:path]
114
+ @module_prefixes.pop if opts[:module]
115
+ end
116
+
117
+ private
118
+
119
+ def __on(method, path, to, constraints)
120
+ if path != "/"
121
+ path = "/#{path}" unless path.start_with?("/")
122
+ path = path.delete_suffix("/") if path.end_with?("/")
123
+ end
124
+
125
+ path_prefix = @path_prefixes.any? ? "/#{@path_prefixes.join("/")}" : nil
126
+ module_prefix = @module_prefixes.any? ? "#{@module_prefixes.join("/")}/" : nil
127
+
128
+ if to.is_a?(String)
129
+ @router.on(method, "#{path_prefix}#{path}", "#{module_prefix}#{to}", constraints: constraints || {})
130
+ else
131
+ @router.on(method, "#{path_prefix}#{path}", to, constraints: constraints || {})
132
+ end
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Rage::Router::HandlerStorage
4
+ def initialize
5
+ @unconstrained_handler = nil # optimized reference to the handler that will match most of the time
6
+ @constraints = []
7
+ @handlers = [] # unoptimized list of handler objects for which the fast matcher function will be compiled
8
+ @constrained_handler_stores = nil
9
+ end
10
+
11
+ # This is the hot path for node handler finding -- change with care!
12
+ def get_matching_handler(derived_constraints)
13
+ return @unconstrained_handler unless derived_constraints
14
+ get_handler_matching_constraints(derived_constraints)
15
+ end
16
+
17
+ def add_handler(constrainer, route)
18
+ params = route[:params]
19
+ constraints = route[:constraints]
20
+
21
+ handler_object = {
22
+ params: params,
23
+ constraints: constraints,
24
+ handler: route[:handler],
25
+ create_params_object: compile_create_params_object(params)
26
+ }
27
+
28
+ constraints_keys = constraints.keys
29
+ if constraints_keys.empty?
30
+ @unconstrained_handler = handler_object
31
+ end
32
+
33
+ constraints_keys.each do |constraint_key|
34
+ @constraints << constraint_key unless @constraints.include?(constraint_key)
35
+ end
36
+
37
+ if @handlers.length >= 32
38
+ raise "Limit reached: a maximum of 32 route handlers per node allowed when there are constraints"
39
+ end
40
+
41
+ @handlers << handler_object
42
+ # Sort the most constrained handlers to the front of the list of handlers so they are tested first.
43
+ @handlers.sort_by! { |a| a[:constraints].length }
44
+
45
+ compile_get_handler_matching_constraints(constrainer)
46
+ end
47
+
48
+ private
49
+
50
+ def compile_create_params_object(param_keys)
51
+ lines = []
52
+
53
+ param_keys.each_with_index do |key, i|
54
+ lines << "'#{key}' => param_values[#{i}]"
55
+ end
56
+
57
+ eval "->(param_values) { { #{lines.join(',')} } }"
58
+ end
59
+
60
+ def get_handler_matching_constraints(_derived_constraints)
61
+ end
62
+
63
+ # Builds a store object that maps from constraint values to a bitmap of handler indexes which pass the constraint for a value
64
+ # So for a host constraint, this might look like { "fastify.io": 0b0010, "google.ca": 0b0101 }, meaning the 3rd handler is constrainted to fastify.io, and the 2nd and 4th handlers are constrained to google.ca.
65
+ # The store's implementation comes from the strategies provided to the Router.
66
+ def build_constraint_store(store, constraint)
67
+ @handlers.each_with_index do |handler, i|
68
+ constraint_value = handler[:constraints][constraint]
69
+ if constraint_value
70
+ indexes = store.get(constraint_value) || 0
71
+ indexes |= 1 << i # set the i-th bit for the mask because this handler is constrained by this value https://stackoverflow.com/questions/1436438/how-do-you-set-clear-and-toggle-a-single-bit-in-javascrip
72
+ store.set(constraint_value, indexes)
73
+ end
74
+ end
75
+ end
76
+
77
+ # Builds a bitmask for a given constraint that has a bit for each handler index that is 0 when that handler *is* constrained and 1 when the handler *isnt* constrainted. This is opposite to what might be obvious, but is just for convienience when doing the bitwise operations.
78
+ def constrained_index_bitmask(constraint)
79
+ mask = 0
80
+
81
+ @handlers.each_with_index do |handler, i|
82
+ constraint_value = handler[:constraints][constraint]
83
+ mask |= 1 << i if constraint_value
84
+ end
85
+
86
+ ~mask
87
+ end
88
+
89
+ # Compile a fast function to match the handlers for this node
90
+ # The function implements a general case multi-constraint matching algorithm.
91
+ # The general idea is this: we have a bunch of handlers, each with a potentially different set of constraints, and sometimes none at all. We're given a list of constraint values and we have to use the constraint-value-comparison strategies to see which handlers match the constraint values passed in.
92
+ # We do this by asking each constraint store which handler indexes match the given constraint value for each store. Trickily, the handlers that a store says match are the handlers constrained by that store, but handlers that aren't constrained at all by that store could still match just fine. So, each constraint store can only describe matches for it, and it won't have any bearing on the handlers it doesn't care about. For this reason, we have to ask each stores which handlers match and track which have been matched (or not cared about) by all of them.
93
+ # We use bitmaps to represent these lists of matches so we can use bitwise operations to implement this efficiently. Bitmaps are cheap to allocate, let us implement this masking behaviour in one CPU instruction, and are quite compact in memory. We start with a bitmap set to all 1s representing every handler that is a match candidate, and then for each constraint, see which handlers match using the store, and then mask the result by the mask of handlers that that store applies to, and bitwise AND with the candidate list. Phew.
94
+ # We consider all this compiling function complexity to be worth it, because the naive implementation that just loops over the handlers asking which stores match is quite a bit slower.
95
+ def compile_get_handler_matching_constraints(constrainer)
96
+ @constrained_handler_stores = {}
97
+
98
+ @constraints.each do |constraint|
99
+ store = constrainer.new_store_for_constraint(constraint)
100
+ @constrained_handler_stores[constraint] = store
101
+
102
+ build_constraint_store(store, constraint)
103
+ end
104
+
105
+ lines = []
106
+ lines << <<-RUBY
107
+ candidates = #{(1 << @handlers.length) - 1}
108
+ mask, matches = nil
109
+ RUBY
110
+
111
+ @constraints.each do |constraint|
112
+ # Setup the mask for indexes this constraint applies to. The mask bits are set to 1 for each position if the constraint applies.
113
+ lines << <<-RUBY
114
+ mask = #{constrained_index_bitmask(constraint)}
115
+ value = derived_constraints[:#{constraint}]
116
+ RUBY
117
+
118
+ # If there's no constraint value, none of the handlers constrained by this constraint can match. Remove them from the candidates.
119
+ # If there is a constraint value, get the matching indexes bitmap from the store, and mask it down to only the indexes this constraint applies to, and then bitwise and with the candidates list to leave only matching candidates left.
120
+ strategy = constrainer.strategies[constraint]
121
+ match_mask = strategy.must_match_when_derived ? "matches" : "(matches | mask)"
122
+
123
+ lines.push << <<-RUBY
124
+ if !value
125
+ candidates &= mask
126
+ else
127
+ matches = @constrained_handler_stores[:#{constraint}].get(value) || 0
128
+ candidates &= #{match_mask}
129
+ end
130
+
131
+ return nil if candidates == 0
132
+ RUBY
133
+ end
134
+
135
+ # There are some constraints that can be derived and marked as "must match", where if they are derived, they only match routes that actually have a constraint on the value, like the SemVer version constraint.
136
+ # An example: a request comes in for version 1.x, and this node has a handler that matches the path, but there's no version constraint. For SemVer, the find-my-way semantics do not match this handler to that request.
137
+ # This function is used by Nodes with handlers to match when they don't have any constrained routes to exclude request that do have must match derived constraints present.
138
+ constrainer.strategies.each do |constraint, strategy|
139
+ if strategy.must_match_when_derived && !@constraints.include?(constraint)
140
+ lines << "return nil if derived_constraints[:#{constraint}]"
141
+ end
142
+ end
143
+
144
+ # Return the first handler whose bit is set in the candidates https://stackoverflow.com/questions/18134985/how-to-find-index-of-first-set-bit
145
+ lines << "return @handlers[Math.log2(candidates).floor]"
146
+
147
+ instance_eval <<-RUBY
148
+ def get_handler_matching_constraints(derived_constraints)
149
+ #{lines.join("\n")}
150
+ end
151
+ RUBY
152
+ end
153
+ end