├── .circleci └── config.yml ├── .envrc ├── .formatter.exs ├── .gitignore ├── .tool-versions ├── CHANGELOG.md ├── README.md ├── coveralls.json ├── extras └── logo.png ├── flake.lock ├── flake.nix ├── lib ├── fluent.ex └── fluent │ ├── assembly.ex │ ├── assembly │ └── source.ex │ ├── message_not_found.ex │ ├── native.ex │ └── store.ex ├── mix.exs ├── mix.lock ├── native └── fluent_native │ ├── .cargo │ └── config │ ├── .gitignore │ ├── Cargo.lock │ ├── Cargo.toml │ ├── Cross.toml │ ├── README.md │ └── src │ └── lib.rs ├── priv └── fluent │ ├── en │ ├── hello.ftl │ └── tabs.ftl │ ├── it │ └── tabs.ftl │ └── ru │ └── tabs.ftl └── test ├── fluent └── native_test.exs ├── fluent_test.exs ├── support └── test_assemblies.ex └── test_helper.exs /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | # Elixir CircleCI 2.0 configuration file 2 | # 3 | # Check https://circleci.com/docs/2.0/language-elixir/ for more details 4 | version: 2 5 | jobs: 6 | build: 7 | docker: 8 | # specify the version here 9 | - image: virviil/rustler:rust1.38-elixir1.9.1 10 | 11 | # Specify service dependencies here if necessary 12 | # CircleCI maintains a library of pre-built images 13 | # documented at https://circleci.com/docs/2.0/circleci-images/ 14 | # - image: circleci/postgres:9.4 15 | 16 | working_directory: ~/repo 17 | environment: 18 | MIX_ENV: test 19 | steps: 20 | - checkout 21 | 22 | # specify any bash command here prefixed with `run: ` 23 | - run: mix deps.get 24 | - run: mix compile --warnings-as-errors 25 | - run: mix credo --strict 26 | - run: mix coveralls.circle -------------------------------------------------------------------------------- /.envrc: -------------------------------------------------------------------------------- 1 | use flake 2 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | fluent-*.tar 24 | 25 | # Rustler artifacts 26 | priv/native 27 | 28 | .elixir_ls 29 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | elixir 1.15.0-otp-25 2 | erlang 25.0 3 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## Unreleased 4 | 5 | ## 0.2.4 6 | 7 | - Updated dependencies 8 | 9 | ## 0.2.3 10 | 11 | - Updated dependencies 12 | 13 | ## 0.2.2 14 | 15 | - Updated dependencies 16 | ## 0.2.1 17 | ### Added 18 | 19 | - `use_isolating` featrure is added both for `Native` representation and for `Assemblies` 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # libfluent 2 | [![](https://img.shields.io/hexpm/dt/libfluent.svg?style=flat-square)](https://hex.pm/packages/libfluent)[![](https://img.shields.io/hexpm/v/libfluent.svg?style=flat-square)](https://hex.pm/packages/libfluent)[![](https://img.shields.io/hexpm/l/libfluent.svg?style=flat-square)](https://hex.pm/packages/libfluent)[![](https://img.shields.io/circleci/build/gh/Virviil/libfluent?style=flat-square)](https://circleci.com/gh/Virviil/libfluent)[![](https://img.shields.io/coveralls/github/Virviil/libfluent.svg?style=flat-square)](https://coveralls.io/github/Virviil/libfluent)[![](https://img.shields.io/github/last-commit/virviil/libfluent.svg?style=flat-square)](https://github.com/Virviil/libfluent/commits)[![](https://img.shields.io/maintenance/yes/2020.svg?style=flat-square)](https://github.com/Virviil/libfluent) 3 | 4 | Module provides [**Project Fluent**](https://projectfluent.org/) bindings and API to build 5 | internationalized applications. API is trying to be as simmilar as possible to 6 | Elixir's *default* internatrionalization library - [**Gettext**](https://hex.pm/packages/gettext) 7 | 8 | At the same time, `Fluent.Native` module - as native binding - can be used to work with **Project Fluent** 9 | from any structure of choice. One can use it to define his own API, that is opposite to **Gettext** API 10 | as much as he wants. 11 | 12 | ## Using Fluent 13 | 14 | To use **Fluent**, a module that calls `use Fluent.Assembly` has to be defined: 15 | 16 | ```elixir 17 | defmodule MyApp.Fluent do 18 | use Fluent.Assembly, otp_app: :my_app 19 | end 20 | ``` 21 | 22 | This automatically defines some funcitons in the `MyApp.Fluent` module, that can be used to translation: 23 | 24 | ```elixir 25 | import MyApp.Fluent 26 | 27 | # Simple translation 28 | ftl("hello-world") 29 | 30 | # Argument-based translation 31 | ftl("hello-user", userName: "Alice") 32 | 33 | # With different types 34 | ftl("shared-photos", userName: "Alice", userGender: "female", photoCount: 3) 35 | ``` 36 | 37 | ## Translations 38 | 39 | Translations are stored inside **FTL** files, with `.ftl` extension. 40 | Syntax overview can be found [here](https://projectfluent.org/fluent/guide/) 41 | 42 | **FTL** files, containgin translations for an application must be stored in a directory (by default it's `priv/fluent`), 43 | that has the following structure: 44 | 45 | ```bash 46 | %FLUENT_TRANSLATIONS_DIRECTORY% 47 | └─ %LOCALE% 48 | ├─ file_1.ftl 49 | ├─ file_2.ftl 50 | └─ file_3.ftl 51 | ``` 52 | 53 | Here, **%LOCALE%** is the locale of the translations (for example, `en_US`), 54 | and file_i.ftl are FTL files containing translations. All the files from single translation 55 | are loaded as the single scope, so name conflicts inside the files in one folder should be avoided. 56 | 57 | A concrete example of such a directory structure could look like this: 58 | 59 | ```bash 60 | priv/gettext 61 | └─ en_US 62 | | ├─ default.ftl 63 | | └─ errors.ftl 64 | └─ it 65 | ├─ default.ftl 66 | └─ errors.ftl 67 | ``` 68 | 69 | By default, **Fluent** expects translations to be stored under the `fluent` directory inside `priv` directory of an application. This behaviour can be changed by specifying a `:priv` option when using `Fluent.Assembly`: 70 | 71 | ```elixir 72 | # Look for translations in my_app/priv/translations instead of 73 | # my_app/priv/gettext 74 | use Fluent.Assembly, otp_app: :my_app, priv: "translations" 75 | ``` 76 | 77 | ## Locale 78 | 79 | At runtime, all translation functions that do not explicitly take a locale as an argument read the locale from the assembly locale and then fallbacks to `libfluent`'s locale. 80 | 81 | `Fluent.put_locale/1` can be used to change the locale of all assemblies for the current Elixir process. That's the preferred mechanism for setting the locale at runtime. `Fluent.put_locale/2` can be used when you want to set the locale of one specific **Fluent** assembly without affecting other **Fluent** assemblies. 82 | 83 | Similarly, `Fluent.get_locale/0` gets the locale for all assemblies in the current process. `Fluent.get_locale/1` gets the locale of a specific assembly for the current process. Check their documentation for more information. 84 | 85 | Locales are expressed as strings (like "en" or "fr"); they can be arbitrary strings as long as they match a directory name. As mentioned above, the locale is stored per-process (in the process dictionary): this means that the locale must be set in every new process in order to have the right locale available for that process. Pay attention to this behaviour, since not setting the locale will not result in any errors when `Fluent.get_locale/0` or `Fluent.get_locale/1` are called; the default locale will be returned instead. 86 | 87 | To decide which locale to use, each gettext-related function in a given assembly follows these steps: 88 | 89 | * if there is a assembly-specific locale for the given assembly for this process (see `Fluent.put_locale/2`), 90 | use that, *otherwise* 91 | * if there is a global locale for this process (see `Fluent.put_locale/1`), 92 | use that, *otherwise* 93 | * if there is a assembly's specific default locale in the configuration for that assembly's `:otp_app` 94 | (see the [**Default locale**](#default-locale) section below), use that, *otherwise* 95 | * use the default global **Fluent** locale (see the [**Default locale**](#default-locale) section below) 96 | 97 | 98 | ### Default locale 99 | 100 | The global **Fluent** default locale can be configured through the `:default_locale` key of the `:libfluent` application: 101 | 102 | ```elixir 103 | config :libfluent, :default_locale, "fr" 104 | ``` 105 | 106 | By default the global locale is "en". 107 | 108 | If for some reason an assembly requires with a different `:default_locale` than all other assemblies, you can set the `:default_locale` inside the assembly configuration, but this approach is generally discouraged as it makes it hard to track which locale each assembly is using: 109 | 110 | ```elixir 111 | config :my_app, MyApp.Fluent, default_locale: "fr" 112 | ``` 113 | 114 | ## Installation 115 | 116 | If [available in Hex](https://hex.pm/docs/publish), the package can be installed 117 | by adding `fluent` to your list of dependencies in `mix.exs`: 118 | 119 | ```elixir 120 | def deps do 121 | [ 122 | {:libfluent, "~> 0.2.2"} 123 | ] 124 | end 125 | ``` 126 | 127 | Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc) 128 | and published on [HexDocs](https://hexdocs.pm). Once published, the docs can 129 | be found at [https://hexdocs.pm/libfluent](https://hexdocs.pm/libfluent). 130 | -------------------------------------------------------------------------------- /coveralls.json: -------------------------------------------------------------------------------- 1 | { 2 | "skip_files": [ 3 | "test/support" 4 | ] 5 | } -------------------------------------------------------------------------------- /extras/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Virviil/libfluent/8c0ca49b930e72ff6ce512fcfbd31b54fdd23e05/extras/logo.png -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "flake-utils": { 4 | "inputs": { 5 | "systems": "systems" 6 | }, 7 | "locked": { 8 | "lastModified": 1687709756, 9 | "narHash": "sha256-Y5wKlQSkgEK2weWdOu4J3riRd+kV/VCgHsqLNTTWQ/0=", 10 | "owner": "numtide", 11 | "repo": "flake-utils", 12 | "rev": "dbabf0ca0c0c4bce6ea5eaf65af5cb694d2082c7", 13 | "type": "github" 14 | }, 15 | "original": { 16 | "owner": "numtide", 17 | "repo": "flake-utils", 18 | "type": "github" 19 | } 20 | }, 21 | "nixpkgs": { 22 | "locked": { 23 | "lastModified": 1688939073, 24 | "narHash": "sha256-jYhYjeK5s6k8QS3i+ovq9VZqBJaWbxm7awTKNhHL9d0=", 25 | "owner": "nixos", 26 | "repo": "nixpkgs", 27 | "rev": "8df7a67abaf8aefc8a2839e0b48f92fdcf69a38b", 28 | "type": "github" 29 | }, 30 | "original": { 31 | "owner": "nixos", 32 | "ref": "nixos-23.05", 33 | "repo": "nixpkgs", 34 | "type": "github" 35 | } 36 | }, 37 | "root": { 38 | "inputs": { 39 | "flake-utils": "flake-utils", 40 | "nixpkgs": "nixpkgs" 41 | } 42 | }, 43 | "systems": { 44 | "locked": { 45 | "lastModified": 1681028828, 46 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 47 | "owner": "nix-systems", 48 | "repo": "default", 49 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 50 | "type": "github" 51 | }, 52 | "original": { 53 | "owner": "nix-systems", 54 | "repo": "default", 55 | "type": "github" 56 | } 57 | } 58 | }, 59 | "root": "root", 60 | "version": 7 61 | } 62 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | inputs = { 3 | nixpkgs.url = "github:nixos/nixpkgs/nixos-23.05"; 4 | flake-utils.url = "github:numtide/flake-utils"; 5 | }; 6 | 7 | outputs = 8 | { self 9 | , nixpkgs 10 | , flake-utils 11 | }: flake-utils.lib.eachDefaultSystem (system: 12 | let 13 | pkgs = import nixpkgs { inherit system; }; 14 | in 15 | { 16 | devShells.default = pkgs.mkShell { 17 | buildInputs = with pkgs; [ 18 | elixir_1_15 19 | erlang 20 | ]; 21 | }; 22 | }); 23 | } 24 | -------------------------------------------------------------------------------- /lib/fluent.ex: -------------------------------------------------------------------------------- 1 | defmodule Fluent do 2 | @moduledoc """ 3 | Module 4 | """ 5 | 6 | @typedoc """ 7 | Represents locale. Basically it's a string, that has valid locale identifier 8 | """ 9 | @type locale :: String.t() 10 | 11 | @typedoc """ 12 | Native container, that is firstly identified and then used for translations 13 | """ 14 | @type bundle :: reference() 15 | 16 | @typedoc """ 17 | Name for Fluent's assembly. Should be valid module, that *uses* `Fluent.Assembly` 18 | """ 19 | @type assembly :: module 20 | 21 | @spec put_locale(locale) :: nil 22 | def put_locale(locale) when is_binary(locale), do: Process.put(Fluent, locale) 23 | 24 | def put_locale(locale), 25 | do: raise(ArgumentError, "put_locale/1 only accepts binary locales, got: #{inspect(locale)}") 26 | 27 | @doc """ 28 | Gets the locale for the current process and the given assembly. 29 | This function returns the value of the locale for the current process and the 30 | given `assembly`. If there is no locale for the current process and the given 31 | assembly, then either the global Fluent locale (if set), or the default locale 32 | for the given assembly, or the global default locale is returned. See the 33 | "Locale" section in the module documentation for more information. 34 | ## Examples 35 | Fluent.get_locale(MyApp.Fluent) 36 | #=> "en" 37 | """ 38 | @spec get_locale(assembly) :: locale 39 | def get_locale(assembly) do 40 | Process.get(assembly) || Process.get(Fluent) || assembly.__config__(:default_locale) 41 | end 42 | 43 | @doc """ 44 | Sets the locale for the current process and the given `assembly`. 45 | The locale is stored in the process dictionary. `locale` must be a string; if 46 | it's not, an `ArgumentError` exception is raised. 47 | ## Examples 48 | Fluent.put_locale(MyApp.Fluent, "pt_BR") 49 | #=> nil 50 | Fluent.get_locale(MyApp.Fluent) 51 | #=> "pt_BR" 52 | """ 53 | @spec put_locale(assembly, locale) :: nil 54 | def put_locale(assembly, locale) when is_binary(locale), do: Process.put(assembly, locale) 55 | 56 | def put_locale(_assembly, locale), 57 | do: raise(ArgumentError, "put_locale/2 only accepts binary locales, got: #{inspect(locale)}") 58 | 59 | @doc """ 60 | Runs `fun` with the global Fluent locale set to `locale`. 61 | This function just sets the global Fluent locale to `locale` before running 62 | `fun` and sets it back to its previous value afterwards. Note that 63 | `put_locale/2` is used to set the locale, which is thus set only for the 64 | current process (keep this in mind if you plan on spawning processes inside 65 | `fun`). 66 | The value returned by this function is the return value of `fun`. 67 | ## Examples 68 | Fluent.put_locale("fr") 69 | MyApp.Fluent.ftl("Hello world") 70 | #=> "Bonjour monde" 71 | Fluent.Assembly.with_locale "it", fn -> 72 | MyApp.Fluent.ftl("Hello world") 73 | end 74 | #=> "Ciao mondo" 75 | MyApp.Fluent.ftl("Hello world") 76 | #=> "Bonjour monde" 77 | """ 78 | @spec with_locale(locale, (-> result)) :: result when result: var 79 | def with_locale(locale, fun) do 80 | previous_locale = Process.get(Fluent) 81 | Fluent.put_locale(locale) 82 | 83 | try do 84 | fun.() 85 | after 86 | if previous_locale do 87 | Fluent.put_locale(previous_locale) 88 | else 89 | Process.delete(Fluent) 90 | end 91 | end 92 | end 93 | 94 | @doc """ 95 | Runs `fun` with the `Fluent.Assembly` locale set to `locale` for the given `assembly`. 96 | This function just sets the Fluent.Assembly locale for `assembly` to `locale` before 97 | running `fun` and sets it back to its previous value afterwards. Note that 98 | `put_locale/2` is used to set the locale, which is thus set only for the 99 | current process (keep this in mind if you plan on spawning processes inside 100 | `fun`). 101 | The value returned by this function is the return value of `fun`. 102 | ## Examples 103 | Fluent.put_locale(MyApp.Fluent, "fr") 104 | MyApp.Fluent.ftl("Hello world") 105 | #=> "Bonjour monde" 106 | Fluent.with_locale MyApp.Fluent, "it", fn -> 107 | MyApp.Fluent.ftl("Hello world") 108 | end 109 | #=> "Ciao mondo" 110 | MyApp.Fluent.ftl("Hello world") 111 | #=> "Bonjour monde" 112 | """ 113 | @spec with_locale(assembly, locale, (-> result)) :: result when result: var 114 | def with_locale(assembly, locale, fun) do 115 | previous_locale = Process.get(assembly) 116 | Fluent.put_locale(assembly, locale) 117 | 118 | try do 119 | fun.() 120 | after 121 | if previous_locale do 122 | Fluent.put_locale(assembly, previous_locale) 123 | else 124 | Process.delete(assembly) 125 | end 126 | end 127 | end 128 | 129 | @doc """ 130 | Returns all the locales for which FTL files exist for the given `assembly`. 131 | If the translations directory for the given assembly doesn't exist, then an 132 | empty list is returned. 133 | ## Examples 134 | With the following assembly: 135 | defmodule MyApp.Fluent do 136 | use Fluent.Assembly, otp_app: :my_app 137 | end 138 | and the following translations directory: 139 | my_app/priv/fluent 140 | ├─ en 141 | ├─ it 142 | └─ pt_BR 143 | then: 144 | Fluent.known_locales(MyApp.Fluent) 145 | #=> ["en", "it", "pt_BR"] 146 | """ 147 | @spec known_locales(assembly) :: [locale] 148 | def known_locales(assembly) do 149 | Fluent.Store.known_locales(assembly.__store__()) 150 | end 151 | end 152 | -------------------------------------------------------------------------------- /lib/fluent/assembly.ex: -------------------------------------------------------------------------------- 1 | defmodule Fluent.Assembly do 2 | @moduledoc """ 3 | Module 4 | """ 5 | @type t :: atom() 6 | 7 | @doc """ 8 | Doc for using macro 9 | 10 | opts: 11 | 12 | * otp_app: "libfluen" 13 | * priv: "priv/fluent" 14 | * default_locale: "en-US" 15 | * use_isolating: false - default is true 16 | """ 17 | defmacro __using__(opts) do 18 | quote location: :keep do 19 | @otp_app Keyword.fetch!(unquote(opts), :otp_app) 20 | @priv Keyword.get(unquote(opts), :priv, "fluent") 21 | @default_locale Keyword.get(unquote(opts), :default_locale, "en") 22 | @silent_errors Keyword.get(unquote(opts), :silent_errors, false) 23 | @use_isolating Keyword.get(unquote(opts), :use_isolating, true) 24 | 25 | @spec __config__(atom()) :: any() 26 | def __config__(:otp_app), do: @otp_app 27 | def __config__(:sys), do: Application.get_env(@otp_app, __MODULE__, []) 28 | def __config__(:priv), do: Keyword.get(__config__(:sys), :priv, nil) || @priv 29 | 30 | def __config__(:default_locale), 31 | do: Keyword.get(__config__(:sys), :default_locale, nil) || @default_locale 32 | 33 | def __config__(:silent_errors), 34 | do: Keyword.get(__config__(:sys), :silent_errors, nil) || @silent_errors 35 | 36 | def __config__(:use_isolating), 37 | do: Keyword.get(__config__(:sys), :use_isolating, nil) || @use_isolating 38 | 39 | @spec __store__() :: Fluent.Store.t() 40 | def __store__ do 41 | Fluent.Store.get_store(__MODULE__) 42 | end 43 | 44 | def ftl(message, args \\ []) do 45 | locale = Fluent.get_locale(__MODULE__) 46 | 47 | case Fluent.Store.format_pattern(__store__(), locale, message, args) do 48 | {:ok, message} -> 49 | message 50 | 51 | _ -> 52 | case __config__(:silent_errors) do 53 | true -> 54 | message 55 | 56 | false -> 57 | raise( 58 | Fluent.MessageNotFound, 59 | msg: message, 60 | locale: locale, 61 | assembly: __MODULE__ 62 | ) 63 | end 64 | end 65 | end 66 | end 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /lib/fluent/assembly/source.ex: -------------------------------------------------------------------------------- 1 | defmodule Fluent.Assembly.Source do 2 | @moduledoc """ 3 | Module 4 | """ 5 | 6 | @doc """ 7 | Returns absolute path to directory, which contains all FTL data for `assembly`. 8 | 9 | ## Examples: 10 | 11 | iex> assembly_dir(MyApp.Fluent) 12 | "/path/to/ftl/files/" 13 | """ 14 | @spec assembly_dir(assembly :: Fluent.Assembly.t()) :: Path.t() 15 | def assembly_dir(assembly) do 16 | assembly.__config__(:otp_app) 17 | |> :code.priv_dir() 18 | |> Path.join(assembly.__config__(:priv)) 19 | end 20 | 21 | @doc """ 22 | Returns absolute path to directory, which contains all FTL data for `assembly` with given `locale`. 23 | 24 | ## Examples: 25 | 26 | iex> assembly_dir(MyApp.Fluent, "en-US") 27 | "/path/to/ftl/files/en-US" 28 | """ 29 | @spec locale_dir(assembly :: Fluent.Assembly.t(), locale :: Fluent.locale()) :: Path.t() 30 | def locale_dir(assembly, locale) do 31 | Path.join(assembly_dir(assembly), locale) 32 | end 33 | 34 | @doc """ 35 | Returns list of all available locales for given `assembly` 36 | 37 | ## Examples: 38 | 39 | iex> locales(MyApp.Fluent) 40 | ["en", "fr", "ru"] 41 | """ 42 | @spec locales(assembly :: Fluent.Assembly.t()) :: [Fluent.locale()] 43 | def locales(assembly) do 44 | # Caching not to call twise 45 | assembly_dir = assembly_dir(assembly) 46 | 47 | assembly_dir 48 | |> Path.join("*") 49 | |> Path.wildcard() 50 | |> Enum.map(&Path.relative_to(&1, assembly_dir)) 51 | end 52 | 53 | @doc """ 54 | Returns absolute pathes to all FTL files for given `assembly` and it's `locale`. 55 | 56 | ## Examples: 57 | 58 | iex> ftl_files_pathes(MyApp.Fluent, "en") 59 | ["/path/to/ftl/1.ftl", "/path/to/ftl/2.frl", ... "/path/to/ftl/last.ftl"] 60 | """ 61 | @spec ftl_files_pathes(assembly :: Fluent.Assembly.t(), locale :: Fluent.locale()) :: [Path.t()] 62 | def ftl_files_pathes(assembly, locale) do 63 | locale_dir(assembly, locale) 64 | |> Path.join("*.ftl") 65 | |> Path.wildcard() 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /lib/fluent/message_not_found.ex: -------------------------------------------------------------------------------- 1 | defmodule Fluent.MessageNotFound do 2 | defexception [:message, :msg, :locale, :assembly] 3 | 4 | @impl Exception 5 | def exception(error) do 6 | %__MODULE__{ 7 | message: 8 | "Translation for message #{Keyword.get(error, :msg)} is not found in #{Keyword.get(error, :assembly)} assembly for #{Keyword.get(error, :locale)} locale." 9 | } 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/fluent/native.ex: -------------------------------------------------------------------------------- 1 | defmodule Fluent.Native do 2 | @moduledoc """ 3 | Module 4 | """ 5 | use Rustler, otp_app: :libfluent, crate: :fluent_native 6 | 7 | @doc """ 8 | Initializes Fluent native bundle for given `locale`, and returns reference to it on succeded initialization. 9 | 10 | Available options: 11 | 12 | * `use_isolating` - if set to **false**, removes isolation for messages. Can be used in specific environments to prevent unnesessary identation. 13 | Must be set to **true** in for right-to-left locale usages. 14 | 15 | ## Examples: 16 | 17 | iex> init("en") 18 | {:ok, #Reference<...>} 19 | """ 20 | @spec init(locale :: Fluent.locale(), opts :: Keyword.t()) :: 21 | {:ok, Fluent.bundle()} | no_return() 22 | def init(_locale, _opts \\ []), do: error() 23 | 24 | @doc """ 25 | Adds new FTL `resource` for existing `bundle`. 26 | 27 | Resource **mast be** valid FTL source. The function can returns `:ok` if `resource` is valid, 28 | and does not return `bundle` reference again, becuse data under the reference is mutable. 29 | 30 | ## Examples: 31 | 32 | iex> {:ok, bundle} = init("en") 33 | {:ok, #Reference<...>} 34 | 35 | iex> with_resource(bundle, "key = Translation") 36 | :ok 37 | """ 38 | @spec with_resource(bundle :: Fluent.bundle(), resource :: String.t()) :: 39 | :ok | {:error, :bad_resource} | no_return() 40 | def with_resource(_bundle, _resource), do: error() 41 | 42 | @doc """ 43 | Performs localization for given `message` with given `bundle`. 44 | 45 | Returns `ok` tuple if message is succeeded 46 | Potentially can crash in the `bundle` that is given not match. 47 | """ 48 | @spec format_pattern(bundle :: Fluent.bundle(), message :: String.t(), args :: Keyword.t()) :: 49 | {:ok, String.t()} | {:error, :bad_msg} | no_return() 50 | def format_pattern(_bundle, _message, _args), do: error() 51 | 52 | @spec assert_locale(locale :: Fluent.locale()) :: :ok | {:error, any} | no_return 53 | def assert_locale(locale) when is_binary(locale), do: error() 54 | 55 | defp error, do: :erlang.nif_error(:nif_not_loaded) 56 | end 57 | -------------------------------------------------------------------------------- /lib/fluent/store.ex: -------------------------------------------------------------------------------- 1 | defmodule Fluent.Store do 2 | @moduledoc """ 3 | This module hande references to active bundles, that are using during runtimne 4 | of the program 5 | """ 6 | alias Fluent.Assembly.Source 7 | 8 | @behaviour Access 9 | defstruct bundles: %{} 10 | 11 | @type t :: %__MODULE__{} 12 | 13 | ############################################################################# 14 | ### Access behaviour implementation 15 | ############################################################################# 16 | 17 | @impl Access 18 | defdelegate fetch(term, key), to: Map 19 | 20 | @impl Access 21 | defdelegate get_and_update(data, key, function), to: Map 22 | 23 | @impl Access 24 | defdelegate pop(data, key), to: Map 25 | 26 | ############################################################################# 27 | ### Initialization tree 28 | ############################################################################# 29 | 30 | @spec get_store(assembly :: Fluent.Assembly.t()) :: Fluent.Store.t() 31 | def get_store(assembly) do 32 | case :persistent_term.get(assembly, nil) do 33 | assembly = %__MODULE__{} -> assembly 34 | _ -> initialize_store(assembly) 35 | end 36 | end 37 | 38 | @spec initialize_store(assembly :: Fluent.Assembly.t()) :: {:ok, t()} 39 | def initialize_store(assembly) do 40 | assembly 41 | |> Source.locales() 42 | |> Enum.each(&initialize_locale(assembly, &1)) 43 | 44 | :persistent_term.get(assembly) 45 | end 46 | 47 | @doc """ 48 | 49 | Example: 50 | 51 | iex> Fluent.Store.initialize_bundle(MyApp.Fluent, "en-US") 52 | """ 53 | @spec initialize_locale(assembly :: Fluent.Assembly.t(), locale :: Fluent.locale()) :: 54 | {:ok, Fluent.bundle()} 55 | def initialize_locale(assembly, locale) do 56 | with {:ok, bundle_ref} when is_reference(bundle_ref) <- 57 | Fluent.Native.init(locale, use_isolating: assembly.__config__(:use_isolating)), 58 | :ok <- persist_bundle(assembly, locale, bundle_ref), 59 | :ok <- add_resources(bundle_ref, assembly, locale) do 60 | {:ok, bundle_ref} 61 | end 62 | end 63 | 64 | @spec format_pattern(Fluent.Store.t(), Fluent.locale(), String.t(), Keyword.t()) :: any 65 | def format_pattern(store, locale, message, args \\ []) do 66 | case Map.get(store.bundles, locale) do 67 | nil -> {:error, :bad_locale} 68 | bundle -> Fluent.Native.format_pattern(bundle, message, args) 69 | end 70 | end 71 | 72 | @spec known_locales(store :: t()) :: [Fluent.locale()] 73 | def known_locales(store) do 74 | Map.keys(store[:bundles]) 75 | end 76 | 77 | @spec add_resources( 78 | bundle_reference :: Fluent.bundle(), 79 | assembly :: Fluent.Assembly.t(), 80 | locale :: Fluent.locale() 81 | ) :: :ok 82 | defp add_resources(bundle_reference, assembly, locale) do 83 | assembly 84 | |> Source.ftl_files_pathes(locale) 85 | |> Enum.each(fn path -> 86 | case File.read(path) do 87 | {:ok, raw_data} -> Fluent.Native.with_resource(bundle_reference, raw_data) 88 | # May be here is needed to handle errors 89 | {:error, _} -> :skip_this_file 90 | end 91 | end) 92 | end 93 | 94 | @spec persist_bundle( 95 | assembly :: Fluent.Assembly.t(), 96 | locale :: Fluent.locale(), 97 | bundle_reference :: reference() 98 | ) :: :ok 99 | defp persist_bundle(assembly, locale, bundle_reference) do 100 | store = 101 | put_in( 102 | :persistent_term.get(assembly, %__MODULE__{}), 103 | [:bundles, locale], 104 | bundle_reference 105 | ) 106 | 107 | :persistent_term.put(assembly, store) 108 | end 109 | end 110 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Fluent.MixProject do 2 | use Mix.Project 3 | 4 | @app :libfluent 5 | @version "0.2.4" 6 | @native_app :fluent_native 7 | 8 | def project do 9 | [ 10 | app: @app, 11 | version: @version, 12 | elixir: "~> 1.9", 13 | start_permanent: Mix.env() == :prod, 14 | deps: deps(), 15 | elixirc_paths: elixirc_paths(Mix.env()), 16 | package: package(), 17 | description: description(), 18 | compilers: Mix.compilers(), 19 | test_coverage: [tool: ExCoveralls], 20 | docs: docs(), 21 | preferred_cli_env: [ 22 | coveralls: :test, 23 | "coveralls.detail": :test, 24 | "coveralls.post": :test, 25 | "coveralls.html": :test 26 | ] 27 | ] 28 | end 29 | 30 | # Run "mix help compile.app" to learn about applications. 31 | def application, do: [extra_applications: [:logger]] 32 | 33 | def description() do 34 | """ 35 | I18n and L10n Project Fluent implimentation for Elixir. 36 | """ 37 | end 38 | 39 | defp elixirc_paths(:test), do: ["lib", "test/support"] 40 | defp elixirc_paths(_), do: ["lib"] 41 | 42 | # Run "mix help deps" to learn about dependencies. 43 | defp deps do 44 | [ 45 | {:rustler, "~> 0.29.1"}, 46 | {:mix_test_watch, "~> 1.1", only: :dev, runtime: false}, 47 | {:ex_doc, ">= 0.0.0", only: :dev}, 48 | {:excoveralls, "~> 0.13", only: :test}, 49 | {:credo, "~> 1.7", only: [:dev, :test], runtime: false} 50 | ] 51 | end 52 | 53 | defp docs do 54 | [ 55 | main: "readme", 56 | logo: "extras/logo.png", 57 | extras: [ 58 | "README.md" 59 | ] 60 | ] 61 | end 62 | 63 | defp package do 64 | [ 65 | maintainers: ["Dmitry Rubinstein"], 66 | licenses: ~w(MIT Apache-2.0), 67 | links: %{"Github" => "https://github.com/Virviil/libfluent"}, 68 | files: ~w(mix.exs lib) ++ rust_files() 69 | ] 70 | end 71 | 72 | defp rust_files do 73 | ~w(Cargo.toml src .cargo) 74 | |> Enum.map(&"native/#{@native_app}/#{&1}") 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "bunt": {:hex, :bunt, "0.2.1", "e2d4792f7bc0ced7583ab54922808919518d0e57ee162901a16a1b6664ef3b14", [:mix], [], "hexpm", "a330bfb4245239787b15005e66ae6845c9cd524a288f0d141c148b02603777a5"}, 3 | "certifi": {:hex, :certifi, "2.9.0", "6f2a475689dd47f19fb74334859d460a2dc4e3252a3324bd2111b8f0429e7e21", [:rebar3], [], "hexpm", "266da46bdb06d6c6d35fde799bcb28d36d985d424ad7c08b5bb48f5b5cdd4641"}, 4 | "credo": {:hex, :credo, "1.7.0", "6119bee47272e85995598ee04f2ebbed3e947678dee048d10b5feca139435f75", [:mix], [{:bunt, "~> 0.2.1", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2.8", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "6839fcf63d1f0d1c0f450abc8564a57c43d644077ab96f2934563e68b8a769d7"}, 5 | "earmark": {:hex, :earmark, "1.4.0", "397e750b879df18198afc66505ca87ecf6a96645545585899f6185178433cc09", [:mix], [], "hexpm", "4bedcec35de03b5f559fd2386be24d08f7637c374d3a85d3fe0911eecdae838a"}, 6 | "earmark_parser": {:hex, :earmark_parser, "1.4.33", "3c3fd9673bb5dcc9edc28dd90f50c87ce506d1f71b70e3de69aa8154bc695d44", [:mix], [], "hexpm", "2d526833729b59b9fdb85785078697c72ac5e5066350663e5be6a1182da61b8f"}, 7 | "ex_doc": {:hex, :ex_doc, "0.30.1", "a0f3b598d3c2cb3af48af39e59fa66ac8d4033740409b11dd753a3f30f8f8f7a", [:mix], [{:earmark_parser, "~> 1.4.31", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "2e2216e84aa33e5803f8898d762b0f5e76bf2de3a08d1f40ac5f74456dd5057c"}, 8 | "excoveralls": {:hex, :excoveralls, "0.16.1", "0bd42ed05c7d2f4d180331a20113ec537be509da31fed5c8f7047ce59ee5a7c5", [:mix], [{:hackney, "~> 1.16", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "dae763468e2008cf7075a64cb1249c97cb4bc71e236c5c2b5e5cdf1cfa2bf138"}, 9 | "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, 10 | "hackney": {:hex, :hackney, "1.18.1", "f48bf88f521f2a229fc7bae88cf4f85adc9cd9bcf23b5dc8eb6a1788c662c4f6", [:rebar3], [{:certifi, "~> 2.9.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "a4ecdaff44297e9b5894ae499e9a070ea1888c84afdd1fd9b7b2bc384950128e"}, 11 | "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, 12 | "jason": {:hex, :jason, "1.4.1", "af1504e35f629ddcdd6addb3513c3853991f694921b1b9368b0bd32beb9f1b63", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fbb01ecdfd565b56261302f7e1fcc27c4fb8f32d56eab74db621fc154604a7a1"}, 13 | "makeup": {:hex, :makeup, "1.1.0", "6b67c8bc2882a6b6a445859952a602afc1a41c2e08379ca057c0f525366fc3ca", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "0a45ed501f4a8897f580eabf99a2e5234ea3e75a4373c8a52824f6e873be57a6"}, 14 | "makeup_elixir": {:hex, :makeup_elixir, "0.16.1", "cc9e3ca312f1cfeccc572b37a09980287e243648108384b97ff2b76e505c3555", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "e127a341ad1b209bd80f7bd1620a15693a9908ed780c3b763bccf7d200c767c6"}, 15 | "makeup_erlang": {:hex, :makeup_erlang, "0.1.2", "ad87296a092a46e03b7e9b0be7631ddcf64c790fa68a9ef5323b6cbb36affc72", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "f3f5a1ca93ce6e092d92b6d9c049bcda58a3b617a8d888f8e7231c85630e8108"}, 16 | "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, 17 | "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, 18 | "mix_test_watch": {:hex, :mix_test_watch, "1.1.0", "330bb91c8ed271fe408c42d07e0773340a7938d8a0d281d57a14243eae9dc8c3", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}], "hexpm", "52b6b1c476cbb70fd899ca5394506482f12e5f6b0d6acff9df95c7f1e0812ec3"}, 19 | "nimble_parsec": {:hex, :nimble_parsec, "1.3.1", "2c54013ecf170e249e9291ed0a62e5832f70a476c61da16f6aac6dca0189f2af", [:mix], [], "hexpm", "2682e3c0b2eb58d90c6375fc0cc30bc7be06f365bf72608804fb9cffa5e1b167"}, 20 | "parse_trans": {:hex, :parse_trans, "3.3.1", "16328ab840cc09919bd10dab29e431da3af9e9e7e7e6f0089dd5a2d2820011d8", [:rebar3], [], "hexpm", "07cd9577885f56362d414e8c4c4e6bdf10d43a8767abb92d24cbe8b24c54888b"}, 21 | "rustler": {:hex, :rustler, "0.29.1", "880f20ae3027bd7945def6cea767f5257bc926f33ff50c0d5d5a5315883c084d", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:toml, "~> 0.6", [hex: :toml, repo: "hexpm", optional: false]}], "hexpm", "109497d701861bfcd26eb8f5801fe327a8eef304f56a5b63ef61151ff44ac9b6"}, 22 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, 23 | "toml": {:hex, :toml, "0.7.0", "fbcd773caa937d0c7a02c301a1feea25612720ac3fa1ccb8bfd9d30d822911de", [:mix], [], "hexpm", "0690246a2478c1defd100b0c9b89b4ea280a22be9a7b313a8a058a2408a2fa70"}, 24 | "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, 25 | } 26 | -------------------------------------------------------------------------------- /native/fluent_native/.cargo/config: -------------------------------------------------------------------------------- 1 | [target.x86_64-apple-darwin] 2 | rustflags = [ 3 | "-C", "link-arg=-undefined", 4 | "-C", "link-arg=dynamic_lookup", 5 | ] 6 | 7 | [target.aarch64-apple-darwin] 8 | rustflags = [ 9 | "-C", "link-arg=-undefined", 10 | "-C", "link-arg=dynamic_lookup", 11 | ] 12 | 13 | [target.x86_64-unknown-linux-musl] 14 | rustflags = [ 15 | "-C", "target-feature=-crt-static" 16 | ] 17 | 18 | [target.aarch64-unknown-linux-musl] 19 | rustflags = [ 20 | "-C", "target-feature=-crt-static" 21 | ] -------------------------------------------------------------------------------- /native/fluent_native/.gitignore: -------------------------------------------------------------------------------- 1 | /target/ -------------------------------------------------------------------------------- /native/fluent_native/Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 3 4 | 5 | [[package]] 6 | name = "aho-corasick" 7 | version = "1.0.2" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "43f6cb1bf222025340178f382c426f13757b2960e89779dfcb319c32542a5a41" 10 | dependencies = [ 11 | "memchr", 12 | ] 13 | 14 | [[package]] 15 | name = "displaydoc" 16 | version = "0.2.4" 17 | source = "registry+https://github.com/rust-lang/crates.io-index" 18 | checksum = "487585f4d0c6655fe74905e2504d8ad6908e4db67f744eb140876906c2f3175d" 19 | dependencies = [ 20 | "proc-macro2", 21 | "quote", 22 | "syn", 23 | ] 24 | 25 | [[package]] 26 | name = "fluent" 27 | version = "0.16.0" 28 | source = "registry+https://github.com/rust-lang/crates.io-index" 29 | checksum = "61f69378194459db76abd2ce3952b790db103ceb003008d3d50d97c41ff847a7" 30 | dependencies = [ 31 | "fluent-bundle", 32 | "unic-langid", 33 | ] 34 | 35 | [[package]] 36 | name = "fluent-bundle" 37 | version = "0.15.2" 38 | source = "registry+https://github.com/rust-lang/crates.io-index" 39 | checksum = "e242c601dec9711505f6d5bbff5bedd4b61b2469f2e8bb8e57ee7c9747a87ffd" 40 | dependencies = [ 41 | "fluent-langneg", 42 | "fluent-syntax", 43 | "intl-memoizer", 44 | "intl_pluralrules", 45 | "rustc-hash", 46 | "self_cell", 47 | "smallvec", 48 | "unic-langid", 49 | ] 50 | 51 | [[package]] 52 | name = "fluent-langneg" 53 | version = "0.13.0" 54 | source = "registry+https://github.com/rust-lang/crates.io-index" 55 | checksum = "2c4ad0989667548f06ccd0e306ed56b61bd4d35458d54df5ec7587c0e8ed5e94" 56 | dependencies = [ 57 | "unic-langid", 58 | ] 59 | 60 | [[package]] 61 | name = "fluent-syntax" 62 | version = "0.11.0" 63 | source = "registry+https://github.com/rust-lang/crates.io-index" 64 | checksum = "c0abed97648395c902868fee9026de96483933faa54ea3b40d652f7dfe61ca78" 65 | dependencies = [ 66 | "thiserror", 67 | ] 68 | 69 | [[package]] 70 | name = "fluent_native" 71 | version = "0.2.4" 72 | dependencies = [ 73 | "fluent", 74 | "intl-memoizer", 75 | "rustler", 76 | "unic-langid", 77 | ] 78 | 79 | [[package]] 80 | name = "heck" 81 | version = "0.4.1" 82 | source = "registry+https://github.com/rust-lang/crates.io-index" 83 | checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" 84 | 85 | [[package]] 86 | name = "intl-memoizer" 87 | version = "0.5.1" 88 | source = "registry+https://github.com/rust-lang/crates.io-index" 89 | checksum = "c310433e4a310918d6ed9243542a6b83ec1183df95dff8f23f87bb88a264a66f" 90 | dependencies = [ 91 | "type-map", 92 | "unic-langid", 93 | ] 94 | 95 | [[package]] 96 | name = "intl_pluralrules" 97 | version = "7.0.2" 98 | source = "registry+https://github.com/rust-lang/crates.io-index" 99 | checksum = "078ea7b7c29a2b4df841a7f6ac8775ff6074020c6776d48491ce2268e068f972" 100 | dependencies = [ 101 | "unic-langid", 102 | ] 103 | 104 | [[package]] 105 | name = "lazy_static" 106 | version = "1.4.0" 107 | source = "registry+https://github.com/rust-lang/crates.io-index" 108 | checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" 109 | 110 | [[package]] 111 | name = "memchr" 112 | version = "2.5.0" 113 | source = "registry+https://github.com/rust-lang/crates.io-index" 114 | checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d" 115 | 116 | [[package]] 117 | name = "proc-macro2" 118 | version = "1.0.64" 119 | source = "registry+https://github.com/rust-lang/crates.io-index" 120 | checksum = "78803b62cbf1f46fde80d7c0e803111524b9877184cfe7c3033659490ac7a7da" 121 | dependencies = [ 122 | "unicode-ident", 123 | ] 124 | 125 | [[package]] 126 | name = "quote" 127 | version = "1.0.29" 128 | source = "registry+https://github.com/rust-lang/crates.io-index" 129 | checksum = "573015e8ab27661678357f27dc26460738fd2b6c86e46f386fde94cb5d913105" 130 | dependencies = [ 131 | "proc-macro2", 132 | ] 133 | 134 | [[package]] 135 | name = "regex" 136 | version = "1.9.1" 137 | source = "registry+https://github.com/rust-lang/crates.io-index" 138 | checksum = "b2eae68fc220f7cf2532e4494aded17545fce192d59cd996e0fe7887f4ceb575" 139 | dependencies = [ 140 | "aho-corasick", 141 | "memchr", 142 | "regex-automata", 143 | "regex-syntax", 144 | ] 145 | 146 | [[package]] 147 | name = "regex-automata" 148 | version = "0.3.2" 149 | source = "registry+https://github.com/rust-lang/crates.io-index" 150 | checksum = "83d3daa6976cffb758ec878f108ba0e062a45b2d6ca3a2cca965338855476caf" 151 | dependencies = [ 152 | "aho-corasick", 153 | "memchr", 154 | "regex-syntax", 155 | ] 156 | 157 | [[package]] 158 | name = "regex-syntax" 159 | version = "0.7.4" 160 | source = "registry+https://github.com/rust-lang/crates.io-index" 161 | checksum = "e5ea92a5b6195c6ef2a0295ea818b312502c6fc94dde986c5553242e18fd4ce2" 162 | 163 | [[package]] 164 | name = "rustc-hash" 165 | version = "1.1.0" 166 | source = "registry+https://github.com/rust-lang/crates.io-index" 167 | checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" 168 | 169 | [[package]] 170 | name = "rustler" 171 | version = "0.29.1" 172 | source = "registry+https://github.com/rust-lang/crates.io-index" 173 | checksum = "0884cb623b9f43d3e2c51f9071c5e96a5acf3e6e6007866812884ff0cb983f1e" 174 | dependencies = [ 175 | "lazy_static", 176 | "rustler_codegen", 177 | "rustler_sys", 178 | ] 179 | 180 | [[package]] 181 | name = "rustler_codegen" 182 | version = "0.29.1" 183 | source = "registry+https://github.com/rust-lang/crates.io-index" 184 | checksum = "50e277af754f2560cf4c4ebedb68c1a735292fb354505c6133e47ec406e699cf" 185 | dependencies = [ 186 | "heck", 187 | "proc-macro2", 188 | "quote", 189 | "syn", 190 | ] 191 | 192 | [[package]] 193 | name = "rustler_sys" 194 | version = "2.3.1" 195 | source = "registry+https://github.com/rust-lang/crates.io-index" 196 | checksum = "0a7c0740e5322b64e2b952d8f0edce5f90fcf6f6fe74cca3f6e78eb3de5ea858" 197 | dependencies = [ 198 | "regex", 199 | "unreachable", 200 | ] 201 | 202 | [[package]] 203 | name = "self_cell" 204 | version = "0.10.2" 205 | source = "registry+https://github.com/rust-lang/crates.io-index" 206 | checksum = "1ef965a420fe14fdac7dd018862966a4c14094f900e1650bbc71ddd7d580c8af" 207 | 208 | [[package]] 209 | name = "smallvec" 210 | version = "1.11.0" 211 | source = "registry+https://github.com/rust-lang/crates.io-index" 212 | checksum = "62bb4feee49fdd9f707ef802e22365a35de4b7b299de4763d44bfea899442ff9" 213 | 214 | [[package]] 215 | name = "syn" 216 | version = "2.0.25" 217 | source = "registry+https://github.com/rust-lang/crates.io-index" 218 | checksum = "15e3fc8c0c74267e2df136e5e5fb656a464158aa57624053375eb9c8c6e25ae2" 219 | dependencies = [ 220 | "proc-macro2", 221 | "quote", 222 | "unicode-ident", 223 | ] 224 | 225 | [[package]] 226 | name = "thiserror" 227 | version = "1.0.43" 228 | source = "registry+https://github.com/rust-lang/crates.io-index" 229 | checksum = "a35fc5b8971143ca348fa6df4f024d4d55264f3468c71ad1c2f365b0a4d58c42" 230 | dependencies = [ 231 | "thiserror-impl", 232 | ] 233 | 234 | [[package]] 235 | name = "thiserror-impl" 236 | version = "1.0.43" 237 | source = "registry+https://github.com/rust-lang/crates.io-index" 238 | checksum = "463fe12d7993d3b327787537ce8dd4dfa058de32fc2b195ef3cde03dc4771e8f" 239 | dependencies = [ 240 | "proc-macro2", 241 | "quote", 242 | "syn", 243 | ] 244 | 245 | [[package]] 246 | name = "tinystr" 247 | version = "0.7.1" 248 | source = "registry+https://github.com/rust-lang/crates.io-index" 249 | checksum = "7ac3f5b6856e931e15e07b478e98c8045239829a65f9156d4fa7e7788197a5ef" 250 | dependencies = [ 251 | "displaydoc", 252 | ] 253 | 254 | [[package]] 255 | name = "type-map" 256 | version = "0.4.0" 257 | source = "registry+https://github.com/rust-lang/crates.io-index" 258 | checksum = "b6d3364c5e96cb2ad1603037ab253ddd34d7fb72a58bdddf4b7350760fc69a46" 259 | dependencies = [ 260 | "rustc-hash", 261 | ] 262 | 263 | [[package]] 264 | name = "unic-langid" 265 | version = "0.9.1" 266 | source = "registry+https://github.com/rust-lang/crates.io-index" 267 | checksum = "398f9ad7239db44fd0f80fe068d12ff22d78354080332a5077dc6f52f14dcf2f" 268 | dependencies = [ 269 | "unic-langid-impl", 270 | ] 271 | 272 | [[package]] 273 | name = "unic-langid-impl" 274 | version = "0.9.1" 275 | source = "registry+https://github.com/rust-lang/crates.io-index" 276 | checksum = "e35bfd2f2b8796545b55d7d3fd3e89a0613f68a0d1c8bc28cb7ff96b411a35ff" 277 | dependencies = [ 278 | "tinystr", 279 | ] 280 | 281 | [[package]] 282 | name = "unicode-ident" 283 | version = "1.0.10" 284 | source = "registry+https://github.com/rust-lang/crates.io-index" 285 | checksum = "22049a19f4a68748a168c0fc439f9516686aa045927ff767eca0a85101fb6e73" 286 | 287 | [[package]] 288 | name = "unreachable" 289 | version = "1.0.0" 290 | source = "registry+https://github.com/rust-lang/crates.io-index" 291 | checksum = "382810877fe448991dfc7f0dd6e3ae5d58088fd0ea5e35189655f84e6814fa56" 292 | dependencies = [ 293 | "void", 294 | ] 295 | 296 | [[package]] 297 | name = "void" 298 | version = "1.0.2" 299 | source = "registry+https://github.com/rust-lang/crates.io-index" 300 | checksum = "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d" 301 | -------------------------------------------------------------------------------- /native/fluent_native/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "fluent_native" 3 | version = "0.2.4" 4 | authors = [] 5 | edition = "2021" 6 | 7 | [lib] 8 | name = "fluent_native" 9 | path = "src/lib.rs" 10 | crate-type = ["cdylib"] 11 | 12 | [dependencies] 13 | intl-memoizer = "0.5.1" 14 | rustler = "0.29.1" 15 | fluent = "0.16.0" 16 | unic-langid = "0.9.1" 17 | -------------------------------------------------------------------------------- /native/fluent_native/Cross.toml: -------------------------------------------------------------------------------- 1 | [build.env] 2 | passthrough = [ 3 | "RUSTLER_NIF_VERSION" 4 | ] 5 | -------------------------------------------------------------------------------- /native/fluent_native/README.md: -------------------------------------------------------------------------------- 1 | # NIF for Elixir.Fluent.Native 2 | 3 | ## To load the NIF: 4 | 5 | ```elixir 6 | defmodule Fluent.Native do 7 | use Rustler, otp_app: , crate: "fluent_native" 8 | 9 | # When your NIF is loaded, it will override this function. 10 | def add(_a, _b), do: :erlang.nif_error(:nif_not_loaded) 11 | end 12 | ``` 13 | 14 | ## Examples 15 | 16 | [This](https://github.com/hansihe/NifIo) is a complete example of a NIF written in Rust. 17 | -------------------------------------------------------------------------------- /native/fluent_native/src/lib.rs: -------------------------------------------------------------------------------- 1 | use rustler::ResourceArc; 2 | use rustler::{Encoder, Env, Error, Term, TermType}; 3 | use std::sync::RwLock; 4 | 5 | use fluent::bundle::FluentBundle; 6 | use fluent::{FluentArgs, FluentResource, FluentValue}; 7 | use intl_memoizer::concurrent::IntlLangMemoizer; 8 | use unic_langid::LanguageIdentifier; 9 | 10 | mod atoms { 11 | rustler::atoms! { 12 | ok, 13 | error, 14 | 15 | bad_locale, 16 | bad_resource, 17 | bad_msg, 18 | no_value, 19 | 20 | use_isolating, 21 | } 22 | } 23 | 24 | struct Container { 25 | data: RwLock>, 26 | } 27 | 28 | fn on_init<'a>(env: Env<'a>, _load_info: Term<'a>) -> bool { 29 | rustler::resource!(Container, env); 30 | true 31 | } 32 | 33 | #[rustler::nif] 34 | fn init<'a>( 35 | env: Env<'a>, 36 | lang: String, 37 | bundle_init_keyword: Vec<(rustler::types::Atom, Term)>, 38 | ) -> Result, Error> { 39 | // Getting language 40 | let lang_id = match lang.parse::() { 41 | Ok(lang_id) => lang_id, 42 | Err(_e) => return Ok((atoms::error(), (atoms::bad_locale(), lang)).encode(env)), 43 | }; 44 | // Initializing bundle 45 | let mut bundle = FluentBundle::new_concurrent(vec![lang_id]); 46 | 47 | // Setting different configs and flags 48 | for (key, value) in bundle_init_keyword { 49 | if key == atoms::use_isolating() { 50 | let use_isolating_value: bool = value.decode()?; 51 | bundle.set_use_isolating(use_isolating_value); 52 | } 53 | } 54 | let bundle = Container { 55 | data: RwLock::new(bundle), 56 | }; 57 | 58 | Ok((atoms::ok(), ResourceArc::new(bundle)).encode(env)) 59 | } 60 | 61 | #[rustler::nif] 62 | fn with_resource( 63 | env: Env<'_>, 64 | container: ResourceArc, 65 | source: String, 66 | ) -> Result, Error> { 67 | // Initializing resource 68 | let resource = match FluentResource::try_new(source) { 69 | Ok(resource) => resource, 70 | Err((_resource, _error)) => return Ok((atoms::error(), atoms::bad_resource()).encode(env)), 71 | }; 72 | 73 | // Locking bundle to write 74 | let mut bundle = container.data.write().unwrap(); 75 | match bundle.add_resource(resource) { 76 | Ok(_value) => Ok(atoms::ok().encode(env)), 77 | Err(_e) => return Ok((atoms::error(), atoms::bad_resource()).encode(env)), 78 | } 79 | } 80 | 81 | #[rustler::nif] 82 | fn format_pattern<'a>( 83 | env: Env<'a>, 84 | container: ResourceArc, 85 | msg_id: String, 86 | arg_ids: Vec<(rustler::types::Atom, Term)>, 87 | ) -> Result, Error> { 88 | // Reconfiguring args 89 | let arg_ids: Vec<(String, FluentValue)> = arg_ids 90 | .into_iter() 91 | .map(|(key, value)| { 92 | let fluent_value: FluentValue = match &value.get_type() { 93 | TermType::Binary => match value.decode::() { 94 | Ok(string) => FluentValue::from(string), 95 | Err(_e) => panic!("Mismatched types"), 96 | }, 97 | TermType::Number => match value.decode::() { 98 | Ok(float) => FluentValue::from(float), 99 | Err(_e) => match value.decode::() { 100 | Ok(integer) => FluentValue::from(integer), 101 | Err(_e) => panic!("Mismatched types"), 102 | }, 103 | }, 104 | _ => panic!("Mismatched types"), // TODO: Add error handling 105 | }; 106 | (format!("{:?}", key), fluent_value) 107 | }) 108 | .collect(); 109 | 110 | // Locking bundle to write 111 | let bundle = container.data.read().unwrap(); 112 | 113 | // Getting message 114 | let msg = match bundle.get_message(&msg_id) { 115 | Some(msg) => msg, 116 | None => return Ok((atoms::error(), atoms::bad_msg()).encode(env)), 117 | }; 118 | 119 | // Getting args 120 | let mut args = FluentArgs::new(); 121 | for (key, value) in &arg_ids { 122 | args.set(key, value.clone()); 123 | } 124 | 125 | // Getting errors 126 | let mut errors = vec![]; 127 | 128 | // Formatting pattern 129 | let pattern = match msg.value() { 130 | Some(value) => value, 131 | None => return Ok((atoms::error(), atoms::no_value()).encode(env)), 132 | }; 133 | 134 | let value = bundle.format_pattern(pattern, Some(&args), &mut errors); 135 | 136 | Ok((atoms::ok(), value.into_owned()).encode(env)) 137 | } 138 | 139 | #[rustler::nif] 140 | fn assert_locale(env: Env<'_>, lang: String) -> Result, Error> { 141 | // Getting language 142 | match lang.parse::() { 143 | Ok(lang_id) => Ok((atoms::ok(), &lang_id.to_string()).encode(env)), 144 | Err(_e) => Ok((atoms::error(), (atoms::bad_locale(), lang)).encode(env)), 145 | } 146 | } 147 | 148 | rustler::init!( 149 | "Elixir.Fluent.Native", 150 | [init, with_resource, format_pattern, assert_locale], 151 | load = on_init 152 | ); 153 | 154 | #[cfg(test)] 155 | mod test { 156 | use crate::*; 157 | 158 | fn assert_send_and_sync() {} 159 | 160 | #[test] 161 | fn it_is_send_and_sync() { 162 | assert_send_and_sync::(); 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /priv/fluent/en/hello.ftl: -------------------------------------------------------------------------------- 1 | # Simple things are simple. 2 | hello-user = Hello, {$userName}! 3 | 4 | # Complex things are possible. 5 | shared-photos = 6 | {$userName} {$photoCount -> 7 | [one] added a new photo 8 | *[other] added {$photoCount} new photos 9 | } to {$userGender -> 10 | [male] his stream 11 | [female] her stream 12 | *[other] their stream 13 | }. -------------------------------------------------------------------------------- /priv/fluent/en/tabs.ftl: -------------------------------------------------------------------------------- 1 | ## Closing tabs 2 | 3 | tabs-close-button = Close 4 | tabs-close-tooltip = {$tabCount -> 5 | [one] Close {$tabCount} tab 6 | *[other] Close {$tabCount} tabs 7 | } 8 | tabs-close-warning = 9 | You are about to close {$tabCount} tabs. 10 | Are you sure you want to continue? 11 | 12 | ## Syncing 13 | 14 | -sync-brand-name = Firefox Account 15 | 16 | sync-dialog-title = {-sync-brand-name} 17 | sync-headline-title = 18 | {-sync-brand-name}: The best way to bring 19 | your data always with you 20 | sync-signedout-title = 21 | Connect with your {-sync-brand-name} 22 | -------------------------------------------------------------------------------- /priv/fluent/it/tabs.ftl: -------------------------------------------------------------------------------- 1 | ## Closing tabs 2 | 3 | tabs-close-button = Chiudi 4 | tabs-close-tooltip = {$tabCount -> 5 | [one] Chiudi {$tabCount} scheda 6 | *[other] Chiudi {$tabCount} schede 7 | } 8 | tabs-close-warning = 9 | Verranno chiuse {$tabCount} schede. Proseguire? 10 | 11 | ## Syncing 12 | 13 | -sync-brand-name = {$first -> 14 | *[uppercase] Account Firefox 15 | [lowercase] account Firefox 16 | } 17 | 18 | sync-dialog-title = {-sync-brand-name} 19 | sync-headline-title = 20 | {-sync-brand-name}: il modo migliore 21 | per avere i tuoi dati sempre con te 22 | sync-signedout-title = 23 | Connetti il tuo {-sync-brand-name(first: "lowercase")} -------------------------------------------------------------------------------- /priv/fluent/ru/tabs.ftl: -------------------------------------------------------------------------------- 1 | ## Closing tabs 2 | 3 | tabs-close-button = Закрыть 4 | tabs-close-tooltip = {$tabCount -> 5 | [one] Закрыть {$tabCount} вкладку 6 | [few] Закрыть {$tabCount} вкладки 7 | [many] Закрыть {$tabCount} вкладок 8 | *[other] Закрыть {$tabCount} вкладок 9 | } 10 | tabs-close-warning = 11 | Вы собираетесь закрыть {$tabCount} вкладок. 12 | Вы уверены, что хотите продолжить? 13 | 14 | ## Syncing 15 | 16 | -sync-brand-name = Аккаунт Firefox 17 | 18 | sync-dialog-title = {-sync-brand-name} 19 | sync-headline-title = 20 | {-sync-brand-name}: Лучший способ всегда 21 | иметь доступ к вашим данным 22 | sync-signedout-title = 23 | Синхронизируйтесь с вашим {-sync-brand-name} 24 | -------------------------------------------------------------------------------- /test/fluent/native_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Fluent.NativeTest do 2 | use ExUnit.Case, async: true 3 | 4 | describe "Fluent Native init" do 5 | test "it initializes bundle with valid locale" do 6 | assert {:ok, _} = Fluent.Native.init("en") 7 | end 8 | 9 | test "it fails with init for invalid locale" do 10 | assert {:error, {:bad_locale, "this-is-wrong-locale"}} = 11 | Fluent.Native.init("this-is-wrong-locale") 12 | end 13 | end 14 | 15 | describe "with_resource" do 16 | setup do 17 | case Fluent.Native.init("en") do 18 | {:ok, reference} -> %{bundle: reference} 19 | _ -> :error 20 | end 21 | end 22 | 23 | test "it adds valid resource", %{bundle: bundle} do 24 | assert :ok = Fluent.Native.with_resource(bundle, "a = A") 25 | end 26 | 27 | test "it fails with bad resource", %{bundle: bundle} do 28 | assert {:error, :bad_resource} = Fluent.Native.with_resource(bundle, "bad resource") 29 | end 30 | 31 | test "it adds message to the bundle", %{bundle: bundle} do 32 | assert {:error, :bad_msg} = Fluent.Native.format_pattern(bundle, "a", []) 33 | Fluent.Native.with_resource(bundle, "a = A") 34 | assert {:ok, "A"} = Fluent.Native.format_pattern(bundle, "a", []) 35 | end 36 | 37 | test "it uses isolation by default", %{bundle: bundle} do 38 | assert {:error, :bad_msg} = Fluent.Native.format_pattern(bundle, "a", []) 39 | Fluent.Native.with_resource(bundle, "hello-user = Hello, {$userName}!") 40 | 41 | assert {:ok, "Hello, \u2068Test User\u2069!"} = 42 | Fluent.Native.format_pattern(bundle, "hello-user", userName: "Test User") 43 | end 44 | end 45 | 46 | describe "without isolation" do 47 | setup do 48 | case Fluent.Native.init("en", use_isolating: false) do 49 | {:ok, reference} -> %{bundle: reference} 50 | _ -> :error 51 | end 52 | end 53 | 54 | test "it uses no isolation", %{bundle: bundle} do 55 | assert {:error, :bad_msg} = Fluent.Native.format_pattern(bundle, "a", []) 56 | Fluent.Native.with_resource(bundle, "hello-user = Hello, {$userName}!") 57 | 58 | assert {:ok, "Hello, Test User!"} = 59 | Fluent.Native.format_pattern(bundle, "hello-user", userName: "Test User") 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /test/fluent_test.exs: -------------------------------------------------------------------------------- 1 | defmodule FluentTest do 2 | use ExUnit.Case, async: true 3 | 4 | describe "put_locale" do 5 | test "it puts locale in current process store" do 6 | Fluent.put_locale("en-US") 7 | assert "en-US" = Process.get(Fluent) 8 | end 9 | 10 | test "it rises if the locale is not binary" do 11 | assert_raise ArgumentError, fn -> 12 | Fluent.put_locale(1234) 13 | end 14 | end 15 | end 16 | 17 | describe "get_locale" do 18 | test "it gets default's Fluent Assembly default locale" do 19 | assert Fluent.get_locale(Assembly.Empty) == "en" 20 | end 21 | 22 | test "it get overrided Fluent Assembly default locale" do 23 | assert Fluent.get_locale(Assembly.DefaultLanguageChanged) == "es" 24 | end 25 | 26 | test "it get global Fluent locale if defined" do 27 | Fluent.put_locale("jp") 28 | assert Fluent.get_locale(Assembly.Empty) == "jp" 29 | end 30 | 31 | test "it get defined for current process assembly locale" do 32 | Fluent.put_locale(Assembly.Empty, "fr") 33 | Fluent.put_locale(Assembly.DefaultLanguageChanged, "ru") 34 | assert Fluent.get_locale(Assembly.Empty) == "fr" 35 | assert Fluent.get_locale(Assembly.DefaultLanguageChanged) == "ru" 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /test/support/test_assemblies.ex: -------------------------------------------------------------------------------- 1 | defmodule Assembly.Empty do 2 | @moduledoc false 3 | use Fluent.Assembly, otp_app: :libfluent 4 | end 5 | 6 | defmodule Assembly.WithPriv do 7 | @moduledoc false 8 | use Fluent.Assembly, otp_app: :libfluent, priv: "another-priv-folder" 9 | end 10 | 11 | defmodule Assembly.DefaultLanguageChanged do 12 | @moduledoc false 13 | use Fluent.Assembly, otp_app: :libfluent, default_locale: "es" 14 | end 15 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | --------------------------------------------------------------------------------