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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +196 -0
- data/action.yml +83 -0
- data/exe/dialecto-rails +6 -0
- data/lib/dialecto-rails.rb +1 -0
- data/lib/dialecto_rails/config.rb +206 -0
- data/lib/dialecto_rails/errors.rb +21 -0
- data/lib/dialecto_rails/in_context/args.rb +37 -0
- data/lib/dialecto_rails/in_context/assets/overlay.js +2784 -0
- data/lib/dialecto_rails/in_context/backend_hook.rb +39 -0
- data/lib/dialecto_rails/in_context/csp.rb +72 -0
- data/lib/dialecto_rails/in_context/git_context.rb +103 -0
- data/lib/dialecto_rails/in_context/key_index.rb +128 -0
- data/lib/dialecto_rails/in_context/launch_token.rb +78 -0
- data/lib/dialecto_rails/in_context/live_middleware.rb +307 -0
- data/lib/dialecto_rails/in_context/live_runtime.rb +53 -0
- data/lib/dialecto_rails/in_context/live_store.rb +14 -0
- data/lib/dialecto_rails/in_context/marker.rb +106 -0
- data/lib/dialecto_rails/in_context/marking.rb +122 -0
- data/lib/dialecto_rails/in_context/middleware.rb +202 -0
- data/lib/dialecto_rails/in_context/overrides.rb +61 -0
- data/lib/dialecto_rails/in_context/railtie_stub.rb +76 -0
- data/lib/dialecto_rails/in_context/runtime.rb +52 -0
- data/lib/dialecto_rails/in_context/sanitizer.rb +19 -0
- data/lib/dialecto_rails/in_context/store.rb +41 -0
- data/lib/dialecto_rails/scan/catalogs.rb +46 -0
- data/lib/dialecto_rails/scan/cli.rb +339 -0
- data/lib/dialecto_rails/scan/client.rb +117 -0
- data/lib/dialecto_rails/scan/envelope.rb +100 -0
- data/lib/dialecto_rails/scan/git.rb +68 -0
- data/lib/dialecto_rails/scan/worktree.rb +41 -0
- data/lib/dialecto_rails/unmarked.rb +42 -0
- data/lib/dialecto_rails/usages/call_args.rb +78 -0
- data/lib/dialecto_rails/usages/catalog_keys.rb +67 -0
- data/lib/dialecto_rails/usages/check.rb +70 -0
- data/lib/dialecto_rails/usages/haml_extractor.rb +260 -0
- data/lib/dialecto_rails/usages/keys.rb +206 -0
- data/lib/dialecto_rails/usages/known_wrappers.rb +31 -0
- data/lib/dialecto_rails/usages/lazy_scope.rb +52 -0
- data/lib/dialecto_rails/usages/parse_check.rb +75 -0
- data/lib/dialecto_rails/usages/read_by_name.json +28 -0
- data/lib/dialecto_rails/usages/record.rb +91 -0
- data/lib/dialecto_rails/usages/ruby_visitor.rb +628 -0
- data/lib/dialecto_rails/usages/scanner.rb +241 -0
- data/lib/dialecto_rails/usages/summary.rb +191 -0
- data/lib/dialecto_rails/usages/templates.rb +45 -0
- data/lib/dialecto_rails/usages/wrappers.rb +336 -0
- data/lib/dialecto_rails/version.rb +3 -0
- data/lib/dialecto_rails.rb +14 -0
- 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
|