Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ to use the Prism Scanner without Rails support.
- This should not affect the results of the CLI tasks.
- Loads environment variables via `dotenv` if available. [#395](https://github.com/glebm/i18n-tasks/issues/395)
- Adds CLI command `check_prism` to try the new parser out and see the differences in key detection.
- 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.

## v1.0.15

Expand Down
20 changes: 18 additions & 2 deletions lib/i18n/tasks/missing_keys.rb
Original file line number Diff line number Diff line change
Expand Up @@ -108,8 +108,24 @@ def missing_diff_tree(locale, compared_to = base_locale)

# keys used in the code missing translations in locale
def missing_used_tree(locale)
used_tree(strict: true).select_keys do |key, _node|
locale_key_missing?(locale, key)
used_tree(strict: true).select_keys do |key, node|
occurrences = node.data[:occurrences] || []

# An occurrence may carry candidate keys (for relative lookups). If any
# candidate key exists in the locale, the usage is considered present.
occurrences_all_missing = occurrences.all? do |occ|
candidates = if occ.respond_to?(:candidate_keys) && occ.candidate_keys.present?
occ.candidate_keys
else
# fallback to the scanned key
[key]
end

# Occurrence is missing iff all its candidates are missing
candidates.all? { |c| locale_key_missing?(locale, c) }
end

occurrences_all_missing
end.set_root_key!(locale, type: :missing_used)
end

Expand Down
62 changes: 40 additions & 22 deletions lib/i18n/tasks/scanners/prism_scanners/nodes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -98,25 +98,36 @@ def occurrences(file_path)
occurrence(file_path)
end

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

parts = [scope]
base_parts = [scope].compact

if relative_key?
parts.concat(parent&.path || [])
parts << key
# For relative keys in controllers/methods, generate candidate keys by
# progressively stripping trailing path segments from the parent path.
# Example: parent.path = ["events", "create"], key = ".success"
# yields: ["events.create.success", "events.success"]
parent_path = parent&.path || []
rel_key = key[1..] # strip leading dot # rubocop:disable Performance/ArraySemiInfiniteRangeSlice

candidates = []
parent_path_length = parent_path.length
# Do not generate an unscoped bare key (keep_count = 0). Start from full parent path
parent_path_length.downto(1) do |keep_count|
parts = base_parts + parent_path.first(keep_count) + [rel_key]
candidates << parts.compact.join(".")
end

# TODO: Fallback to controller without action name
candidates.map { |c| c.gsub("..", ".") }
elsif key.start_with?(".")
parts << key[1..] # rubocop:disable Performance/ArraySemiInfiniteRangeSlice
[base_parts + [key[1..]]].flatten.compact.join(".").gsub("..", ".") # rubocop:disable Performance/ArraySemiInfiniteRangeSlice,Performance/ChainArrayAllocation
else
parts << key
[base_parts + [key]].flatten.compact.join(".").gsub("..", ".") # rubocop:disable Performance/ChainArrayAllocation
end

parts.compact.join(".").gsub("..", ".")
end

private
Expand All @@ -135,20 +146,27 @@ def occurrence(file_path)

location = local_node.location

final_key = full_key
return nil if final_key.nil?

[
final_key,
::I18n::Tasks::Scanners::Results::Occurrence.new(
path: file_path,
line: local_node.respond_to?(:slice) ? local_node.slice : local_node.location.slice,
pos: location.start_offset,
line_pos: location.start_column,
line_num: location.start_line,
raw_key: key
)
]
final = full_key
return nil if final.nil?

occurrence = ::I18n::Tasks::Scanners::Results::Occurrence.new(
path: file_path,
line: local_node.respond_to?(:slice) ? local_node.slice : local_node.location.slice,
pos: location.start_offset,
line_pos: location.start_column,
line_num: location.start_line,
raw_key: key
)

# full_key may be a single String or an Array of candidate strings
if final.is_a?(Array)
# record candidate keys on the occurrence (first candidate is the primary)
occurrence.instance_variable_set(:@candidate_keys, final)
[final.first, occurrence]
else
occurrence.instance_variable_set(:@candidate_keys, [final])
[final, occurrence]
end
rescue ScopeError
nil
end
Expand Down
12 changes: 8 additions & 4 deletions lib/i18n/tasks/scanners/results/occurrence.rb
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ class Occurrence
# @return [String, nil] the raw key (for relative keys and references)
attr_accessor :raw_key

# @return [Array<String>, nil] candidate keys that may be used at runtime
attr_reader :candidate_keys

# @param path [String]
# @param pos [Integer]
# @param line_num [Integer]
Expand All @@ -36,32 +39,33 @@ class Occurrence
# @param raw_key [String, nil]
# @param default_arg [String, nil]
# rubocop:disable Metrics/ParameterLists
def initialize(path:, pos:, line_num:, line_pos:, line:, raw_key: nil, default_arg: nil)
def initialize(path:, pos:, line_num:, line_pos:, line:, raw_key: nil, default_arg: nil, candidate_keys: nil)
@path = path
@pos = pos
@line_num = line_num
@line_pos = line_pos
@line = line
@raw_key = raw_key
@default_arg = default_arg
@candidate_keys = candidate_keys
end
# rubocop:enable Metrics/ParameterLists

def inspect
"Occurrence(#{@path}:#{@line_num}, line_pos: #{@line_pos}, pos: #{@pos}, raw_key: #{@raw_key}, default_arg: #{@default_arg}, line: #{@line})" # rubocop:disable Layout/LineLength
"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
end

def ==(other)
other.path == @path && other.pos == @pos && other.line_num == @line_num && other.line == @line &&
other.raw_key == @raw_key && other.default_arg == @default_arg
other.raw_key == @raw_key && other.default_arg == @default_arg && other.candidate_keys == @candidate_keys
end

def eql?(other)
self == other
end

def hash
[@path, @pos, @line_num, @line_pos, @line, @default_arg].hash
[@path, @pos, @line_num, @line_pos, @line, @default_arg, @candidate_keys].hash
end

# @param raw_key [String]
Expand Down
24 changes: 21 additions & 3 deletions lib/i18n/tasks/scanners/ruby_scanner.rb
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,13 @@ def comments_to_occurences(path, ast, comments)
nil,
location: associated_node || comment.location
)
results << result if result
next unless result

if result.is_a?(Array) && result.first.is_a?(Array)
results.concat(result)
else
results << result
end
end
end

Expand All @@ -108,7 +114,13 @@ def ast_to_occurences(ast)
calls.each do |send_node, method_name|
@matchers.each do |matcher|
result = matcher.convert_to_key_occurrences(send_node, method_name)
results << result if result
next unless result

if result.is_a?(Array) && result.first.is_a?(Array)
results.concat(result)
else
results << result
end
end
end

Expand Down Expand Up @@ -176,7 +188,13 @@ def process_prism_results(path, parse_results)
occurrences = []
visitor.process.each do |translation_call|
result = translation_call.occurrences(path)
occurrences << result if result
next unless result

if result.is_a?(Array) && result.first.is_a?(Array)
occurrences.concat(result)
else
occurrences << result
end
end

occurrences
Expand Down
25 changes: 25 additions & 0 deletions spec/missing_keys_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -71,4 +71,29 @@ def configuration_from(locale)
end
end
end

describe "candidate keys in occurrences" do
it "does not report a usage missing if any candidate key exists in locale" do
i18n = I18n::Tasks::BaseTask.new

# simulate that locale 'en' contains 'events.success' but not 'events.create.success'
allow(i18n).to receive(:key_value?) do |key, locale|
key == "events.success"
end
allow(i18n).to receive(:external_key?).and_return(false)

# Create an occurrence and attach candidate keys like the Prism scanner would
occ = make_occurrence(path: "app/controllers/events_controller.rb", line: "t('.success')", line_num: 10, raw_key: ".success")
occ.instance_variable_set(:@candidate_keys, ["events.create.success", "events.success"])

key_occ = ::I18n::Tasks::Scanners::Results::KeyOccurrences.new(key: "events.create.success", occurrences: [occ])

# Stub the scanner to return our key occurrence
allow(i18n).to receive_messages(external_key?: false, scanner: double(keys: [key_occ])) # rubocop:disable RSpec/VerifiedDoubles

missing = i18n.missing_used_forest(%w[en])

expect(missing.leaves.to_a).to be_empty
end
end
end
24 changes: 24 additions & 0 deletions spec/prism_scanner_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,30 @@ def method_in_before_action2
)
end

it "controller - relative key" do
source = <<~RUBY
class EventsController < ApplicationController
def create
t('.relative_key')
end
end
RUBY

occurrences =
process_string("app/controllers/events_controller.rb", source)
expect(occurrences.map(&:first).uniq).to match_array(
%w[events.create.relative_key]
)

# Check candidate_keys
expect(occurrences.map { |o| o.last.candidate_keys }.flatten.uniq).to match_array(
%w[
events.create.relative_key
events.relative_key
]
)
end

it "empty controller" do
source = <<~RUBY
class ApplicationController < ActionController::Base
Expand Down
Loading