rails_agent_console 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 (55) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +80 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +414 -0
  5. data/exe/rails-agent +7 -0
  6. data/lib/generators/rails_agent_console/install_generator.rb +17 -0
  7. data/lib/generators/rails_agent_console/templates/rails_agent_console.rb +21 -0
  8. data/lib/rails_agent_console/agent.rb +277 -0
  9. data/lib/rails_agent_console/cli.rb +114 -0
  10. data/lib/rails_agent_console/configuration.rb +170 -0
  11. data/lib/rails_agent_console/console_installer.rb +49 -0
  12. data/lib/rails_agent_console/console_methods.rb +54 -0
  13. data/lib/rails_agent_console/console_session.rb +41 -0
  14. data/lib/rails_agent_console/credentials.rb +66 -0
  15. data/lib/rails_agent_console/diagnosis/extra.rb +72 -0
  16. data/lib/rails_agent_console/diagnosis.rb +149 -0
  17. data/lib/rails_agent_console/errors.rb +20 -0
  18. data/lib/rails_agent_console/executor.rb +190 -0
  19. data/lib/rails_agent_console/history.rb +65 -0
  20. data/lib/rails_agent_console/model_picker.rb +166 -0
  21. data/lib/rails_agent_console/prompt/dates.rb +34 -0
  22. data/lib/rails_agent_console/prompt/follow_up.rb +38 -0
  23. data/lib/rails_agent_console/prompt.rb +122 -0
  24. data/lib/rails_agent_console/proposal.rb +208 -0
  25. data/lib/rails_agent_console/providers/anthropic.rb +48 -0
  26. data/lib/rails_agent_console/providers/base.rb +114 -0
  27. data/lib/rails_agent_console/providers/callable.rb +28 -0
  28. data/lib/rails_agent_console/providers/gemini.rb +45 -0
  29. data/lib/rails_agent_console/providers/ollama.rb +84 -0
  30. data/lib/rails_agent_console/providers/openai.rb +51 -0
  31. data/lib/rails_agent_console/providers.rb +28 -0
  32. data/lib/rails_agent_console/query_validator/model_suggestion.rb +35 -0
  33. data/lib/rails_agent_console/query_validator/parse_error.rb +23 -0
  34. data/lib/rails_agent_console/query_validator/result.rb +55 -0
  35. data/lib/rails_agent_console/query_validator/rules.rb +108 -0
  36. data/lib/rails_agent_console/query_validator/sensitive_columns.rb +22 -0
  37. data/lib/rails_agent_console/query_validator.rb +305 -0
  38. data/lib/rails_agent_console/railtie.rb +26 -0
  39. data/lib/rails_agent_console/rewriter/arguments.rb +192 -0
  40. data/lib/rails_agent_console/rewriter/chain.rb +126 -0
  41. data/lib/rails_agent_console/rewriter/date_ranges.rb +21 -0
  42. data/lib/rails_agent_console/rewriter/distinct.rb +41 -0
  43. data/lib/rails_agent_console/rewriter/joined.rb +42 -0
  44. data/lib/rails_agent_console/rewriter/qualifier.rb +116 -0
  45. data/lib/rails_agent_console/rewriter/quotes.rb +29 -0
  46. data/lib/rails_agent_console/rewriter/sql_repair.rb +114 -0
  47. data/lib/rails_agent_console/rewriter.rb +183 -0
  48. data/lib/rails_agent_console/schema.rb +275 -0
  49. data/lib/rails_agent_console/setup.rb +40 -0
  50. data/lib/rails_agent_console/ui.rb +125 -0
  51. data/lib/rails_agent_console/value_check.rb +85 -0
  52. data/lib/rails_agent_console/version.rb +5 -0
  53. data/lib/rails_agent_console/wizard.rb +235 -0
  54. data/lib/rails_agent_console.rb +58 -0
  55. metadata +145 -0
@@ -0,0 +1,305 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ripper"
4
+ require "set"
5
+
6
+ require_relative "query_validator/model_suggestion"
7
+ require_relative "query_validator/sensitive_columns"
8
+ require_relative "query_validator/rules"
9
+
10
+ module RailsAgentConsole
11
+ class QueryValidator
12
+ include Rules
13
+
14
+ def self.validate(code, write_mode: RailsAgentConsole.configuration.write_mode?)
15
+ new(code, write_mode: write_mode).validate
16
+ end
17
+
18
+ def initialize(code, write_mode: false)
19
+ @code = code.to_s
20
+ @write_mode = write_mode
21
+ @violations = []
22
+ @methods = []
23
+ @constants = []
24
+ @writes = []
25
+ @unknown = []
26
+ @unknown_constants = []
27
+ @bare = []
28
+ @blocked = false
29
+ @syntax_error = false
30
+ @locals = Set.new
31
+ end
32
+
33
+ def validate
34
+ if @code.strip.empty?
35
+ @violations << "empty code"
36
+ return result
37
+ end
38
+
39
+ sexp = Ripper.sexp(@code)
40
+ if sexp.nil?
41
+ @syntax_error = true
42
+ @violations << syntax_violation
43
+ return result
44
+ end
45
+
46
+ collect_locals(sexp)
47
+ inspect_node(sexp)
48
+ classify_methods
49
+ check_sensitive_columns
50
+ result
51
+ end
52
+
53
+ private
54
+
55
+ def syntax_violation
56
+ message = ParseError.message_for(@code)
57
+ message ? "the code is not valid Ruby: #{message}" : "the code is not valid Ruby"
58
+ end
59
+
60
+ def result
61
+ Result.new(code: @code, violations: @violations, writes: @writes,
62
+ methods: @methods, constants: @constants, unknown: @unknown,
63
+ unknown_constants: @unknown_constants, blocked: @blocked,
64
+ syntax_error: @syntax_error)
65
+ end
66
+
67
+ def collect_locals(node, inside_params: false)
68
+ return unless node.is_a?(Array)
69
+
70
+ case node.first
71
+ when :var_field, :params, :block_var, :rest_param, :kwrest_param, :blockarg
72
+ collect_identifiers(node)
73
+ inside_params = true
74
+ end
75
+
76
+ node.each { |child| collect_locals(child, inside_params: inside_params) if child.is_a?(Array) }
77
+ end
78
+
79
+ def collect_identifiers(node)
80
+ return unless node.is_a?(Array)
81
+
82
+ @locals << node[1] if node.first == :@ident && node[1].is_a?(String)
83
+ node.each { |child| collect_identifiers(child) if child.is_a?(Array) }
84
+ end
85
+
86
+ def inspect_node(node)
87
+ return unless node.is_a?(Array)
88
+ return if handled_as_data?(node)
89
+ return if handled_as_declaration?(node)
90
+ return if handled_as_call?(node)
91
+
92
+ inspect_children(node)
93
+ end
94
+
95
+ def handled_as_data?(node)
96
+ case node.first
97
+ when :symbol_literal, :@label
98
+ true
99
+ when :@tstring_content
100
+ check_string(node[1])
101
+ true
102
+ when :@const
103
+ @constants << node[1]
104
+ true
105
+ else
106
+ false
107
+ end
108
+ end
109
+
110
+ def handled_as_declaration?(node)
111
+ case node.first
112
+ when :xstring_literal
113
+ block("shell command execution via backticks")
114
+ when :def, :defs, :class, :module, :sclass
115
+ block("defining methods or classes is not allowed in a generated query")
116
+ when :@ivar, :@gvar, :@cvar
117
+ block("access to variable #{node[1]}")
118
+ when :field
119
+ block("attribute assignment")
120
+ inspect_children(node)
121
+ when :super, :zsuper
122
+ block("super")
123
+ else
124
+ return false
125
+ end
126
+ true
127
+ end
128
+
129
+ def handled_as_call?(node)
130
+ case node.first
131
+ when :call
132
+ symbol_receiver(node)
133
+ record_message(node[3], receiver: node[1])
134
+ inspect_node(node[1])
135
+ when :command_call
136
+ record_message(node[3], receiver: node[1])
137
+ inspect_node(node[1])
138
+ inspect_node(node[4])
139
+ when :fcall
140
+ record_message(node[1])
141
+ when :command
142
+ record_message(node[1])
143
+ inspect_node(node[2])
144
+ when :vcall
145
+ record_bare(node[1])
146
+ else
147
+ return false
148
+ end
149
+ true
150
+ end
151
+
152
+ def symbol_receiver(node)
153
+ return unless node[1].is_a?(Array) && node[1].first == :symbol_literal
154
+ return unless node[3].is_a?(Array) && node[3].first == :@ident
155
+ return if %w[to_s to_sym to_proc].include?(node[3][1])
156
+
157
+ name = node[1].flatten.find { |part| part.is_a?(String) }
158
+ violate("`:#{name}.#{node[3][1]}` calls #{node[3][1]} on a Symbol, not on a table. " \
159
+ "A column of a joined table is written in SQL: where(\"#{name.pluralize}.#{node[3][1]} = ...\")")
160
+ end
161
+
162
+ def inspect_children(node)
163
+ node.each { |child| inspect_node(child) if child.is_a?(Array) }
164
+ end
165
+
166
+ def record_message(message, receiver: nil)
167
+ return @methods << "call" if message == :call
168
+ return unless message.is_a?(Array)
169
+
170
+ case message.first
171
+ when :@ident, :@op, :@kw
172
+ @methods << message[1] unless read_on_receiver?(message[1], receiver)
173
+ when :@const then @constants << message[1]
174
+ else inspect_node(message)
175
+ end
176
+ end
177
+
178
+ def read_on_receiver?(name, receiver)
179
+ value_construction?(name, receiver) || relation_load?(name, receiver)
180
+ end
181
+
182
+ def relation_load?(name, receiver)
183
+ return false unless name == "load" && !receiver.nil?
184
+
185
+ constant = constant_name(receiver)
186
+ constant.nil? || !UNSAFE_LOAD_RECEIVERS.include?(constant)
187
+ end
188
+
189
+ def value_construction?(name, receiver)
190
+ return false unless CONSTRUCTORS.include?(name)
191
+
192
+ constant = constant_name(receiver)
193
+ !constant.nil? && VALUE_CONSTANTS.include?(constant) && !model_constants.include?(constant)
194
+ end
195
+
196
+ def constant_name(node)
197
+ return unless node.is_a?(Array)
198
+
199
+ case node.first
200
+ when :var_ref, :top_const_ref then constant_name(node[1])
201
+ when :const_path_ref then constant_name(node[2])
202
+ when :@const then node[1]
203
+ end
204
+ end
205
+
206
+ def record_bare(message)
207
+ return unless message.is_a?(Array) && message.first == :@ident
208
+ return if @locals.include?(message[1])
209
+
210
+ @methods << message[1]
211
+ @bare << message[1]
212
+ end
213
+
214
+ def check_string(value)
215
+ block("raw SQL that modifies data: #{value.strip.inspect}") if value.to_s.match?(DANGEROUS_SQL)
216
+ end
217
+
218
+ def classify_methods
219
+ @methods.uniq.each do |name|
220
+ if FORBIDDEN.include?(name)
221
+ block("`#{name}` is never allowed from the agent console")
222
+ elsif WRITE.include?(name)
223
+ @writes << name
224
+ violate("`#{name}` writes to the database (read-only mode)") unless @write_mode
225
+ elsif allowed?(name)
226
+ next
227
+ else
228
+ @unknown << name
229
+ violate("`#{name}` is not on the read-only allowlist")
230
+ end
231
+ end
232
+
233
+ @constants.uniq.each do |name|
234
+ next if allowed_constant?(name)
235
+
236
+ if BLOCKED_CONSTANTS.include?(name)
237
+ block("constant `#{name}` is never allowed from the agent console")
238
+ else
239
+ @unknown_constants << name
240
+ violate("constant `#{name}` is not allowed#{ModelSuggestion.for(name)}")
241
+ end
242
+ end
243
+ end
244
+
245
+ def allowed?(name)
246
+ return allowed_bare?(name) if @bare.include?(name)
247
+
248
+ ALLOWED.include?(name) || schema_readers.include?(name) ||
249
+ extra_allowed_methods.include?(name) ||
250
+ (name.end_with?("?") && ALLOWED.include?(name.chomp("?")))
251
+ end
252
+
253
+ def allowed_bare?(name)
254
+ ALLOWED.include?(name) || extra_allowed_methods.include?(name) ||
255
+ (name.end_with?("?") && ALLOWED.include?(name.chomp("?")))
256
+ end
257
+
258
+ def schema_readers
259
+ @schema_readers ||= Set.new(
260
+ Schema.models.flat_map do |model|
261
+ model.column_names + model.column_names.map { |column| "#{column}?" } + model.association_names
262
+ end
263
+ )
264
+ rescue StandardError
265
+ Set.new
266
+ end
267
+
268
+ def allowed_constant?(name)
269
+ SAFE_CONSTANTS.include?(name) || model_constants.include?(name) ||
270
+ extra_allowed_constants.include?(name)
271
+ end
272
+
273
+ def model_constants
274
+ @model_constants ||= Set.new(Schema.model_names.flat_map { |model| model.split("::") })
275
+ rescue StandardError
276
+ Set.new
277
+ end
278
+
279
+ def extra_allowed_methods
280
+ Set.new(RailsAgentConsole.configuration.extra_allowed_methods.map(&:to_s))
281
+ end
282
+
283
+ def extra_allowed_constants
284
+ Set.new(RailsAgentConsole.configuration.extra_allowed_constants.map(&:to_s))
285
+ end
286
+
287
+ def check_sensitive_columns
288
+ SensitiveColumns.named_in(@code).each do |name|
289
+ block("`#{name}` holds a credential and is never read by the agent")
290
+ end
291
+ end
292
+
293
+ def violate(message)
294
+ @violations << message
295
+ end
296
+
297
+ def block(message)
298
+ @blocked = true
299
+ violate(message)
300
+ end
301
+ end
302
+ end
303
+
304
+ require_relative "query_validator/parse_error"
305
+ require_relative "query_validator/result"
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/railtie"
4
+
5
+ module RailsAgentConsole
6
+ class Railtie < ::Rails::Railtie
7
+ config.rails_agent_console = ActiveSupport::OrderedOptions.new
8
+
9
+ initializer "rails_agent_console.configure" do |app|
10
+ app.config.rails_agent_console.each do |key, value|
11
+ setter = "#{key}="
12
+ configuration = RailsAgentConsole.configuration
13
+ configuration.public_send(setter, value) if configuration.respond_to?(setter)
14
+ end
15
+ end
16
+
17
+ console do
18
+ require "irb"
19
+ require "rails_agent_console/console_installer"
20
+ require "rails_agent_console/console_session"
21
+
22
+ RailsAgentConsole::ConsoleInstaller.install!
23
+ RailsAgentConsole::ConsoleSession.start
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,192 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAgentConsole
4
+ module Rewriter
5
+ class Arguments
6
+ ARGUMENTS = /\A\.\s*\w+[?!]?\s*\((.*)\)\s*\z/m
7
+ STRING_ARGUMENT = /\A\s*("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')\s*\z/
8
+ COLUMN_PAIR = /\A\s*(\w+):\s*([a-z_]\w*)\s*\z/
9
+ SYMBOL_PAIR = /\A\s*(\w+):\s*:(\w+)\s*\z/
10
+ SINGLE_PAIR = /\A\s*(\w+):\s*(.+?)\s*\z/m
11
+ HASH_KEY = /(?<![\w.:])(\w+):\s*\{/
12
+ ORDER_COLUMN = /\A\s*:?(\w+)(?::\s*:(asc|desc))?\s*\z/i
13
+ WEEKDAY = /\A\s*(\w+):\s*(.*\bwday\b.*?)\s*\z/m
14
+ WEEKDAY_TEST = /(?:Time\.(?:current|now)|Date\.(?:current|today))\.wday\s*==\s*([0-6])/
15
+ RAW_SQL_CALLS = %w[pluck pick order reorder].to_set.freeze
16
+ COMPARE_CALLS = %w[where not rewhere].to_set.freeze
17
+ DIRECTIONS = %w[asc desc].to_set.freeze
18
+ VALUES = %w[true false nil].to_set.freeze
19
+
20
+ NOTES = {
21
+ compare: "a hash value is a Ruby value, so comparing two columns is written in SQL",
22
+ weekday: "Time.current.wday is today's weekday, worked out once; the day of each row is EXTRACT(DOW ...)",
23
+ order: "ordering by a joined table's column is written as table.column",
24
+ joined_key: "the column belongs to a joined table, so the condition names that table",
25
+ table_key: "a condition names the joined table rather than the association, " \
26
+ "so every reference to it agrees",
27
+ order_in_group: "a grouped query can only order by what it groups or aggregates, so it orders by MIN(...)",
28
+ raw_sql: "Rails only accepts an SQL expression in pluck and order through Arel.sql",
29
+ count: "counting a joined table counts its id",
30
+ nest: "an association of a joined model is joined through it",
31
+ bare: "an association is named with a symbol, not a bare word"
32
+ }.freeze
33
+ ASSOCIATION_CALLS = (JOINS.keys + %w[includes preload eager_load]).to_set.freeze
34
+ BARE_WORDS = /\A\s*[a-z_]\w*(?:\s*,\s*[a-z_]\w*)*\s*\z/
35
+
36
+ def initialize(chain, model)
37
+ @chain = chain
38
+ @model = model
39
+ @fixed = []
40
+ end
41
+
42
+ def rewrite
43
+ calls = nest_joins(@chain.calls)
44
+ tables = Joined.tables(calls, @model)
45
+ @grouped = calls.select { |call| call.name == "group" }.map(&:symbol_arguments)
46
+ calls = calls.map { |call| fix(call, tables) }
47
+ return if @fixed.empty?
48
+
49
+ [@chain.with_calls(calls), "#{@fixed.uniq.map { |key| NOTES[key] }.join("; ").sub(/\A./, &:upcase)}."]
50
+ end
51
+
52
+ private
53
+
54
+ def bare_association(call, inner)
55
+ return unless ASSOCIATION_CALLS.include?(call.name) && inner.match?(BARE_WORDS)
56
+
57
+ names = inner.split(",").map(&:strip)
58
+ return unless (names - @model.association_names).empty?
59
+
60
+ @fixed << :bare
61
+ names.map { |name| ":#{name}" }.join(", ")
62
+ end
63
+
64
+ def fix(call, tables)
65
+ original = call.text[ARGUMENTS, 1]
66
+ return call if original.nil?
67
+
68
+ inner = table_keys(call, original, tables)
69
+ replacement = bare_association(call, inner) || compare_columns(call, inner) || weekday(call, inner) ||
70
+ joined_key(call, inner, tables) ||
71
+ order_through(call, inner, tables) || order_in_group(call, inner) || wrap_sql(call, inner) ||
72
+ count_joined(call, inner, tables) || (inner unless inner == original)
73
+ replacement ? Chain::Call.new(call.name, ".#{call.name}(#{replacement})") : call
74
+ end
75
+
76
+ def table_keys(call, inner, tables)
77
+ return inner unless COMPARE_CALLS.include?(call.name)
78
+
79
+ inner.gsub(HASH_KEY) do |key|
80
+ table = tables[Regexp.last_match(1)]
81
+ next key if table.nil? || table == Regexp.last_match(1)
82
+
83
+ @fixed << :table_key
84
+ "#{table}: {"
85
+ end
86
+ end
87
+
88
+ def compare_columns(call, inner)
89
+ left, right = inner.match(COLUMN_PAIR)&.captures
90
+ return unless COMPARE_CALLS.include?(call.name) && left && !VALUES.include?(right)
91
+ return unless ([left, right] - @model.column_names).empty?
92
+
93
+ fixed(:compare, %("#{@model.table}.#{left} = #{@model.table}.#{right}"))
94
+ end
95
+
96
+ def weekday(call, inner)
97
+ column, condition = inner.match(WEEKDAY)&.captures
98
+ return unless COMPARE_CALLS.include?(call.name) && column && @model.column_names.include?(column)
99
+ return unless condition.gsub(WEEKDAY_TEST, "").match?(/\A[\s()|]*\z/)
100
+
101
+ days = condition.scan(WEEKDAY_TEST).flatten.uniq.join(", ")
102
+ fixed(:weekday, %("EXTRACT(DOW FROM #{@model.table}.#{column}) IN (#{days})"))
103
+ end
104
+
105
+ def joined_key(call, inner, tables)
106
+ key, value = inner.match(SINGLE_PAIR)&.captures
107
+ return unless COMPARE_CALLS.include?(call.name) && key && !@model.column_names.include?(key)
108
+ return unless Chain::Scanner.single_argument?(value)
109
+
110
+ owners = tables.values.uniq.select { |table| Joined.model_for(table)&.column_names&.include?(key) }
111
+ fixed(:joined_key, "#{owners.first}: { #{key}: #{value} }") if owners.size == 1
112
+ end
113
+
114
+ def order_through(call, inner, tables)
115
+ name, column = inner.match(SYMBOL_PAIR)&.captures
116
+ return unless RAW_SQL_CALLS.include?(call.name) && name && !DIRECTIONS.include?(column.downcase)
117
+
118
+ table = tables[name] || (name if tables.value?(name))
119
+ return unless table && Joined.model_for(table)&.column_names&.include?(column)
120
+
121
+ fixed(:order, %("#{table}.#{column}"))
122
+ end
123
+
124
+ def order_in_group(call, inner)
125
+ column, direction = inner.match(ORDER_COLUMN)&.captures
126
+ return unless %w[order reorder].include?(call.name) && column && grouped_elsewhere?(column)
127
+
128
+ suffix = direction ? " #{direction.upcase}" : ""
129
+ fixed(:order_in_group, %(Arel.sql("MIN(#{@model.table}.#{column})#{suffix}")))
130
+ end
131
+
132
+ def grouped_elsewhere?(column)
133
+ return false if @grouped.empty? || @grouped.any?(&:nil?)
134
+
135
+ !@grouped.flatten.include?(column) && @model.column_names.include?(column)
136
+ end
137
+
138
+ def wrap_sql(call, inner)
139
+ literal = inner[STRING_ARGUMENT, 1]
140
+ return unless RAW_SQL_CALLS.include?(call.name) && literal&.include?("(")
141
+
142
+ fixed(:raw_sql, "Arel.sql(#{literal})")
143
+ end
144
+
145
+ def count_joined(call, inner, tables)
146
+ name = inner[/\A\s*:(\w+)\s*\z/, 1]
147
+ return unless call.name == "count" && name && !@model.column_names.include?(name)
148
+
149
+ table = joined_table(name.delete_suffix("_id"), tables)
150
+ table && fixed(:count, %("#{table}.id"))
151
+ end
152
+
153
+ def joined_table(name, tables)
154
+ tables[name] || tables.values.find { |table| [name, name.pluralize].include?(table) }
155
+ end
156
+
157
+ def nest_joins(calls)
158
+ joined = []
159
+ calls.map do |call|
160
+ names = JOINS.key?(call.name) && call.symbol_arguments
161
+ next call unless names
162
+
163
+ nested = names.map { |name| nest(name, joined) }
164
+ joined.concat(names)
165
+ nested == names ? call : nested_call(call, names, nested)
166
+ end
167
+ end
168
+
169
+ def nest(name, joined)
170
+ return name if Joined.target(@model, name) || @model.inbound.any? { |ref| ref[:table].to_s == name }
171
+
172
+ parent = joined.find do |candidate|
173
+ through = Joined.target(@model, candidate)
174
+ through && Joined.target(through, name)
175
+ end
176
+ parent ? "#{parent}: :#{name}" : name
177
+ end
178
+
179
+ def nested_call(call, names, nested)
180
+ @fixed << :nest
181
+ plain = nested.select.with_index { |item, index| item == names[index] }.map { |name| ":#{name}" }
182
+ pairs = nested.reject.with_index { |item, index| item == names[index] }
183
+ Chain::Call.new(call.name, ".#{call.name}(#{(plain + pairs).join(", ")})")
184
+ end
185
+
186
+ def fixed(key, replacement)
187
+ @fixed << key
188
+ replacement
189
+ end
190
+ end
191
+ end
192
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAgentConsole
4
+ module Rewriter
5
+ class Chain
6
+ RECEIVER = /\A[A-Za-z_]\w*(?:::[A-Z]\w*)*\z/
7
+ CALL_NAME = /\A\.\s*(\w+[?!]?)/
8
+
9
+ Call = Struct.new(:name, :text) do
10
+ def symbol_arguments
11
+ inner = text[/\A\.\s*\w+[?!]?\s*\((.*)\)\s*\z/m, 1]
12
+ return unless inner&.match?(/\A\s*:\w+(?:\s*,\s*:\w+)*\s*\z/)
13
+
14
+ inner.scan(/:(\w+)/).flatten
15
+ end
16
+ end
17
+
18
+ attr_reader :receiver, :calls
19
+
20
+ def self.parse(code)
21
+ text = code.to_s.strip
22
+ return if text.empty? || text.match?(/\bdo\b|;/)
23
+
24
+ pieces = Scanner.new(text).pieces
25
+ return if pieces.nil? || pieces.size < 2 || !pieces.first.match?(RECEIVER)
26
+
27
+ calls = pieces.drop(1).map { |piece| Call.new(piece[CALL_NAME, 1], piece) }
28
+ return unless calls.all? { |call| call.name && Scanner.call_shape?(call.text.sub(CALL_NAME, "")) }
29
+
30
+ new(pieces.first, calls)
31
+ end
32
+
33
+ def initialize(receiver, calls)
34
+ @receiver = receiver
35
+ @calls = calls
36
+ end
37
+
38
+ def with_calls(calls)
39
+ self.class.new(receiver, calls)
40
+ end
41
+
42
+ def to_s
43
+ receiver + calls.map(&:text).join
44
+ end
45
+
46
+ class Scanner
47
+ def self.call_shape?(rest)
48
+ new(rest).call_shape?
49
+ end
50
+
51
+ def self.single_argument?(text)
52
+ new(text).single_argument?
53
+ end
54
+
55
+ def initialize(text)
56
+ @text = text
57
+ @depth = 0
58
+ @quote = nil
59
+ @escaped = false
60
+ end
61
+
62
+ def pieces
63
+ cuts = []
64
+ @text.each_char.with_index do |char, index|
65
+ next if in_string?(char)
66
+ return nil unless balanced_after?(char)
67
+
68
+ cuts << index if char == "." && @depth.zero? && call_dot?(index)
69
+ end
70
+ return unless @depth.zero? && @quote.nil?
71
+
72
+ ([0] + cuts + [@text.length]).each_cons(2).map { |from, to| @text[from...to].strip }
73
+ end
74
+
75
+ def call_shape?
76
+ @text.each_char do |char|
77
+ return false if @quote.nil? && @depth.zero? && !char.match?(/[\s(){}]/)
78
+ next if in_string?(char)
79
+ return false unless balanced_after?(char)
80
+ end
81
+ @depth.zero? && @quote.nil?
82
+ end
83
+
84
+ def single_argument?
85
+ @text.each_char do |char|
86
+ next if in_string?(char)
87
+ return false unless balanced_after?(char)
88
+ return false if char == "," && @depth.zero?
89
+ end
90
+ @depth.zero? && @quote.nil?
91
+ end
92
+
93
+ private
94
+
95
+ def in_string?(char)
96
+ return string_opened?(char) if @quote.nil?
97
+
98
+ if @escaped then @escaped = false
99
+ elsif char == "\\" then @escaped = true
100
+ elsif char == @quote then @quote = nil
101
+ end
102
+ true
103
+ end
104
+
105
+ def string_opened?(char)
106
+ return false unless ["'", '"'].include?(char)
107
+
108
+ @quote = char
109
+ true
110
+ end
111
+
112
+ def balanced_after?(char)
113
+ @depth += 1 if "([{".include?(char)
114
+ @depth -= 1 if ")]}".include?(char)
115
+ @depth >= 0
116
+ end
117
+
118
+ def call_dot?(index)
119
+ before = index.positive? ? @text[index - 1] : ""
120
+ after = @text[index + 1].to_s
121
+ before != "." && after != "." && !before.match?(/\d/) && after.match?(/[\s\w]/)
122
+ end
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAgentConsole
4
+ module Rewriter
5
+ module DateRanges
6
+ INCLUSIVE_END = /
7
+ (?<![.])\.\.\s*
8
+ (?<date>Time\.zone\.parse\((?<quote>["'])\d{4}-\d{2}-\d{2}\k<quote>\))
9
+ (?!\s*\.\s*end_of_day)
10
+ /x
11
+ NOTE = "A range that ended on a date stopped at midnight and missed that day, so it now runs to end_of_day."
12
+
13
+ module_function
14
+
15
+ def fix(code)
16
+ fixed = code.gsub(INCLUSIVE_END) { "..#{Regexp.last_match[:date]}.end_of_day" }
17
+ fixed == code ? [code, nil] : [fixed, NOTE]
18
+ end
19
+ end
20
+ end
21
+ end