Current section

Files

Jump to
gettext_mapper lib mix tasks gettext_mapper.sync.ex
Raw

lib/mix/tasks/gettext_mapper.sync.ex

defmodule Mix.Tasks.GettextMapper.Sync do
@shortdoc "Synchronize gettext translations with static translation maps in code"
@moduledoc """
Synchronizes gettext translations with static translation maps in your Elixir code.
This task scans your codebase for `gettext_mapper/1` calls with static maps and
updates them with the latest translations from your gettext .po files.
## Examples
# Sync all files in lib/
mix gettext_mapper.sync
# Sync specific files
mix gettext_mapper.sync lib/my_app/models.ex lib/my_app/views.ex
# Dry run (show what would be changed without modifying files)
mix gettext_mapper.sync --dry-run
# Use specific backend
mix gettext_mapper.sync --backend MyApp.Gettext
# Generate translation map for a specific message (doesn't modify files)
mix gettext_mapper.sync --message "Hello"
## How it works
1. Finds all `gettext_mapper(%{...})` calls with static translation maps
2. Extracts the default locale message from each map
3. Looks up current translations for that message in gettext .po files
4. Updates the static map with the current translations
5. Applies Elixir's built-in formatter to ensure consistent, idiomatic formatting
## Formatting
The task automatically formats the updated translation maps using Elixir's built-in
`Code.format_string!/2` function, ensuring that the output follows your project's
formatting conventions. This means:
- Consistent spacing around `=>` operators
- Proper indentation for multiline maps
- Standard quote style
- Proper alignment and line breaks
## Example transformation
Before:
```elixir
def hello do
gettext_mapper(%{"de" => "Hello!", "en" => "Hello!", "uk" => "Hello!"})
end
```
After (if .po files were updated with "!" suffix):
```elixir
def hello do
gettext_mapper(%{"de" => "Hello!", "en" => "Hello!", "uk" => "Hello!"})
end
```
"""
use Mix.Task
@impl Mix.Task
def run(args) do
Mix.Task.run("compile")
Mix.Task.run("app.start")
{opts, paths, _} =
OptionParser.parse(args,
switches: [dry_run: :boolean, backend: :string, message: :string],
aliases: [d: :dry_run, b: :backend, m: :message]
)
dry_run = Keyword.get(opts, :dry_run, false)
backend = get_backend(opts)
message = Keyword.get(opts, :message)
# If message is provided, just generate the translation map
if message do
generate_single_translation_map(message, backend)
else
# Sync files
files_to_process =
if Enum.empty?(paths) do
find_elixir_files()
else
paths
end
if dry_run do
Mix.shell().info("Running in dry-run mode. No files will be modified.")
end
total_files = length(files_to_process)
updated_count =
Enum.reduce(files_to_process, 0, fn file, acc ->
if process_file(file, backend, dry_run) do
acc + 1
else
acc
end
end)
Mix.shell().info(
"Processed #{total_files} files, updated #{updated_count} files with translation changes."
)
end
end
defp generate_single_translation_map(message, backend) do
try do
default_domain = GettextMapper.GettextAPI.default_domain()
translation_map = generate_translation_map(message, backend, default_domain)
Mix.shell().info("Original message: \"#{message}\"")
Mix.shell().info("Updated translation map:")
Mix.shell().info(format_elixir_map(translation_map))
rescue
error ->
Mix.shell().error("Error: #{Exception.message(error)}")
end
end
defp get_backend(opts) do
case Keyword.get(opts, :backend) do
nil ->
try do
GettextMapper.GettextAPI.gettext_module()
rescue
_error ->
# Try to load test environment
if File.exists?("test/test_helper.exs") do
Code.require_file("test/test_helper.exs")
GettextMapper.GettextAPI.gettext_module()
else
reraise "No gettext backend configured. Use --backend YourApp.Gettext or configure :gettext_mapper, :gettext",
__STACKTRACE__
end
end
backend_string ->
String.to_existing_atom(backend_string)
end
end
defp find_elixir_files do
"lib/**/*.ex"
|> Path.wildcard()
|> Enum.filter(&File.exists?/1)
end
defp process_file(file_path, backend, dry_run) do
content = File.read!(file_path)
updated_content = sync_translation_maps(content, backend)
if content != updated_content do
if dry_run do
Mix.shell().info("Would update: #{file_path}")
show_diff(content, updated_content)
else
File.write!(file_path, updated_content)
Mix.shell().info("Updated: #{file_path}")
end
true
else
false
end
rescue
error ->
Mix.shell().error("Error processing #{file_path}: #{Exception.message(error)}")
false
end
defp sync_translation_maps(content, backend) do
# Split content into lines to check for comments
lines = String.split(content, "\n")
# Regex to find gettext_mapper calls with static maps, optionally with domain
regex = ~r/gettext_mapper\(\s*%\{([^}]+)\}\s*(?:,\s*domain:\s*"([^"]+)")?\s*\)/s
Regex.replace(regex, content, fn full_match, map_content, domain_content ->
# Check if this match is inside a comment
if in_comment?(full_match, content, lines) do
# Return the match unchanged if it's in a comment
full_match
else
domain =
if domain_content != "",
do: domain_content,
else: GettextMapper.GettextAPI.default_domain()
process_translation_map(map_content, domain, backend, full_match)
end
end)
end
defp in_comment?(match_text, full_content, _lines) do
# Find the position of the match in the content
case :binary.match(full_content, match_text) do
{pos, _len} ->
# Check if this match is in a line comment, @moduledoc, or @doc section
line_comment?(full_content, pos) or doc_section?(full_content, pos)
:nomatch ->
false
end
end
defp line_comment?(content, pos) do
# Check if there's a # character before this position in the same line
content_before_match = String.slice(content, 0, pos)
# Find the last newline before the match
last_newline_pos =
content_before_match
|> String.reverse()
|> :binary.match("\n")
|> case do
{reverse_pos, _} -> pos - reverse_pos - 1
:nomatch -> 0
end
line_before_match =
String.slice(content_before_match, last_newline_pos, pos - last_newline_pos)
# Check if there's a # in the line before the match
String.contains?(line_before_match, "#")
end
defp doc_section?(content, pos) do
# Check if the match is inside @moduledoc """ or @doc """ blocks
content_before_match = String.slice(content, 0, pos)
# Find all doc block starts (either @doc """ or @moduledoc """)
doc_starts =
(Regex.scan(~r/@doc\s+"""/, content_before_match, return: :index) ++
Regex.scan(~r/@moduledoc\s+"""/, content_before_match, return: :index))
|> Enum.map(fn [{start, _}] -> start end)
|> Enum.sort()
# Find all standalone """ that are not part of @doc or @moduledoc
all_triple_quotes =
Regex.scan(~r/"""/, content_before_match, return: :index)
|> Enum.map(fn [{start, _}] -> start end)
|> Enum.sort()
# Filter out the """ that are part of @doc/@moduledoc declarations
doc_ends =
Enum.reject(all_triple_quotes, fn quote_pos ->
# Check if this """ is preceded by @doc or @moduledoc on the same or previous lines
Enum.any?(doc_starts, fn doc_start ->
# Rough heuristic for same declaration
abs(quote_pos - doc_start) < 50
end)
end)
# Count how many doc blocks are currently open
open_blocks = length(doc_starts) - length(doc_ends)
# If we have more opening doc blocks than closing """, we're inside a doc block
open_blocks > 0
end
defp process_translation_map(map_content, domain, backend, full_match) do
case parse_translation_map(map_content) do
{:ok, translations} ->
# Try to find the default locale message to look up in gettext
default_locale = get_default_locale(backend)
default_message = Map.get(translations, default_locale)
if default_message do
updated_translations = generate_translation_map(default_message, backend, domain)
format_translation_map_call_with_elixir_formatter(updated_translations, domain)
else
# If no default locale found, try the first English message
english_message = Map.get(translations, "en")
if english_message do
updated_translations = generate_translation_map(english_message, backend, domain)
format_translation_map_call_with_elixir_formatter(updated_translations, domain)
else
# Return original if we can't find a suitable message
full_match
end
end
:error ->
# Return original if parsing fails
full_match
end
end
defp parse_translation_map(map_string) do
try do
# Clean up the map string
cleaned =
map_string
|> String.replace(~r/\s+/, " ")
|> String.trim()
# Parse key-value pairs with various quote styles
pairs = Regex.scan(~r/"([^"]+)"\s*=>\s*"([^"]*)"/, cleaned)
translations =
Enum.into(pairs, %{}, fn [_, key, value] ->
{key, value}
end)
if map_size(translations) > 0 do
{:ok, translations}
else
:error
end
rescue
_ -> :error
end
end
defp get_default_locale(backend) do
try do
backend.__gettext__(:default_locale)
rescue
_ -> "en"
end
end
defp generate_translation_map(message, backend, domain) do
# Discover locales from .po files and read translations directly
known_locales = discover_locales_from_po_files(backend, domain)
Enum.into(known_locales, %{}, fn locale ->
translation = lookup_translation_in_po(backend, locale, domain, message)
{locale, translation}
end)
end
defp lookup_translation_in_po(backend, locale, domain, message) do
# Get the priv directory for the backend
priv_dir =
try do
backend.__gettext__(:priv)
rescue
_ -> "priv/gettext"
end
default_domain = GettextMapper.GettextAPI.default_domain()
po_filename = if domain == default_domain, do: "#{default_domain}.po", else: "#{domain}.po"
po_file_path = Path.join([priv_dir, locale, "LC_MESSAGES", po_filename])
if File.exists?(po_file_path) do
content = File.read!(po_file_path)
extract_translation_from_po_content(content, message)
else
# Fall back to original message if no .po file
message
end
end
defp extract_translation_from_po_content(content, msgid) do
# Look for the msgid and extract the corresponding msgstr
escaped_msgid = Regex.escape(msgid)
pattern = ~r/msgid "#{escaped_msgid}"\s*\nmsgstr "([^"]*)"/s
case Regex.run(pattern, content) do
[_, msgstr] -> msgstr
# Fall back to msgid if not found
_ -> msgid
end
end
defp discover_locales_from_po_files(backend, domain) do
# Get the priv directory for the backend
priv_dir =
try do
backend.__gettext__(:priv)
rescue
_ -> "priv/gettext"
end
default_domain = GettextMapper.GettextAPI.default_domain()
po_filename = if domain == default_domain, do: "#{default_domain}.po", else: "#{domain}.po"
# Look for locale directories with the domain .po file
if File.exists?(priv_dir) do
priv_dir
|> File.ls!()
|> Enum.filter(fn item ->
locale_dir = Path.join(priv_dir, item)
po_file = Path.join([locale_dir, "LC_MESSAGES", po_filename])
File.dir?(locale_dir) && File.exists?(po_file)
end)
else
# Fallback to default if no priv dir exists
[GettextMapper.GettextAPI.default_locale()]
end
end
# Format using Elixir's built-in formatter for consistent, idiomatic formatting
defp format_translation_map_call_with_elixir_formatter(translations, domain) do
default_domain = GettextMapper.GettextAPI.default_domain()
# Create unformatted string with translations
entries =
Enum.map(translations, fn {locale, translation} ->
~s("#{locale}" => "#{escape_string(translation)}")
end)
# Build the basic call
unformatted =
if domain == default_domain do
"gettext_mapper(%{#{Enum.join(entries, ", ")}})"
else
"gettext_mapper(%{#{Enum.join(entries, ", ")}}, domain: \"#{domain}\")"
end
# Use Elixir's formatter for consistent formatting
try do
Code.format_string!(unformatted) |> to_string()
rescue
_ ->
# Fallback to unformatted if formatting fails
unformatted
end
end
defp format_elixir_map(map) do
entries =
Enum.map(map, fn {locale, translation} ->
~s("#{locale}" => "#{escape_string(translation)}")
end)
"%{#{Enum.join(entries, ", ")}}"
end
defp escape_string(str) do
str
|> String.replace("\\", "\\\\")
|> String.replace("\"", "\\\"")
end
defp show_diff(original, updated) do
# Simple diff display - shows only changed lines
original_lines = String.split(original, "\n")
updated_lines = String.split(updated, "\n")
Enum.with_index(original_lines)
|> Enum.each(fn {line, index} ->
updated_line = Enum.at(updated_lines, index, "")
if line != updated_line do
Mix.shell().info(" - #{line}")
Mix.shell().info(" + #{updated_line}")
end
end)
end
end