diff --git a/lib/ruby_lsp/ruby_lsp_rails/addon.rb b/lib/ruby_lsp/ruby_lsp_rails/addon.rb index 9001cf6b..84b6f3a1 100644 --- a/lib/ruby_lsp/ruby_lsp_rails/addon.rb +++ b/lib/ruby_lsp/ruby_lsp_rails/addon.rb @@ -149,6 +149,10 @@ def workspace_did_change_watched_files(changes) offer_to_run_pending_migrations end + + if changes.any? { |c| %r{config/locales/.*\.yml}.match?(c[:uri]) } + @rails_runner_client.trigger_i18n_reload + end end # @override @@ -230,7 +234,7 @@ def register_additional_file_watchers(global_state:, outgoing_queue:) id: "workspace/didChangeWatchedFilesRails", method: "workspace/didChangeWatchedFiles", register_options: Interface::DidChangeWatchedFilesRegistrationOptions.new( - watchers: [structure_sql_file_watcher, fixture_file_watcher], + watchers: [structure_sql_file_watcher, fixture_file_watcher, i18n_file_watcher], ), ), ], @@ -254,6 +258,14 @@ def fixture_file_watcher ) end + #: -> Interface::FileSystemWatcher + def i18n_file_watcher + Interface::FileSystemWatcher.new( + glob_pattern: "**/config/locales/**/*.{yml,yaml}", + kind: Constant::WatchKind::CREATE | Constant::WatchKind::CHANGE | Constant::WatchKind::DELETE, + ) + end + #: -> void def offer_to_run_pending_migrations return unless @outgoing_queue diff --git a/lib/ruby_lsp/ruby_lsp_rails/hover.rb b/lib/ruby_lsp/ruby_lsp_rails/hover.rb index 2bd35379..40c59e67 100644 --- a/lib/ruby_lsp/ruby_lsp_rails/hover.rb +++ b/lib/ruby_lsp/ruby_lsp_rails/hover.rb @@ -29,6 +29,7 @@ def initialize(client, response_builder, node_context, global_state, dispatcher) :on_constant_path_node_enter, :on_constant_read_node_enter, :on_symbol_node_enter, + :on_string_node_enter, ) end @@ -56,6 +57,11 @@ def on_symbol_node_enter(node) handle_possible_dsl(node) end + #: (Prism::StringNode node) -> void + def on_string_node_enter(node) + handle_possible_i18n(node) + end + private #: (String name) -> void @@ -148,6 +154,31 @@ def handle_association(node) generate_hover(result[:name]) end + #: (Prism::StringNode node) -> void + def handle_possible_i18n(node) + call_node = @node_context.call_node + return unless call_node + + receiver = call_node.receiver + return unless receiver.is_a?(Prism::ConstantReadNode) + return unless receiver.name == :I18n + + message = call_node.message + return unless message == "t" + + first_argument = call_node.arguments&.arguments&.first + return unless first_argument.is_a?(Prism::StringNode) + return unless first_argument == node + + i18n_key = first_argument.unescaped + return if i18n_key.empty? + + result = @client.i18n(i18n_key) + return unless result + + generate_i18n_hover(result) + end + # Copied from `RubyLsp::Listeners::Hover#generate_hover` #: (String name) -> void def generate_hover(name) @@ -164,6 +195,16 @@ def generate_hover(name) @response_builder.push(content, category: category) end end + + #: (Hash[Symbol, String] translations) -> void + def generate_i18n_hover(translations) + content = translations.map { |lang, translation| "#{lang}: #{translation}" }.join("\n") + content = "```yaml\n#{content}\n```" + @response_builder.push( + content, + category: :documentation, + ) + end end end end diff --git a/lib/ruby_lsp/ruby_lsp_rails/runner_client.rb b/lib/ruby_lsp/ruby_lsp_rails/runner_client.rb index 315224a3..abb6418f 100644 --- a/lib/ruby_lsp/ruby_lsp_rails/runner_client.rb +++ b/lib/ruby_lsp/ruby_lsp_rails/runner_client.rb @@ -180,6 +180,17 @@ def route(controller:, action:) nil end + #: (String key) -> Hash[Symbol, untyped]? + def i18n(key) + make_request("i18n", key: key) + rescue MessageError + log_message( + "Ruby LSP Rails failed to get i18n information", + type: RubyLsp::Constant::MessageType::ERROR, + ) + nil + end + # Delegates a notification to a server add-on #: (server_addon_name: String, request_name: String, **untyped params) -> void def delegate_notification(server_addon_name:, request_name:, **params) @@ -239,6 +250,18 @@ def trigger_reload nil end + #: -> void + def trigger_i18n_reload + log_message("Reloading I18n translations") + send_notification("reload_i18n") + rescue MessageError + log_message( + "Ruby LSP Rails failed to trigger I18n reload", + type: RubyLsp::Constant::MessageType::ERROR, + ) + nil + end + #: -> void def shutdown log_message("Ruby LSP Rails shutting down server") diff --git a/lib/ruby_lsp/ruby_lsp_rails/server.rb b/lib/ruby_lsp/ruby_lsp_rails/server.rb index 1fc3b9f4..144cb0ce 100644 --- a/lib/ruby_lsp/ruby_lsp_rails/server.rb +++ b/lib/ruby_lsp/ruby_lsp_rails/server.rb @@ -332,6 +332,17 @@ def execute(request, params) with_request_error_handling(request) do send_result(resolve_route_info(params)) end + when "i18n" + with_request_error_handling(request) do + result = resolve_i18n_key(params.fetch(:key)) + send_result(result) + end + when "reload_i18n" + with_progress("rails-reload-i18n", "Reloading Ruby LSP Rails I18n") do + with_notification_error_handling(request) do + I18n.reload! if defined?(I18n) && I18n.respond_to?(:reload!) + end + end when "server_addon/register" with_notification_error_handling(request) do require params[:server_addon_path] @@ -525,6 +536,16 @@ def database_supports_indexing?(model) rescue NotImplementedError @database_supports_indexing = false end + + #: (String) -> Hash[Symbol, String] + def resolve_i18n_key(key) + locales = I18n.available_locales + result = {} + locales.each.map do |locale| + result[locale] = I18n.t(key, locale: locale, default: "⚠️ translation missing") + end + result + end end end end diff --git a/test/dummy/app/models/user.rb b/test/dummy/app/models/user.rb index c696c86d..5c120091 100644 --- a/test/dummy/app/models/user.rb +++ b/test/dummy/app/models/user.rb @@ -16,4 +16,8 @@ class User < ApplicationRecord def foo puts "test" end + + def hello + I18n.t("hello") + end end diff --git a/test/dummy/config/locales/es.yml b/test/dummy/config/locales/es.yml new file mode 100644 index 00000000..31fe35de --- /dev/null +++ b/test/dummy/config/locales/es.yml @@ -0,0 +1,3 @@ +--- +es: + hello: "Hola mundo" diff --git a/test/dummy/config/locales/fr.yml b/test/dummy/config/locales/fr.yml new file mode 100644 index 00000000..6ed9bf77 --- /dev/null +++ b/test/dummy/config/locales/fr.yml @@ -0,0 +1,3 @@ +--- +fr: + hello: "Bonjour le monde" diff --git a/test/ruby_lsp_rails/hover_test.rb b/test/ruby_lsp_rails/hover_test.rb index e9bca9e4..86825ce2 100644 --- a/test/ruby_lsp_rails/hover_test.rb +++ b/test/ruby_lsp_rails/hover_test.rb @@ -321,6 +321,28 @@ class Bar < ApplicationRecord CONTENT end + test "returns I18n translation information" do + expected_response = { + en: "hello", + es: "hola", + fr: "bonjour", + } + + RunnerClient.any_instance.stubs(i18n: expected_response) + + response = hover_on_source(<<~RUBY, { line: 0, character: 9 }) + I18n.t("foo") + RUBY + + assert_equal(<<~CONTENT.chomp, response.contents.value) + ```yaml + en: hello + es: hola + fr: bonjour + ``` + CONTENT + end + private def hover_on_source(source, position) diff --git a/test/ruby_lsp_rails/runner_client_test.rb b/test/ruby_lsp_rails/runner_client_test.rb index 0e80a3d3..03fc7300 100644 --- a/test/ruby_lsp_rails/runner_client_test.rb +++ b/test/ruby_lsp_rails/runner_client_test.rb @@ -66,6 +66,24 @@ class RunnerClientTest < ActiveSupport::TestCase assert_match(%r{db/schema\.rb$}, response.fetch(:schema_file)) end + test "#i18n returns translations for the requested key" do + RunnerClient.any_instance.stubs(model: "hello") + translations = @client.i18n("hello") #: as !nil + assert_instance_of(Hash, translations) + assert_equal("Hello world", translations[:en]) + assert_equal("Hola mundo", translations[:es]) + assert_equal("Bonjour le monde", translations[:fr]) + end + + test "#i18n returns translation missing for a key" do + RunnerClient.any_instance.stubs(model: "hello") + translations = @client.i18n("missing_key") #: as !nil + assert_instance_of(Hash, translations) + assert_equal("⚠️ translation missing", translations[:en]) + assert_equal("⚠️ translation missing", translations[:es]) + assert_equal("⚠️ translation missing", translations[:fr]) + end + test "returns nil if the request returns a nil response" do assert_nil @client.model("ApplicationRecord") # ApplicationRecord is abstract end