dialecto-rails 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 (51) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +196 -0
  4. data/action.yml +83 -0
  5. data/exe/dialecto-rails +6 -0
  6. data/lib/dialecto-rails.rb +1 -0
  7. data/lib/dialecto_rails/config.rb +206 -0
  8. data/lib/dialecto_rails/errors.rb +21 -0
  9. data/lib/dialecto_rails/in_context/args.rb +37 -0
  10. data/lib/dialecto_rails/in_context/assets/overlay.js +2784 -0
  11. data/lib/dialecto_rails/in_context/backend_hook.rb +39 -0
  12. data/lib/dialecto_rails/in_context/csp.rb +72 -0
  13. data/lib/dialecto_rails/in_context/git_context.rb +103 -0
  14. data/lib/dialecto_rails/in_context/key_index.rb +128 -0
  15. data/lib/dialecto_rails/in_context/launch_token.rb +78 -0
  16. data/lib/dialecto_rails/in_context/live_middleware.rb +307 -0
  17. data/lib/dialecto_rails/in_context/live_runtime.rb +53 -0
  18. data/lib/dialecto_rails/in_context/live_store.rb +14 -0
  19. data/lib/dialecto_rails/in_context/marker.rb +106 -0
  20. data/lib/dialecto_rails/in_context/marking.rb +122 -0
  21. data/lib/dialecto_rails/in_context/middleware.rb +202 -0
  22. data/lib/dialecto_rails/in_context/overrides.rb +61 -0
  23. data/lib/dialecto_rails/in_context/railtie_stub.rb +76 -0
  24. data/lib/dialecto_rails/in_context/runtime.rb +52 -0
  25. data/lib/dialecto_rails/in_context/sanitizer.rb +19 -0
  26. data/lib/dialecto_rails/in_context/store.rb +41 -0
  27. data/lib/dialecto_rails/scan/catalogs.rb +46 -0
  28. data/lib/dialecto_rails/scan/cli.rb +339 -0
  29. data/lib/dialecto_rails/scan/client.rb +117 -0
  30. data/lib/dialecto_rails/scan/envelope.rb +100 -0
  31. data/lib/dialecto_rails/scan/git.rb +68 -0
  32. data/lib/dialecto_rails/scan/worktree.rb +41 -0
  33. data/lib/dialecto_rails/unmarked.rb +42 -0
  34. data/lib/dialecto_rails/usages/call_args.rb +78 -0
  35. data/lib/dialecto_rails/usages/catalog_keys.rb +67 -0
  36. data/lib/dialecto_rails/usages/check.rb +70 -0
  37. data/lib/dialecto_rails/usages/haml_extractor.rb +260 -0
  38. data/lib/dialecto_rails/usages/keys.rb +206 -0
  39. data/lib/dialecto_rails/usages/known_wrappers.rb +31 -0
  40. data/lib/dialecto_rails/usages/lazy_scope.rb +52 -0
  41. data/lib/dialecto_rails/usages/parse_check.rb +75 -0
  42. data/lib/dialecto_rails/usages/read_by_name.json +28 -0
  43. data/lib/dialecto_rails/usages/record.rb +91 -0
  44. data/lib/dialecto_rails/usages/ruby_visitor.rb +628 -0
  45. data/lib/dialecto_rails/usages/scanner.rb +241 -0
  46. data/lib/dialecto_rails/usages/summary.rb +191 -0
  47. data/lib/dialecto_rails/usages/templates.rb +45 -0
  48. data/lib/dialecto_rails/usages/wrappers.rb +336 -0
  49. data/lib/dialecto_rails/version.rb +3 -0
  50. data/lib/dialecto_rails.rb +14 -0
  51. metadata +119 -0
@@ -0,0 +1,628 @@
1
+ require "prism"
2
+ require_relative "record"
3
+ require_relative "keys"
4
+ require_relative "call_args"
5
+ require_relative "lazy_scope"
6
+ require_relative "wrappers"
7
+
8
+ module DialectoRails
9
+ module Usages
10
+ # Walks one Ruby program (a `.rb` file, or the Ruby a template was reduced to, with the same byte offsets as
11
+ # the file) and returns the usage records, mailer subject conventions and `literal` sweep hits in it.
12
+ class RubyVisitor < Prism::Visitor
13
+ LOOP_METHODS = %i[each map flat_map collect each_with_index filter_map select reject].freeze
14
+ VISIBILITIES = %i[public private protected].freeze
15
+ MAX_LITERAL = 160
16
+ # Wrappers are found by name, so test code (`raise_error "msg"` matchers) must not match them.
17
+ TEST_FILE = %r{(?:\A|/)(?:test|spec|features)/}
18
+
19
+ Result = Struct.new(:records, :conventions, keyword_init: true)
20
+
21
+ # A bare `Struct` standing in for an `Anchor` where the bytes are already decided.
22
+ Span = Struct.new(:start, :finish)
23
+
24
+ def self.literal_values(node)
25
+ return nil unless node.is_a?(Prism::ArrayNode)
26
+
27
+ values = node.elements.map do |element|
28
+ element.unescaped if element.is_a?(Prism::StringNode) || element.is_a?(Prism::SymbolNode)
29
+ end
30
+ values.empty? || values.any?(&:nil?) ? nil : values
31
+ end
32
+
33
+ def initialize(file:, lang:, bytes:, wrappers:, catalog:, i18n_functions: [])
34
+ super()
35
+ @file = file
36
+ @lang = lang
37
+ @bytes = bytes
38
+ @lines = LineIndex.new(bytes)
39
+ @wrappers = wrappers
40
+ @catalog = catalog
41
+ @i18n_functions = i18n_functions.map(&:to_sym)
42
+ @records = []
43
+ @conventions = []
44
+ @consumed = {}
45
+ @classes = []
46
+ @defs = []
47
+ @env = {}
48
+ @arrays = {}
49
+ @constants = {}
50
+ @visibilities = {}
51
+ end
52
+
53
+ def call(program)
54
+ @constants = ConstantCollector.run(program)
55
+ visit(program)
56
+ Result.new(records: @records, conventions: @conventions)
57
+ end
58
+
59
+ # Collects `NAME = %w[a b]` (optionally `.freeze`d), so a loop over the constant resolves to its values.
60
+ class ConstantCollector < Prism::Visitor
61
+ def self.run(program)
62
+ collector = new
63
+ collector.visit(program)
64
+ collector.found.compact
65
+ end
66
+
67
+ attr_reader :found
68
+
69
+ def initialize
70
+ super
71
+ @found = {}
72
+ end
73
+
74
+ def visit_constant_write_node(node)
75
+ value = node.value
76
+ value = value.receiver if value.is_a?(Prism::CallNode) && value.name == :freeze && value.receiver
77
+ @found[node.name] = RubyVisitor.literal_values(value)
78
+ super
79
+ end
80
+ end
81
+
82
+ ## Context
83
+
84
+ def visit_class_node(node)
85
+ names = node.constant_path.slice.split("::")
86
+ enter_scope(names, LazyScope.kind(names.last, node.superclass&.slice), node) { super }
87
+ end
88
+
89
+ def visit_module_node(node)
90
+ enter_scope(node.constant_path.slice.split("::"), nil, node) { super }
91
+ end
92
+
93
+ def visit_def_node(node)
94
+ display = node.receiver ? "#{node.receiver.slice}.#{node.name}" : node.name.to_s
95
+ parameters = node.parameters
96
+ forward = [parameters&.rest, parameters&.keyword_rest, parameters&.block].filter_map { |item| item.name if item.respond_to?(:name) }
97
+ @defs << {name: node.name.to_s, display: display, forward: forward,
98
+ visibility: @visibilities[node.location.start_offset] || :public}
99
+ super
100
+ ensure
101
+ @defs.pop
102
+ end
103
+
104
+ def visit_local_variable_write_node(node)
105
+ values = self.class.literal_values(node.value)
106
+ values ? @arrays[node.name] = values : @arrays.delete(node.name)
107
+ super
108
+ end
109
+
110
+ def visit_call_node(node)
111
+ binding = loop_binding(node)
112
+ saved = @env
113
+ @env = @env.merge(binding) if binding
114
+ handle(node)
115
+ super
116
+ ensure
117
+ @env = saved
118
+ end
119
+
120
+ def visit_string_node(node)
121
+ sweep(node)
122
+ super
123
+ end
124
+
125
+ def visit_symbol_node(node)
126
+ sweep(node)
127
+ super
128
+ end
129
+
130
+ # The parts of `"users.#{role}"` are not strings in their own right.
131
+ def visit_interpolated_string_node(node)
132
+ node.parts.each { |part| @consumed[part.location.start_offset] = true if part.is_a?(Prism::StringNode) }
133
+ super
134
+ end
135
+
136
+ def visit_interpolated_symbol_node(node)
137
+ node.parts.each { |part| @consumed[part.location.start_offset] = true if part.is_a?(Prism::StringNode) }
138
+ super
139
+ end
140
+
141
+ private
142
+
143
+ def enter_scope(names, kind, node)
144
+ nesting = @classes.flat_map { |frame| frame[:names] } + names
145
+ @classes << {names: names, kind: kind, nesting: nesting}
146
+ record_visibilities(node.body)
147
+ yield
148
+ ensure
149
+ @classes.pop
150
+ end
151
+
152
+ # Which methods of a class body are public, private or protected: the bare `private`, `private def` and
153
+ # `private :name` forms.
154
+ def record_visibilities(body)
155
+ statements = body.is_a?(Prism::StatementsNode) ? body.body : []
156
+ state = :public
157
+ named = {}
158
+ statements.each do |statement|
159
+ case statement
160
+ when Prism::CallNode
161
+ next unless statement.receiver.nil? && VISIBILITIES.include?(statement.name)
162
+
163
+ arguments = statement.arguments&.arguments || []
164
+ if arguments.empty?
165
+ state = statement.name
166
+ else
167
+ arguments.each do |argument|
168
+ case argument
169
+ when Prism::DefNode then @visibilities[argument.location.start_offset] = statement.name
170
+ when Prism::SymbolNode then named[argument.unescaped] = statement.name
171
+ end
172
+ end
173
+ end
174
+ when Prism::DefNode
175
+ @visibilities[statement.location.start_offset] ||= state
176
+ end
177
+ end
178
+ statements.grep(Prism::DefNode).each do |definition|
179
+ visibility = named[definition.name.to_s]
180
+ @visibilities[definition.location.start_offset] = visibility if visibility
181
+ end
182
+ end
183
+
184
+ def loop_binding(node)
185
+ return nil unless LOOP_METHODS.include?(node.name) && node.block.is_a?(Prism::BlockNode)
186
+
187
+ return nil unless node.block.parameters.is_a?(Prism::BlockParametersNode)
188
+
189
+ parameter = node.block.parameters.parameters&.requireds&.first
190
+ return nil unless parameter.is_a?(Prism::RequiredParameterNode)
191
+
192
+ values = case (receiver = node.receiver)
193
+ when Prism::ArrayNode then self.class.literal_values(receiver)
194
+ when Prism::ConstantReadNode then @constants[receiver.name]
195
+ when Prism::LocalVariableReadNode then @arrays[receiver.name]
196
+ end
197
+ values ? {parameter.name => values} : nil
198
+ end
199
+
200
+ def fn = @defs.last&.dig(:display)
201
+
202
+ def class_frame = @classes.reverse.find { |frame| frame[:kind] }
203
+
204
+ ## Calls
205
+
206
+ def handle(node)
207
+ wrapped = @wrappers.suppressed(@file, node.location.start_offset)
208
+ unless wrapped.nil?
209
+ emit_template(node, wrapped) if wrapped
210
+ return
211
+ end
212
+
213
+ track_mail(node)
214
+ receiver = CallArgs.receiver_kind(node)
215
+ return receiver_call(node) if receiver == :other
216
+
217
+ form, origin = translate_form(node, receiver)
218
+ positional = CallArgs.positional(node)
219
+ if form
220
+ translate_record(node, positional.first, form, origin) if positional.first
221
+ elsif receiver == :none && !positional.empty? && !TEST_FILE.match?(@file) && (builders = @wrappers.lookup(node.name, file: @file, klass: @classes.last&.dig(:names)&.last))
222
+ wrapper_call(node, positional, builders)
223
+ end
224
+ end
225
+
226
+ # A call on a constant (`Spree.t("x")`, `::Spree.t`): a use when the constant's method is a wrapper, found in
227
+ # the source or declared (spec-0023 §6.3).
228
+ def receiver_call(node)
229
+ path = CallArgs.const_path(node.receiver)
230
+ return unless path && !TEST_FILE.match?(@file)
231
+
232
+ positional = CallArgs.positional(node)
233
+ builders = !positional.empty? && @wrappers.lookup_receiver(path, node.name)
234
+ wrapper_call(node, positional, builders, form: "#{path}.#{node.name}") if builders
235
+ end
236
+
237
+ def translate_form(node, receiver)
238
+ name = node.name
239
+ if receiver == :i18n
240
+ ["I18n.#{name}", "i18n"] if CallArgs::TRANSLATE_NAMES.include?(name) || name == :exists?
241
+ elsif CallArgs::TRANSLATE_NAMES.include?(name)
242
+ [name.to_s, "helper"]
243
+ elsif receiver == :none && @i18n_functions.include?(name)
244
+ [name.to_s, "name"]
245
+ end
246
+ end
247
+
248
+ ## A translate call
249
+
250
+ def translate_record(node, key_node, form, origin)
251
+ options = CallArgs.options(node, forward: @defs.last&.dig(:forward) || [])
252
+ analysis = Keys.analyze(key_node, env: @env)
253
+ # A message with spaces is not a key: a wrapper found by name can collide with an unrelated method.
254
+ return if origin == "wrapper" && analysis.kind == :static && analysis.text.match?(/\s/)
255
+ # A test's own `t "<p>text</p>"` helper (a typography spec) is not a key.
256
+ return if origin == "helper" && TEST_FILE.match?(@file) && analysis.kind == :static && analysis.text.match?(/[\s<\\]/)
257
+ return star(node, form, origin, "custom_separator") if custom_separator?(options.separator)
258
+ return star(node, form, origin, "concatenated_literal") if analysis.reason == :concatenated_literal
259
+
260
+ text = analysis.kind == :static ? analysis.text : analysis.items&.first&.dig(:lit)
261
+ if origin == "i18n"
262
+ analysis = without_leading_dots(analysis)
263
+ lazy = nil
264
+ else
265
+ lazy = lazy_context(analysis) if text.to_s.start_with?(".") && analysis.kind != :set
266
+ end
267
+
268
+ prefix, scope, scope_literals, reasons = lazy ? lazy_prefix(lazy) : scope_prefix(options)
269
+ defaults, default_literals = defaults_for(options, prefix, lazy)
270
+ literals = analysis.literals.to_a + scope_literals + default_literals
271
+ build(node, form, origin, analysis, prefix: prefix, lazy: lazy, reasons: reasons, scope: scope,
272
+ defaults: defaults, literals: literals)
273
+ [key_node, options.scope, options.default].each { |argument| consume(argument) }
274
+ end
275
+
276
+ # `I18n.t(".x")` outside a view has no lazy meaning: I18n drops the empty segment (spec-0023 §6.4).
277
+ def without_leading_dots(analysis)
278
+ return analysis unless analysis.kind == :static && analysis.text.start_with?(".")
279
+
280
+ stripped = analysis.text.sub(/\A\.+/, "")
281
+ Keys::Analysis.new(kind: :static, text: stripped, items: [{lit: stripped}], literals: [])
282
+ end
283
+
284
+ def custom_separator?(separator)
285
+ !separator.nil? && !(separator.is_a?(Prism::StringNode) && separator.unescaped == ".")
286
+ end
287
+
288
+ # Returns [prefix items, scope entries (or nil), scope literals, reasons].
289
+ def scope_prefix(options)
290
+ return [[{wild: true}, {lit: "."}], nil, [], [:splat]] if options.splat
291
+ return [[], nil, [], []] unless options.scope
292
+
293
+ items = []
294
+ entries = []
295
+ literals = []
296
+ reasons = []
297
+ scope_elements(options.scope).each do |element|
298
+ if element[:literal]
299
+ literals << element[:literal]
300
+ entries << {literal: element[:literal]}
301
+ items << {lit: i18n_path(element[:text]) + "."}
302
+ else
303
+ entries << {expr: element[:expr]}
304
+ items << {wild: true} << {lit: "."}
305
+ reasons << :dynamic_scope
306
+ end
307
+ end
308
+ [items, entries, literals, reasons]
309
+ end
310
+
311
+ def scope_elements(node)
312
+ nodes = node.is_a?(Prism::ArrayNode) ? node.elements : [node]
313
+ nodes.map do |element|
314
+ literal = [Prism::StringNode, Prism::SymbolNode].any? { |type| element.is_a?(type) }
315
+ analysis = literal ? Keys.analyze(element, role: :scope) : nil
316
+ if analysis && !analysis.text.empty? && analysis.literals.length == 1
317
+ {literal: analysis.literals.first, text: analysis.text}
318
+ else
319
+ {expr: element.slice.byteslice(0, 200).scrub("")}
320
+ end
321
+ end
322
+ end
323
+
324
+ # The key's prefix for a lazy call: where Rails resolves it from, decided by the file and the class.
325
+ def lazy_prefix(lazy)
326
+ case lazy[:type]
327
+ when :view then [[{lit: lazy[:base]}], nil, [], []]
328
+ when :action then [[{lit: "#{lazy[:path]}.#{lazy[:action]}"}], nil, [], []]
329
+ when :unknown then [[{lit: "#{lazy[:path]}."}, {wild: true}], nil, [], [:lazy_action_unknown]]
330
+ else [[{wild: true}], nil, [], [:lazy_outside_view]]
331
+ end
332
+ end
333
+
334
+ def lazy_context(analysis)
335
+ key = analysis.kind == :static ? analysis.text : nil
336
+ view = LazyScope.view(@file)
337
+ return {type: :view, base: view[:base], from: view[:from], key: key} if view
338
+
339
+ frame = class_frame
340
+ return {type: :outside, from: "other", key: key} unless frame
341
+
342
+ kind = frame[:kind]
343
+ path = LazyScope.path(frame[:nesting].join("::"), kind)
344
+ current = @defs.last
345
+ if current && LazyScope.action?(kind, current[:name], current[:visibility])
346
+ {type: :action, path: path, action: current[:name], from: kind.to_s, key: key}
347
+ else
348
+ {type: :unknown, path: path, from: kind.to_s, key: key}
349
+ end
350
+ end
351
+
352
+ def defaults_for(options, prefix, lazy)
353
+ out = []
354
+ literals = []
355
+ if lazy
356
+ out << {key: "#{lazy[:path]}#{lazy[:key]}"} if %i[action unknown].include?(lazy[:type]) && lazy[:key]
357
+ return [out, literals]
358
+ end
359
+ return [out, literals] if !options.default || prefix.any? { |item| item[:wild] }
360
+
361
+ base = prefix.map { |item| item[:lit] }.join
362
+ nodes = options.default.is_a?(Prism::ArrayNode) ? options.default.elements : [options.default]
363
+ nodes.each do |element|
364
+ next unless element.is_a?(Prism::SymbolNode)
365
+
366
+ analysis = Keys.analyze(element, role: :default)
367
+ literal = analysis.literals.first
368
+ literals << literal if literal
369
+ out << {key: base + analysis.text, literal: literal}
370
+ end
371
+ [out, literals]
372
+ end
373
+
374
+ ## Building a record
375
+
376
+ def build(node, form, origin, analysis, prefix:, lazy:, reasons:, scope:, defaults:, literals:)
377
+ items = prefix + (analysis.items || [{wild: true}])
378
+ unresolved = analysis.kind == :unresolved
379
+ kind = if prefix.any? { |item| item[:wild] } || %i[pattern unresolved].include?(analysis.kind) then :pattern
380
+ elsif analysis.kind == :set then :set
381
+ else :static
382
+ end
383
+
384
+ anchor = Anchor.new(start: node.location.start_offset, call_end: node.location.end_offset, literals: literals)
385
+ return star(node, form, origin, "anchor_too_long", truncate: true) if anchor.too_long?
386
+
387
+ record = base_record(node, anchor, form, origin)
388
+ record["kind"] = kind.to_s
389
+ base = prefix.reject { |item| item[:wild] }.map { |item| item[:lit] }.join
390
+ case kind
391
+ when :static
392
+ record["key"] = i18n_path(base + analysis.text)
393
+ when :set
394
+ record["keys"] = analysis.keys.map { |key| i18n_path(base + key) }.sort
395
+ assign_pattern(record, prefix + analysis.items)
396
+ else
397
+ assign_pattern(record, items)
398
+ reason = (unresolved ? analysis.reason : nil) || reasons.first || analysis.reason || :variable
399
+ record["reason"] = reason.to_s
400
+ end
401
+
402
+ record["lazy"] = lazy_json(lazy) if lazy
403
+ record["scope"] = scope_json(scope, anchor) if scope && !scope.empty?
404
+ unless defaults.empty?
405
+ record["defaults"] = defaults.map do |entry|
406
+ index = entry[:literal] && anchor.index_of(entry[:literal])
407
+ index ? {"key" => entry[:key], "span" => index} : {"key" => entry[:key]}
408
+ end
409
+ end
410
+ record["spans"] = anchor.spans
411
+ @records << record
412
+ end
413
+
414
+ # I18n splits a key and every scope element on "." and drops the empty segments
415
+ # (`I18n.normalize_keys`): `scope: "users."` and `t("a..b")` read as `users.x` and `a.b`.
416
+ def i18n_path(text) = text.split(".").reject(&:empty?).join(".")
417
+
418
+ def scope_json(scope, anchor)
419
+ scope.map { |entry| entry[:literal] ? {"span" => anchor.index_of(entry[:literal])} : {"expr" => entry[:expr]} }
420
+ end
421
+
422
+ def lazy_json(lazy)
423
+ json = {"from" => lazy[:from]}
424
+ case lazy[:type]
425
+ when :view then json["base"] = lazy[:base]
426
+ when :action then json["base"] = "#{lazy[:path]}.#{lazy[:action]}"
427
+ end
428
+ json
429
+ end
430
+
431
+ def assign_pattern(record, items)
432
+ parts = Parts.build(items)
433
+ record["pattern"] = Parts.pattern(parts)
434
+ record["parts"] = Parts.json(parts)
435
+ end
436
+
437
+ def base_record(node, anchor, form, origin)
438
+ start = anchor.start
439
+ content = slice(start, anchor.finish)
440
+ record = {
441
+ "file" => @file, "lang" => @lang, "start_line" => @lines.line_of(start),
442
+ "end_line" => @lines.line_of(start) + content.count("\n"), "offset" => start, "content" => content,
443
+ "form" => form, "fn" => fn
444
+ }
445
+ record["origin"] = origin if origin
446
+ record
447
+ end
448
+
449
+ def slice(start, finish) = @bytes.byteslice(start, finish - start).dup.force_encoding(Encoding::UTF_8)
450
+
451
+ # A pattern `*` record: the key can't be read, so the anchor runs to the call's closing parenthesis and
452
+ # there are no spans.
453
+ def star(node, form, origin, reason, truncate: false, templates: nil, finish: nil)
454
+ start = node.location.start_offset
455
+ finish ||= node.location.end_offset
456
+ if truncate && finish - start > Anchor::MAX_BYTES
457
+ bytes = @bytes.byteslice(start, Anchor::MAX_BYTES).dup.force_encoding(Encoding::UTF_8)
458
+ bytes = bytes.byteslice(0, bytes.bytesize - 1).force_encoding(Encoding::UTF_8) until bytes.valid_encoding?
459
+ finish = start + bytes.bytesize
460
+ end
461
+ record = base_record(node, Span.new(start, finish), form, origin)
462
+ record["kind"] = "pattern"
463
+ record["pattern"] = "*"
464
+ record["parts"] = [{"wild" => true}]
465
+ record["reason"] = reason
466
+ record["via"] = templates if templates && !templates.empty?
467
+ record["spans"] = []
468
+ @records << record
469
+ consume(node.arguments)
470
+ end
471
+
472
+ ## Wrapper calls and templates
473
+
474
+ def wrapper_call(node, positional, builders, form: node.name.to_s)
475
+ options = CallArgs.options(node, forward: @defs.last&.dig(:forward) || [])
476
+ templates = builders.flat_map(&:templates).uniq
477
+ builder = builders.length == 1 ? builders.first : nil
478
+ return unless key_arguments_plausible?(builders, positional)
479
+
480
+ if builder&.pass_through? && positional[builder.items.first[:param]]
481
+ return translate_record(node, positional[builder.items.first[:param]], form, "wrapper")
482
+ end
483
+ if builder.nil? || builder.opaque || options.scope || options.splat || options.separator
484
+ return fixed_pattern(node, form, builder.fixed, templates) if builder&.fixed
485
+
486
+ return star(node, form, "wrapper", "argument", templates: templates)
487
+ end
488
+
489
+ analyses = positional.map { |argument| Keys.analyze(argument, env: @env) }
490
+ used = builder.items.filter_map { |item| item[:param] }.uniq
491
+ known = builder.items.none? { |item| item[:wild] } && used.all? { |index| analyses[index] && %i[static set].include?(analyses[index].kind) }
492
+ substituted = builder.items.flat_map do |item|
493
+ next [item] unless item.key?(:param)
494
+
495
+ argument = analyses[item[:param]]
496
+ argument && %i[static set pattern].include?(argument.kind) ? argument.items : [{wild: true}]
497
+ end
498
+
499
+ extra_end = used.filter_map { |index| positional[index]&.location&.end_offset }.max
500
+ anchor = Anchor.new(start: node.location.start_offset, call_end: node.location.end_offset, literals: [],
501
+ extra_end: extra_end)
502
+ record = base_record(node, anchor, form, "wrapper")
503
+ if known
504
+ keys = expand_keys(builder.items, analyses)
505
+ record["kind"] = "set"
506
+ record["keys"] = keys
507
+ assign_pattern(record, keys.length == 1 ? [{lit: keys.first}] : substituted)
508
+ else
509
+ record["kind"] = "pattern"
510
+ assign_pattern(record, substituted)
511
+ record["reason"] = "argument"
512
+ end
513
+ record["via"] = templates unless templates.empty?
514
+ record["spans"] = []
515
+ @records << record
516
+ positional.each { |argument| consume(argument) }
517
+ end
518
+
519
+ # A declared wrapper whose key can't be read (`Spree.t(key, scope: x)`): it still reads under its own scope.
520
+ def fixed_pattern(node, form, fixed, templates)
521
+ anchor = Anchor.new(start: node.location.start_offset, call_end: node.location.end_offset, literals: [])
522
+ return star(node, form, "wrapper", "argument", templates: templates) if anchor.too_long?
523
+
524
+ record = base_record(node, anchor, form, "wrapper")
525
+ record["kind"] = "pattern"
526
+ assign_pattern(record, [{lit: fixed}, {wild: true}])
527
+ record["reason"] = "argument"
528
+ record["via"] = templates unless templates.empty?
529
+ record["spans"] = []
530
+ @records << record
531
+ consume(node.arguments)
532
+ end
533
+
534
+ NOT_A_KEY =[Prism::IntegerNode, Prism::FloatNode, Prism::NilNode, Prism::TrueNode, Prism::FalseNode,
535
+ Prism::HashNode, Prism::RangeNode].freeze
536
+
537
+ # A call that doesn't pass the argument the wrapper builds its key from, or passes a number or a sentence,
538
+ # isn't reading a key: the wrapper was found by name, and the name may belong to another method.
539
+ def key_arguments_plausible?(builders, positional)
540
+ used = builders.flat_map { |builder| builder.items.filter_map { |item| item[:param] } }.uniq
541
+ arguments = used.map { |index| positional[index] }
542
+ return false if arguments.any?(&:nil?)
543
+
544
+ arguments.none? do |argument|
545
+ NOT_A_KEY.any? { |type| argument.is_a?(type) } ||
546
+ (argument.is_a?(Prism::StringNode) && argument.unescaped.match?(/\s/))
547
+ end
548
+ end
549
+
550
+ def expand_keys(items, analyses)
551
+ options = items.map do |item|
552
+ next [item[:lit]] if item.key?(:lit)
553
+
554
+ argument = analyses[item[:param]]
555
+ argument.kind == :static ? [argument.text] : argument.keys
556
+ end
557
+ options.inject([""]) { |acc, values| acc.product(values).map(&:join) }.uniq.sort
558
+ end
559
+
560
+ # Where a wrapper's text is written: the call inside the wrapper, cut at its first interpolation.
561
+ def emit_template(node, wrapped)
562
+ template = wrapped
563
+ form = node.name.to_s
564
+ form = "I18n.#{form}" if CallArgs.receiver_kind(node) == :i18n
565
+ origin = CallArgs.receiver_kind(node) == :i18n ? "i18n" : "helper"
566
+ record = base_record(node, Span.new(template.start, template.text_end), form, origin)
567
+ record["kind"] = "template"
568
+ record["id"] = template.id
569
+ assign_pattern(record, Keys.analyze(CallArgs.positional(node).first).items)
570
+ record["spans"] = [{"start" => template.literal.text_start - template.start,
571
+ "end" => template.literal.text_end - template.start, "delim" => template.literal.delim,
572
+ "role" => "key", "lang" => "ruby", "head" => true, "closed" => false}]
573
+ @records << record
574
+ consume(CallArgs.positional(node).first)
575
+ end
576
+
577
+ ## Mailer subjects
578
+
579
+ def track_mail(node)
580
+ return unless node.name == :mail && node.receiver.nil?
581
+
582
+ frame = class_frame
583
+ current = @defs.last
584
+ return unless frame && frame[:kind] == :mailer && current && current[:visibility] == :public
585
+
586
+ explicit = CallArgs.keyword_hash(node)&.elements&.any? do |element|
587
+ element.is_a?(Prism::AssocNode) && element.key.is_a?(Prism::SymbolNode) && element.key.unescaped == "subject"
588
+ end
589
+ return if explicit
590
+
591
+ constant = frame[:nesting].join("::")
592
+ @conventions << {
593
+ "key" => "#{LazyScope.path(constant, :mailer)}.#{current[:name]}.subject", "reason" => "mailer_subject",
594
+ "source" => "#{constant}##{current[:name]}", "file" => @file,
595
+ "line" => @lines.line_of(node.location.start_offset)
596
+ }
597
+ end
598
+
599
+ ## The sweep
600
+
601
+ def consume(node)
602
+ return unless node
603
+
604
+ @consumed[node.location.start_offset] = true if node.is_a?(Prism::StringNode) || node.is_a?(Prism::SymbolNode)
605
+ node.compact_child_nodes.each { |child| consume(child) }
606
+ end
607
+
608
+ def sweep(node)
609
+ return if @catalog.nil? || @catalog.empty? || @consumed[node.location.start_offset]
610
+
611
+ text = node.unescaped
612
+ return if text.empty? || text.length >= MAX_LITERAL
613
+ # A one-segment key ("name") is a word in half the strings of a codebase; the sweep wants a dotted key.
614
+ return unless (text.include?(".") && @catalog.include?(text)) || @catalog.prefix?(text)
615
+ return if node.opening_loc&.slice&.start_with?("<<")
616
+
617
+ analysis = node.is_a?(Prism::StringNode) ? Keys.static_string(node, :key) : Keys.static_symbol(node, :key)
618
+ anchor = Anchor.new(start: node.location.start_offset, call_end: node.location.end_offset,
619
+ literals: analysis.literals)
620
+ record = base_record(node, anchor, "literal", nil)
621
+ record["kind"] = "literal"
622
+ record["key"] = text
623
+ record["spans"] = anchor.spans
624
+ @records << record
625
+ end
626
+ end
627
+ end
628
+ end