Skip to content

Commit 5ed8c5a

Browse files
authored
Prism: Supports candidate_keys for each occurrence (#664)
1 parent 0688701 commit 5ed8c5a

File tree

7 files changed

+137
-31
lines changed

7 files changed

+137
-31
lines changed

CHANGES.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ to use the Prism Scanner without Rails support.
2323
- This should not affect the results of the CLI tasks.
2424
- Loads environment variables via `dotenv` if available. [#395](https://github.com/glebm/i18n-tasks/issues/395)
2525
- Adds CLI command `check_prism` to try the new parser out and see the differences in key detection.
26+
- The Prism-based scanner supports candidate_keys for Rails translations, allowing relative translations in controllers to match either the key scoped to controller and action or only to the controller.
2627

2728
## v1.0.15
2829

lib/i18n/tasks/missing_keys.rb

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,8 +108,24 @@ def missing_diff_tree(locale, compared_to = base_locale)
108108

109109
# keys used in the code missing translations in locale
110110
def missing_used_tree(locale)
111-
used_tree(strict: true).select_keys do |key, _node|
112-
locale_key_missing?(locale, key)
111+
used_tree(strict: true).select_keys do |key, node|
112+
occurrences = node.data[:occurrences] || []
113+
114+
# An occurrence may carry candidate keys (for relative lookups). If any
115+
# candidate key exists in the locale, the usage is considered present.
116+
occurrences_all_missing = occurrences.all? do |occ|
117+
candidates = if occ.respond_to?(:candidate_keys) && occ.candidate_keys.present?
118+
occ.candidate_keys
119+
else
120+
# fallback to the scanned key
121+
[key]
122+
end
123+
124+
# Occurrence is missing iff all its candidates are missing
125+
candidates.all? { |c| locale_key_missing?(locale, c) }
126+
end
127+
128+
occurrences_all_missing
113129
end.set_root_key!(locale, type: :missing_used)
114130
end
115131

lib/i18n/tasks/scanners/prism_scanners/nodes.rb

Lines changed: 40 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -102,25 +102,36 @@ def occurrences(file_path)
102102
occurrence(file_path)
103103
end
104104

105+
# Returns either a single key string or an array of candidate key strings for this call.
105106
def full_key
106107
return nil if key.nil?
107108
return nil unless key.is_a?(String)
108109
return nil if relative_key? && !support_relative_keys?
109110

110-
parts = [scope]
111+
base_parts = [scope].compact
111112

112113
if relative_key?
113-
parts.concat(parent&.path || [])
114-
parts << key
114+
# For relative keys in controllers/methods, generate candidate keys by
115+
# progressively stripping trailing path segments from the parent path.
116+
# Example: parent.path = ["events", "create"], key = ".success"
117+
# yields: ["events.create.success", "events.success"]
118+
parent_path = parent&.path || []
119+
rel_key = key[1..] # strip leading dot # rubocop:disable Performance/ArraySemiInfiniteRangeSlice
120+
121+
candidates = []
122+
parent_path_length = parent_path.length
123+
# Do not generate an unscoped bare key (keep_count = 0). Start from full parent path
124+
parent_path_length.downto(1) do |keep_count|
125+
parts = base_parts + parent_path.first(keep_count) + [rel_key]
126+
candidates << parts.compact.join(".")
127+
end
115128

116-
# TODO: Fallback to controller without action name
129+
candidates.map { |c| c.gsub("..", ".") }
117130
elsif key.start_with?(".")
118-
parts << key[1..] # rubocop:disable Performance/ArraySemiInfiniteRangeSlice
131+
[base_parts + [key[1..]]].flatten.compact.join(".").gsub("..", ".") # rubocop:disable Performance/ArraySemiInfiniteRangeSlice,Performance/ChainArrayAllocation
119132
else
120-
parts << key
133+
[base_parts + [key]].flatten.compact.join(".").gsub("..", ".") # rubocop:disable Performance/ChainArrayAllocation
121134
end
122-
123-
parts.compact.join(".").gsub("..", ".")
124135
end
125136

126137
private
@@ -139,20 +150,27 @@ def occurrence(file_path)
139150

140151
location = local_node.location
141152

142-
final_key = full_key
143-
return nil if final_key.nil?
144-
145-
[
146-
final_key,
147-
::I18n::Tasks::Scanners::Results::Occurrence.new(
148-
path: file_path,
149-
line: local_node.respond_to?(:slice) ? local_node.slice : local_node.location.slice,
150-
pos: location.start_offset,
151-
line_pos: location.start_column,
152-
line_num: location.start_line,
153-
raw_key: key
154-
)
155-
]
153+
final = full_key
154+
return nil if final.nil?
155+
156+
occurrence = ::I18n::Tasks::Scanners::Results::Occurrence.new(
157+
path: file_path,
158+
line: local_node.respond_to?(:slice) ? local_node.slice : local_node.location.slice,
159+
pos: location.start_offset,
160+
line_pos: location.start_column,
161+
line_num: location.start_line,
162+
raw_key: key
163+
)
164+
165+
# full_key may be a single String or an Array of candidate strings
166+
if final.is_a?(Array)
167+
# record candidate keys on the occurrence (first candidate is the primary)
168+
occurrence.instance_variable_set(:@candidate_keys, final)
169+
[final.first, occurrence]
170+
else
171+
occurrence.instance_variable_set(:@candidate_keys, [final])
172+
[final, occurrence]
173+
end
156174
rescue ScopeError
157175
nil
158176
end

lib/i18n/tasks/scanners/results/occurrence.rb

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ class Occurrence
2828
# @return [String, nil] the raw key (for relative keys and references)
2929
attr_accessor :raw_key
3030

31+
# @return [Array<String>, nil] candidate keys that may be used at runtime
32+
attr_reader :candidate_keys
33+
3134
# @param path [String]
3235
# @param pos [Integer]
3336
# @param line_num [Integer]
@@ -36,32 +39,33 @@ class Occurrence
3639
# @param raw_key [String, nil]
3740
# @param default_arg [String, nil]
3841
# rubocop:disable Metrics/ParameterLists
39-
def initialize(path:, pos:, line_num:, line_pos:, line:, raw_key: nil, default_arg: nil)
42+
def initialize(path:, pos:, line_num:, line_pos:, line:, raw_key: nil, default_arg: nil, candidate_keys: nil)
4043
@path = path
4144
@pos = pos
4245
@line_num = line_num
4346
@line_pos = line_pos
4447
@line = line
4548
@raw_key = raw_key
4649
@default_arg = default_arg
50+
@candidate_keys = candidate_keys
4751
end
4852
# rubocop:enable Metrics/ParameterLists
4953

5054
def inspect
51-
"Occurrence(#{@path}:#{@line_num}, line_pos: #{@line_pos}, pos: #{@pos}, raw_key: #{@raw_key}, default_arg: #{@default_arg}, line: #{@line})" # rubocop:disable Layout/LineLength
55+
"Occurrence(#{@path}:#{@line_num}, line_pos: #{@line_pos}, pos: #{@pos}, raw_key: #{@raw_key}, candidate_keys: #{@candidate_keys}, default_arg: #{@default_arg}, line: #{@line})" # rubocop:disable Layout/LineLength
5256
end
5357

5458
def ==(other)
5559
other.path == @path && other.pos == @pos && other.line_num == @line_num && other.line == @line &&
56-
other.raw_key == @raw_key && other.default_arg == @default_arg
60+
other.raw_key == @raw_key && other.default_arg == @default_arg && other.candidate_keys == @candidate_keys
5761
end
5862

5963
def eql?(other)
6064
self == other
6165
end
6266

6367
def hash
64-
[@path, @pos, @line_num, @line_pos, @line, @default_arg].hash
68+
[@path, @pos, @line_num, @line_pos, @line, @default_arg, @candidate_keys].hash
6569
end
6670

6771
# @param raw_key [String]

lib/i18n/tasks/scanners/ruby_scanner.rb

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,13 @@ def comments_to_occurences(path, ast, comments)
9090
nil,
9191
location: associated_node || comment.location
9292
)
93-
results << result if result
93+
next unless result
94+
95+
if result.is_a?(Array) && result.first.is_a?(Array)
96+
results.concat(result)
97+
else
98+
results << result
99+
end
94100
end
95101
end
96102

@@ -108,7 +114,13 @@ def ast_to_occurences(ast)
108114
calls.each do |send_node, method_name|
109115
@matchers.each do |matcher|
110116
result = matcher.convert_to_key_occurrences(send_node, method_name)
111-
results << result if result
117+
next unless result
118+
119+
if result.is_a?(Array) && result.first.is_a?(Array)
120+
results.concat(result)
121+
else
122+
results << result
123+
end
112124
end
113125
end
114126

@@ -176,7 +188,13 @@ def process_prism_results(path, parse_results)
176188
occurrences = []
177189
visitor.process.each do |translation_call|
178190
result = translation_call.occurrences(path)
179-
occurrences << result if result
191+
next unless result
192+
193+
if result.is_a?(Array) && result.first.is_a?(Array)
194+
occurrences.concat(result)
195+
else
196+
occurrences << result
197+
end
180198
end
181199

182200
occurrences

spec/missing_keys_spec.rb

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,4 +71,29 @@ def configuration_from(locale)
7171
end
7272
end
7373
end
74+
75+
describe "candidate keys in occurrences" do
76+
it "does not report a usage missing if any candidate key exists in locale" do
77+
i18n = I18n::Tasks::BaseTask.new
78+
79+
# simulate that locale 'en' contains 'events.success' but not 'events.create.success'
80+
allow(i18n).to receive(:key_value?) do |key, locale|
81+
key == "events.success"
82+
end
83+
allow(i18n).to receive(:external_key?).and_return(false)
84+
85+
# Create an occurrence and attach candidate keys like the Prism scanner would
86+
occ = make_occurrence(path: "app/controllers/events_controller.rb", line: "t('.success')", line_num: 10, raw_key: ".success")
87+
occ.instance_variable_set(:@candidate_keys, ["events.create.success", "events.success"])
88+
89+
key_occ = ::I18n::Tasks::Scanners::Results::KeyOccurrences.new(key: "events.create.success", occurrences: [occ])
90+
91+
# Stub the scanner to return our key occurrence
92+
allow(i18n).to receive_messages(external_key?: false, scanner: double(keys: [key_occ])) # rubocop:disable RSpec/VerifiedDoubles
93+
94+
missing = i18n.missing_used_forest(%w[en])
95+
96+
expect(missing.leaves.to_a).to be_empty
97+
end
98+
end
7499
end

spec/prism_scanner_spec.rb

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,30 @@ def method_in_before_action2
6666
)
6767
end
6868

69+
it "controller - relative key" do
70+
source = <<~RUBY
71+
class EventsController < ApplicationController
72+
def create
73+
t('.relative_key')
74+
end
75+
end
76+
RUBY
77+
78+
occurrences =
79+
process_string("app/controllers/events_controller.rb", source)
80+
expect(occurrences.map(&:first).uniq).to match_array(
81+
%w[events.create.relative_key]
82+
)
83+
84+
# Check candidate_keys
85+
expect(occurrences.map { |o| o.last.candidate_keys }.flatten.uniq).to match_array(
86+
%w[
87+
events.create.relative_key
88+
events.relative_key
89+
]
90+
)
91+
end
92+
6993
it "empty controller" do
7094
source = <<~RUBY
7195
class ApplicationController < ActionController::Base

0 commit comments

Comments
 (0)