Skip to content

Commit

Permalink
Merge pull request #4 from change/wbarrett/plan-dies-due-to-contact-w…
Browse files Browse the repository at this point in the history
…ith-reality

Add Linguist.MemorizedVocabulary module
  • Loading branch information
willbarrett authored Apr 24, 2018
2 parents e642a51 + e1e4c56 commit 3cf9b44
Show file tree
Hide file tree
Showing 8 changed files with 228 additions and 4 deletions.
2 changes: 2 additions & 0 deletions config/config.exs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
use Mix.Config

config :linguist, pluralization_key: :count
config :ex_cldr,
locales: ["fr", "en", "es"]
3 changes: 3 additions & 0 deletions lib/linguist/compiler.ex
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ defmodule Linguist.Compiler do
(?<!%) %{.+?}
(?<tail>)
/x
def interpol_rgx do
@interpol_rgx
end

@escaped_interpol_rgx ~r/%%{/
@simple_interpol "%{"
Expand Down
173 changes: 173 additions & 0 deletions lib/linguist/memorized_vocabulary.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
defmodule Linguist.MemorizedVocabulary do
alias Linguist.Compiler
alias Linguist.NoTranslationError
alias Cldr.Number.Cardinal

defmodule TranslationDecodeError do
defexception [:message]
end

@moduledoc """
Defines lookup functions for given translation locales, binding interopolation
Locales are defined with the `locale/2` function, accepting a locale name and
a String path to evaluate for the translations list.
For example, given the following translations :
locale "en", [
flash: [
notice: [
hello: "hello %{first} %{last}",
]
],
users: [
title: "Users",
]
]
locale "fr", Path.join([__DIR__, "fr.exs"])
this module will respond to these functions :
t("en", "flash.notice.hello", bindings \\ []), do: # ...
t("en", "users.title", bindings \\ []), do: # ...
t("fr", "flash.notice.hello", bindings \\ []), do: # ...
"""

def t(locale, path, bindings \\ []) do
pluralization_key = Application.fetch_env!(:linguist, :pluralization_key)
if Keyword.has_key?(bindings, pluralization_key) do
plural_atom =
Cardinal.plural_rule(
Keyword.get(bindings, pluralization_key),
locale
)

do_t(locale, "#{path}.#{plural_atom}", bindings)
else
do_t(locale, path, bindings)
end
end

def t!(locale, path, bindings \\ []) do
case t(locale, path, bindings) do
{:ok, translation} -> translation
{:error, :no_translation} ->
raise %NoTranslationError{message: "#{locale}: #{path}"}
end
end

defp do_t(locale, translation_key, bindings) do
case :ets.lookup(:translations_registry, "#{locale}.#{translation_key}") do
[] -> {:error, :no_translation}
[{_, string}] ->
translation =
Compiler.interpol_rgx()
|> Regex.split(string, on: [:head, :tail])
|> Enum.reduce("", fn
<<"%{" <> rest>>, acc ->
key = String.to_atom(String.trim_trailing(rest, "}"))

acc <> to_string(Keyword.fetch!(bindings, key))
segment, acc ->
acc <> segment
end)
{:ok, translation}
end
end

def locales do
tuple = :ets.lookup(:translations_registry, "memorized_vocabulary.locales")
|> List.first()
if tuple do
elem(tuple, 1)
end
end

def add_locale(name) do
current_locales = locales() || []
:ets.insert(:translations_registry, {"memorized_vocabulary.locales", [name | current_locales]})
end

def update_translations(locale_name, loaded_source) do
loaded_source
|> Enum.map(fn({key, translation_string}) ->
:ets.insert(:translations_registry, {"#{locale_name}.#{key}", translation_string})
end)
end

@doc """
Embeds locales from provided source
* name - The String name of the locale, ie "en", "fr"
* source - The String file path to load YAML from that returns a structured list of translations
Examples
locale "es", Path.join([__DIR__, "es.yml"])
"""
def locale(name, source) do
loaded_source = Linguist.MemorizedVocabulary._load_yaml_file(source)
update_translations(name, loaded_source)
add_locale(name)
end

@doc """
Function used internally to load a yaml file. Please use
the `locale` macro with a path to a yaml file - this function
will not work as expected if called directly.
"""
def _load_yaml_file(source) do
if :ets.info(:translations_registry) == :undefined do
:ets.new(:translations_registry, [:named_table, :set, :protected])
end

{decode_status, [file_data]} = Yomel.decode_file(source)
if decode_status != :ok do
raise %TranslationDecodeError{message: "Decode failed for file #{source}"}
end

%{paths: paths} = file_data
|> Enum.reduce(%{paths: %{}, current_prefix: ""}, &Linguist.MemorizedVocabulary._yaml_reducer/2)
paths
end

@doc """
Recursive function used internally for loading yaml files.
Not intended for external use
"""
def _yaml_reducer({key, value}, acc) when is_binary(value) do
key_name = if acc.current_prefix == "" do
key
else
"#{acc.current_prefix}.#{key}"
end

%{
paths: Map.put(acc.paths, key_name, value),
current_prefix: acc.current_prefix
}
end
def _yaml_reducer({key, value}, acc) do
next_prefix = if acc.current_prefix == "" do
key
else
"#{acc.current_prefix}.#{key}"
end

reduced = Enum.reduce(
value,
%{
paths: acc.paths,
current_prefix: next_prefix
},
&Linguist.MemorizedVocabulary._yaml_reducer/2
)

%{
paths: Map.merge(acc.paths, reduced.paths),
current_prefix: acc.current_prefix
}
end
end
3 changes: 2 additions & 1 deletion lib/linguist/vocabulary.ex
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,8 @@ defmodule Linguist.Vocabulary do
will not work as expected if called directly.
"""
def _load_yaml_file(source) do
YamlElixir.read_from_file!(source)
{:ok, [result]} = Yomel.decode_file(source)
result
|> Enum.reduce([], &Linguist.Vocabulary._yaml_reducer/2)
end

Expand Down
6 changes: 4 additions & 2 deletions mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ defmodule Linguist.Mixfile do
[
app: :linguist,
version: "0.1.5",
compilers: Mix.compilers ++ [:cldr],
elixir: "~> 1.6",
deps: deps(),
package: [
Expand All @@ -21,14 +22,15 @@ defmodule Linguist.Mixfile do
end

def application do
[applications: [:yaml_elixir]]
[applications: []]
end

defp deps do
[
{:ex_cldr, "~> 1.5"},
{:jason, "~> 1.0"},
{:yaml_elixir, "~> 2.0"}
{:yomel, "~> 0.5"},
{:credo, "~> 0.9.0", only: [:dev, :test], runtime: false}
]
end
end
4 changes: 4 additions & 0 deletions mix.lock
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
%{
"abnf2": {:hex, :abnf2, "0.1.2", "6f8792b8ac3288dba5fc889c2bceae9fe78f74e1a7b36bea9726ffaa9d7bef95", [:mix], [], "hexpm"},
"bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm"},
"credo": {:hex, :credo, "0.9.2", "841d316612f568beb22ba310d816353dddf31c2d94aa488ae5a27bb53760d0bf", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:poison, ">= 0.0.0", [hex: :poison, repo: "hexpm", optional: false]}], "hexpm"},
"decimal": {:hex, :decimal, "1.5.0", "b0433a36d0e2430e3d50291b1c65f53c37d56f83665b43d79963684865beab68", [:mix], [], "hexpm"},
"ex_cldr": {:hex, :ex_cldr, "1.5.2", "5c8fe295fef680a821b9e0c19242ea34037af11eb59e6d98f194e6c9c3b4252e", [:mix], [{:abnf2, "~> 0.1", [hex: :abnf2, repo: "hexpm", optional: false]}, {:decimal, "~> 1.4", [hex: :decimal, repo: "hexpm", optional: false]}, {:gettext, "~> 0.13", [hex: :gettext, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.3", [hex: :phoenix, repo: "hexpm", optional: true]}, {:plug, "~> 1.4", [hex: :plug, repo: "hexpm", optional: true]}, {:poison, "~> 2.1 or ~> 3.0", [hex: :poison, repo: "hexpm", optional: true]}, {:sweet_xml, "~> 0.6", [hex: :sweet_xml, repo: "hexpm", optional: true]}], "hexpm"},
"jason": {:hex, :jason, "1.0.0", "0f7cfa9bdb23fed721ec05419bcee2b2c21a77e926bce0deda029b5adc716fe2", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm"},
"poison": {:hex, :poison, "3.1.0", "d9eb636610e096f86f25d9a46f35a9facac35609a7591b3be3326e99a0484665", [:mix], [], "hexpm"},
"yamerl": {:hex, :yamerl, "0.7.0", "e51dba652dce74c20a88294130b48051ebbbb0be7d76f22de064f0f3ccf0aaf5", [:rebar3], [], "hexpm"},
"yaml_elixir": {:hex, :yaml_elixir, "2.0.0", "5d7c40e039b076c0da1921b2754d4a91bc435ac4434bef633f5506dbafd6b8f2", [:mix], [{:yamerl, "~> 0.5", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm"},
"yomel": {:hex, :yomel, "0.5.0", "c5a42d1818deda3f85ae14b1f01f6ece22b9ed8e8087012359fc04b59d85f621", [:make, :mix], [], "hexpm"},
}
34 changes: 34 additions & 0 deletions test/memorized_vocabulary_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
defmodule MemorizedVocabularyTest do
use ExUnit.Case

setup do
Linguist.MemorizedVocabulary.locale("es", Path.join([__DIR__, "es.yml"]))
:ok
end

test "locales() returns locales" do
assert ["es"] == Linguist.MemorizedVocabulary.locales()
end

test "t returns a translation" do
assert {:ok, "bar"} == Linguist.MemorizedVocabulary.t("es", "foo")
end

test "t interpolates values" do
assert {:ok, "hola Michael Westin"} == Linguist.MemorizedVocabulary.t("es", "flash.notice.hello", first: "Michael", last: "Westin")
end

test "t returns {:error, :no_translation} when translation is missing" do
assert Linguist.MemorizedVocabulary.t("es", "flash.not_exists") == {:error, :no_translation}
end

test "t! raises NoTranslationError when translation is missing" do
assert_raise Linguist.NoTranslationError, fn ->
Linguist.MemorizedVocabulary.t!("es", "flash.not_exists")
end
end

test "t pluralizes" do
assert {:ok, "2 manzanas"} == Linguist.MemorizedVocabulary.t("es", "apple", count: 2)
end
end
7 changes: 6 additions & 1 deletion test/vocabulary_test.exs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
defmodule LinguistTest do
defmodule VocabularyTest do
use ExUnit.Case

defmodule I18n do
Expand Down Expand Up @@ -99,6 +99,11 @@ defmodule LinguistTest do
assert I18n.t!("en", "apple", count: 2) == "2 apples"
end

test "pluralizes Spanish correctly" do
assert I18n.t!("es", "apple", count: 1) == "1 manzana"
assert I18n.t!("es", "apple", count: 2) == "2 manzanas"
end

test "throws an error when a pluralized string is not given a count" do
assert_raise Linguist.NoTranslationError, fn ->
I18n.t!("en", "apple")
Expand Down

0 comments on commit 3cf9b44

Please sign in to comment.