├── mise.toml ├── test ├── test_helper.exs ├── paassaa │ ├── data_test.exs │ └── install_version_test.exs └── paasaa_test.exs ├── .formatter.exs ├── .gitignore ├── lib ├── paasaa │ ├── data.ex │ └── scripts.ex └── paasaa.ex ├── script ├── generate_language_list.exs └── generate_language_data.exs ├── LICENSE ├── CHANGELOG.md ├── mix.exs ├── README.md ├── LANGUAGES.md ├── .github └── workflows │ └── elixir.yml └── mix.lock /mise.toml: -------------------------------------------------------------------------------- 1 | [tools] 2 | elixir = "latest" 3 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | inputs: ["*.{ex,exs}", "{config,lib,priv,test,script}/**/*.{ex,exs}"] 3 | ] 4 | -------------------------------------------------------------------------------- /test/paassaa/data_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Paasaa.DataTest do 2 | use ExUnit.Case, async: true 3 | 4 | test "scripts/0" do 5 | assert [{"Arabic", _} | _] = Paasaa.Data.scripts() 6 | end 7 | 8 | test "languages/0" do 9 | languages = Paasaa.Data.languages() 10 | assert is_map(languages) 11 | 12 | latin = languages["Latin"] 13 | 14 | assert is_list(latin) 15 | assert [{lang, %{}} | _] = latin 16 | assert is_binary(lang) 17 | assert String.length(lang) == 3 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /.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 3rd-party dependencies like ExDoc output generated docs. 11 | /doc 12 | 13 | # If the VM crashes, it generates a dump, let's ignore it too. 14 | erl_crash.dump 15 | 16 | # Also ignore archive artifacts (built via "mix archive.build"). 17 | *.ez 18 | bench/snapshots 19 | .elixir_ls 20 | 21 | .DS_Store 22 | -------------------------------------------------------------------------------- /lib/paasaa/data.ex: -------------------------------------------------------------------------------- 1 | defmodule Paasaa.Data do 2 | @moduledoc false 3 | 4 | def scripts do 5 | Paasaa.Scripts.get() 6 | |> Enum.map(fn {name, expr} -> {name, Regex.compile!(expr, "u")} end) 7 | end 8 | 9 | def languages do 10 | Paasaa.Languages.get() 11 | |> Enum.map(&parse_trigrams/1) 12 | |> Enum.into(%{}) 13 | end 14 | 15 | defp parse_trigrams({script, langs}), do: {script, parse_trigrams(langs)} 16 | 17 | defp parse_trigrams(langs) when is_map(langs) do 18 | Enum.map(langs, fn {lang, trigrams} -> 19 | {lang, parse_trigrams(trigrams)} 20 | end) 21 | end 22 | 23 | defp parse_trigrams(trigrams_str) when is_binary(trigrams_str) do 24 | trigrams_str 25 | |> String.split("|") 26 | |> Enum.with_index() 27 | |> Enum.into(%{}) 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /test/paassaa/install_version_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Paasaa.InstallVersionTest do 2 | use ExUnit.Case, async: true 3 | 4 | test "install version check" do 5 | assert_version("README.md") 6 | end 7 | 8 | defp assert_version(filename) do 9 | app = Keyword.get(Mix.Project.config(), :app) 10 | app_version = app |> Application.spec(:vsn) |> to_string() 11 | 12 | file = File.read!(filename) 13 | [_, file_versions] = Regex.run(~r/{:#{app}, "(.+)"}/, file) 14 | 15 | assert Version.match?( 16 | app_version, 17 | file_versions 18 | ), 19 | """ 20 | Install version constraint in `#{filename}` does not match to current app version. 21 | Current App Version: #{app_version} 22 | `#{filename}` Install Versions: #{file_versions} 23 | """ 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /script/generate_language_list.exs: -------------------------------------------------------------------------------- 1 | defmodule LanguageListGenerator do 2 | def generate do 3 | # Get and sort language codes 4 | language_codes = 5 | Paasaa.Languages.get() 6 | |> Enum.flat_map(fn {_script_name, languages_map} -> 7 | Map.keys(languages_map) 8 | end) 9 | |> Enum.sort() 10 | 11 | # Count total languages 12 | total_languages = Enum.count(language_codes) 13 | 14 | # Generate table header 15 | header = "| ISO Code | Language Name |\n|----------|---------------|" 16 | 17 | # Generate language data rows 18 | languages_data = 19 | language_codes 20 | |> Enum.map(fn code -> 21 | case IsoLang.get(code) do 22 | {:ok, %{name: name}} -> 23 | "| #{code} | #{name} |" 24 | 25 | _ -> 26 | "| #{code} | Unknown Language |" 27 | end 28 | end) 29 | |> Enum.join("\n") 30 | 31 | "Total Languages: #{total_languages}\n\n#{header}\n#{languages_data}" 32 | end 33 | end 34 | 35 | File.write!("LANGUAGES.md", "# Supported Languages\n\n" <> LanguageListGenerator.generate()) 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | (The MIT License) 2 | 3 | Copyright (c) 2016 Egor Kislitsyn 4 | Copyright (c) 2014-2015 Titus Wormer 5 | Copyright (c) 2008 Kent S Johnson 6 | Copyright (c) 2006 Jacob R Rideout 7 | Copyright (c) 2004 Maciej Ceglowski 8 | 9 | Permission is hereby granted, free of charge, to any person obtaining 10 | a copy of this software and associated documentation files (the 11 | 'Software'), to deal in the Software without restriction, including 12 | without limitation the rights to use, copy, modify, merge, publish, 13 | distribute, sublicense, and/or sell copies of the Software, and to 14 | permit persons to whom the Software is furnished to do so, subject to 15 | the following conditions: 16 | 17 | The above copyright notice and this permission notice shall be 18 | included in all copies or substantial portions of the Software. 19 | 20 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, 21 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 22 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 23 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 24 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 25 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 26 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 27 | -------------------------------------------------------------------------------- /script/generate_language_data.exs: -------------------------------------------------------------------------------- 1 | ################################################################################ 2 | # This script is used to port over files and data from the 3 | # https://github.com/wooorm/franc/ project 4 | ################################################################################ 5 | 6 | ####### Languages ######## 7 | 8 | languages_url = "https://raw.githubusercontent.com/wooorm/franc/main/packages/franc/data.js" 9 | 10 | %{body: body} = Req.get!(languages_url) 11 | 12 | languages = 13 | Regex.replace(~r/^.*=\s?/sU, body, "") 14 | |> String.replace(~r/\s*(\w+):/, "\n \"\\1\":") 15 | |> String.replace(~r/'/, "\"") 16 | |> :jsx.decode() 17 | |> inspect(limit: :infinity) 18 | 19 | languages_ex = 20 | """ 21 | defmodule Paasaa.Languages do 22 | @moduledoc false 23 | 24 | @data #{languages} 25 | 26 | def get, do: @data 27 | end 28 | 29 | """ 30 | |> Code.format_string!() 31 | 32 | File.write!("./lib/paasaa/languages.ex", languages_ex ++ "\n") 33 | 34 | ######## Scripts ######## 35 | 36 | scripts_url = "https://raw.githubusercontent.com/wooorm/franc/main/packages/franc/expressions.js" 37 | 38 | %{body: body} = Req.get!(scripts_url) 39 | 40 | scripts = 41 | Regex.replace(~r/^.*=\s?/sU, body, "") 42 | |> String.replace(~r/\s*(\w+):/, "\n \"\\1\":") 43 | |> String.replace("/[", "\"[") 44 | |> String.replace("]/g", "]\"") 45 | |> :jsx.decode() 46 | |> Enum.into(%{}) 47 | |> inspect(limit: :infinity) 48 | 49 | scripts_ex = 50 | """ 51 | defmodule Paasaa.Scripts do 52 | @moduledoc false 53 | 54 | @data #{scripts} 55 | 56 | def get, do: @data 57 | end 58 | """ 59 | |> Code.format_string!() 60 | 61 | File.write!("./lib/paasaa/scripts.ex", scripts_ex ++ "\n") 62 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 1.0.0 - 2025-09-06 4 | 5 | After 9 years of stable service, it's finally time for v1.0.0! 6 | 7 | ### Added 8 | 9 | - Added a language reference list with ISO codes and names. 10 | 11 | ### Changed 12 | 13 | - Drop support for Elixir v1.17 and lower. Minimum supported version is now Elixir v1.18. 14 | - Replaced `Tesla` dependency with `Req`. 15 | 16 | ## 0.6.0 - 2022-06-12 17 | 18 | ### Added 19 | 20 | - Adds parsing of the `data.js` file to `generate_language_data.exs` script. 21 | 22 | ### Changed 23 | 24 | - Drops support for Elixir v1.9 and lower. 25 | - Updates `languages_url` target in `generate_language_data.exs` script so it downloads `data.js` (the old `data.json` file was removed in ). 26 | - Refactors script to use `:jsx.decode()` to decode the JSON instead of `Jason` because `Jason` is not a direct dependency of `paasaa`. 27 | - Updates `languages.ex` and `scripts.ex` to reflect latest changes in package per the updated `data.js` file. 28 | - Manually updates `fixtures.ex` to include the latest fixture data. 29 | - Updates all hex dependencies to latest. 30 | - Updates doctests to reflect statistical changes due to language updates. 31 | - Updates tests to utilize modified fixture structure. 32 | - Updates tests to assert on all available languages/fixtures for minimum 98% certainty (instead of limiting the test coverage to the first 10 languages). 33 | 34 | ## 0.5.1 - 2019-07-07 35 | 36 | ### Fixed 37 | 38 | - White and black lists now works for scripts too. 39 | 40 | ### Changed 41 | 42 | - Update language data 43 | - Update dependencies 44 | 45 | ## 0.5.0 - 2019-10-31 46 | 47 | ### Added 48 | 49 | - A script to update language data 50 | 51 | ### Changed 52 | 53 | - Update language data 54 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Paasaa.Mixfile do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :paasaa, 7 | version: "1.0.0", 8 | elixir: "~> 1.18", 9 | elixirc_paths: elixirc_paths(Mix.env()), 10 | description: description(), 11 | package: package(), 12 | deps: deps(), 13 | test_coverage: [tool: ExCoveralls], 14 | # Docs 15 | name: "Paasaa", 16 | source_url: "https://github.com/minibikini/paasaa", 17 | homepage_url: "https://github.com/minibikini/paasaa", 18 | docs: [ 19 | main: "readme", 20 | extras: ["README.md", "LANGUAGES.md"] 21 | ] 22 | ] 23 | end 24 | 25 | def application do 26 | [extra_applications: [:logger]] 27 | end 28 | 29 | defp elixirc_paths(:test), do: ["lib", "test/support"] 30 | defp elixirc_paths(_), do: ["lib"] 31 | 32 | def cli do 33 | [ 34 | preferred_envs: [ 35 | coveralls: :test, 36 | "coveralls.detail": :test, 37 | "coveralls.post": :test, 38 | "coveralls.html": :test 39 | ] 40 | ] 41 | end 42 | 43 | defp deps do 44 | [ 45 | {:ex_doc, "~> 0.28", only: :dev}, 46 | {:credo, "~> 1.7.0", only: [:dev, :test]}, 47 | {:excoveralls, "~> 0.18.3", only: :test}, 48 | {:jsx, "~> 3.1.0", only: :dev}, 49 | {:req, "~> 0.5.15", only: [:dev]}, 50 | {:dialyxir, "~> 1.4.3", only: [:dev], runtime: false}, 51 | {:iso_lang, "~> 0.4.0", only: [:dev], runtime: false} 52 | ] 53 | end 54 | 55 | defp description do 56 | """ 57 | Natural language detection 58 | """ 59 | end 60 | 61 | defp package do 62 | [ 63 | name: :paasaa, 64 | files: ["lib", "mix.exs", "README*", "LICENSE*"], 65 | maintainers: ["Egor Kislitsyn"], 66 | licenses: ["MIT"], 67 | links: %{"GitHub" => "https://github.com/minibikini/paasaa"} 68 | ] 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /lib/paasaa/scripts.ex: -------------------------------------------------------------------------------- 1 | defmodule Paasaa.Scripts do 2 | @moduledoc false 3 | 4 | @data %{ 5 | "Arabic" => 6 | "[؀-؄؆-؋؍-ؚ؜-؞ؠ-ؿف-يٖ-ٯٱ-ۜ۞-ۿݐ-ݿࡰ-ࢎ࢐࢑࢘-ࣣ࣡-ࣿﭐ-﯂ﯓ-ﴽ﵀-ﶏﶒ-ﷇ﷏ﷰ-﷿ﹰ-ﹴﹶ-ﻼ]|�[�-�]|�[�-��-������-��-��������-�������������-��-��-��-���-��-��-��-��-���]", 7 | "Cyrillic" => "[Ѐ-҄҇-ԯᲀ-ᲈᴫᵸⷠ-ⷿꙀ-ꚟ︮︯]", 8 | "Devanagari" => "[ऀ-ॐॕ-ॣ०-ॿ꣠-ꣿ]", 9 | "Ethiopic" => 10 | "[ሀ-ቈቊ-ቍቐ-ቖቘቚ-ቝበ-ኈኊ-ኍነ-ኰኲ-ኵኸ-ኾዀዂ-ዅወ-ዖዘ-ጐጒ-ጕጘ-ፚ፝-፼ᎀ-᎙ⶀ-ⶖⶠ-ⶦⶨ-ⶮⶰ-ⶶⶸ-ⶾⷀ-ⷆⷈ-ⷎⷐ-ⷖⷘ-ⷞꬁ-ꬆꬉ-ꬎꬑ-ꬖꬠ-ꬦꬨ-ꬮ]|�[�-��-����-�]", 11 | "Hebrew" => "[֑-ׇא-תׯ-״יִ-זּטּ-לּמּנּסּףּפּצּ-ﭏ]", 12 | "Latin" => 13 | "[A-Za-zªºÀ-ÖØ-öø-ʸˠ-ˤᴀ-ᴥᴬ-ᵜᵢ-ᵥᵫ-ᵷᵹ-ᶾḀ-ỿⁱⁿₐ-ₜKÅℲⅎⅠ-ↈⱠ-ⱿꜢ-ꞇꞋ-ꟊꟐꟑꟓꟕ-ꟙꟲ-ꟿꬰ-ꭚꭜ-ꭤꭦ-ꭩff-stA-Za-z]|�[�-��-��-�]|�[�-�]", 14 | "Myanmar" => "[က-႟ꧠ-ꧾꩠ-ꩿ]", 15 | "aii" => "[܀-܍܏-݊ݍ-ݏࡠ-ࡪ]", 16 | "ben" => "[ঀ-ঃঅ-ঌএঐও-নপ-রলশ-হ়-ৄেৈো-ৎৗড়ঢ়য়-ৣ০-৾]", 17 | "bod" => "[ༀ-ཇཉ-ཬཱ-ྗྙ-ྼ྾-࿌࿎-࿔࿙࿚]", 18 | "cmn" => 19 | "[⺀-⺙⺛-⻳⼀-⿕々〇〡-〩〸-〻㐀-䶿一-鿿豈-舘並-龎]|�[����]|[�-��-��-��-��-�][�-�]|�[�-��-�]|�[�-��-�]|�[�-��-�]|�[�-��-�]|�[�-�]|�[�-�]|�[�-�]", 20 | "ell" => 21 | "[Ͱ-ͳ͵-ͷͺ-ͽͿ΄ΆΈ-ΊΌΎ-ΡΣ-ϡϰ-Ͽᴦ-ᴪᵝ-ᵡᵦ-ᵪᶿἀ-ἕἘ-Ἕἠ-ὅὈ-Ὅὐ-ὗὙὛὝὟ-ώᾀ-ᾴᾶ-ῄῆ-ΐῖ-Ί῝-`ῲ-ῴῶ-῾Ωꭥ]|�[�-��]|�[�-�]", 22 | "guj" => "[ઁ-ઃઅ-ઍએ-ઑઓ-નપ-રલળવ-હ઼-ૅે-ૉો-્ૐૠ-ૣ૦-૱ૹ-૿]", 23 | "hye" => "[Ա-Ֆՙ-֊֍-֏ﬓ-ﬗ]", 24 | "iii" => "[ꀀ-ꒌ꒐-꓆]", 25 | "jav" => "[ꦀ-꧍꧐-꧙꧞꧟]", 26 | "jpn" => "[ぁ-ゖゝ-ゟ]|�[�-��-�]|🈀|[ァ-ヺヽ-ヿㇰ-ㇿ㋐-㋾㌀-㍗ヲ-ッア-ン]|�[�-��-���]|�[��-��-�]|[㐀-䶵一-龯]", 27 | "kan" => "[ಀ-ಌಎ-ಐಒ-ನಪ-ಳವ-ಹ಼-ೄೆ-ೈೊ-್ೕೖೝೞೠ-ೣ೦-೯ೱೲ]", 28 | "kat" => "[Ⴀ-ჅჇჍა-ჺჼ-ჿᲐ-ᲺᲽ-Ჿⴀ-ⴥⴧⴭ]", 29 | "khm" => "[ក-៝០-៩៰-៹᧠-᧿]", 30 | "kor" => "[ᄀ-ᇿ〮〯ㄱ-ㆎ㈀-㈞㉠-㉾ꥠ-ꥼ가-힣ힰ-ퟆퟋ-ퟻᅠ-하-ᅦᅧ-ᅬᅭ-ᅲᅳ-ᅵ]", 31 | "lao" => "[ກຂຄຆ-ຊຌ-ຣລວ-ຽເ-ໄໆ່-ໍ໐-໙ໜ-ໟ]", 32 | "mal" => "[ഀ-ഌഎ-ഐഒ-ൄെ-ൈൊ-൏ൔ-ൣ൦-ൿ]", 33 | "pan" => "[ਁ-ਃਅ-ਊਏਐਓ-ਨਪ-ਰਲਲ਼ਵਸ਼ਸਹ਼ਾ-ੂੇੈੋ-੍ੑਖ਼-ੜਫ਼੦-੶]", 34 | "sat" => "[᱐-᱿]", 35 | "sin" => "[ඁ-ඃඅ-ඖක-නඳ-රලව-ෆ්ා-ුූෘ-ෟ෦-෯ෲ-෴]|�[�-�]", 36 | "tam" => "[ஂஃஅ-ஊஎ-ஐஒ-கஙசஜஞடணதந-பம-ஹா-ூெ-ைொ-்ௐௗ௦-௺]|�[�-��]", 37 | "tel" => "[ఀ-ఌఎ-ఐఒ-నప-హ఼-ౄె-ైొ-్ౕౖౘ-ౚౝౠ-ౣ౦-౯౷-౿]", 38 | "tha" => "[ก-ฺเ-๛]", 39 | "zgh" => "[ⴰ-ⵧⵯ⵰⵿]" 40 | } 41 | 42 | def get, do: @data 43 | end 44 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Paasaa 2 | 3 | [![Elixir CI](https://github.com/minibikini/paasaa/actions/workflows/elixir.yml/badge.svg)](https://github.com/minibikini/paasaa/actions/workflows/elixir.yml) 4 | [![Coverage Status](https://coveralls.io/repos/github/minibikini/paasaa/badge.svg?branch=master)](https://coveralls.io/github/minibikini/paasaa?branch=master) 5 | [![Hex.pm](https://img.shields.io/hexpm/v/paasaa.svg?maxAge=2592001)](https://hex.pm/packages/paasaa) 6 | [![Hex.pm](https://img.shields.io/hexpm/l/paasaa.svg?maxAge=2592000)](https://hex.pm/packages/paasaa) 7 | 8 | Paasaa is an Elixir library for robust natural language and script detection. It achieves this through statistical analysis of character n-grams and Unicode script properties, without relying on AI. It helps in tasks like text processing, natural language understanding, or internationalization by accurately identifying the writing system and human language of a given text. 9 | 10 | [API Documentation](https://hexdocs.pm/paasaa/) | [Hex Package](https://hex.pm/packages/paasaa) 11 | 12 | ## Installation 13 | 14 | Add `paasaa` to your list of dependencies in `mix.exs`: 15 | 16 | ```elixir 17 | def deps do 18 | [{:paasaa, "~> 1.0.0"}] 19 | end 20 | ``` 21 | 22 | After you are done, run `mix deps.get` in your shell to fetch and compile **Paasaa**. 23 | 24 | ## Usage 25 | 26 | Detect a language: 27 | 28 | ```elixir 29 | iex> Paasaa.detect("Detect this!") 30 | "eng" 31 | ``` 32 | 33 | Detect language and return a scored list of languages: 34 | 35 | ```elixir 36 | iex> Paasaa.all("Detect this!") 37 | [ 38 | {"eng", 1.0}, 39 | {"sco", 0.8230731943771207}, 40 | {"nob", 0.6030053320407174}, 41 | {"nno", 0.5525933107125545}, 42 | ... 43 | ] 44 | ``` 45 | 46 | Detect a script: 47 | 48 | ```elixir 49 | iex> Paasaa.detect_script("Detect this!") 50 | {"Latin", 0.8333333333333334} 51 | ``` 52 | 53 | ### Advanced Usage with Options 54 | 55 | The `detect/2` and `all/2` functions accept a keyword list of options to control their behavior. 56 | 57 | **Whitelist and Blacklist Languages** 58 | 59 | You can restrict the set of possible languages. This is useful if you already know the text must be one of a few languages, or you want to exclude a common false positive. 60 | 61 | ```elixir 62 | # Exclude English to find the next most likely language 63 | iex> Paasaa.detect("Detect this!", blacklist: ["eng"]) 64 | "sco" 65 | 66 | # Only consider Polish and Serbian 67 | iex> text = "Pošto je priznavanje urođenog dostojanstva i jednakih i neotuđivih prava..." 68 | iex> Paasaa.detect(text, whitelist: ["pol", "srp"]) 69 | "srp" 70 | ``` 71 | 72 | **Set Minimum Text Length** 73 | 74 | By default, Paasaa returns `"und"` for very short strings. You can adjust this threshold with `:min_length`. 75 | 76 | ```elixir 77 | iex> Paasaa.detect("Привет", min_length: 10) 78 | "und" 79 | 80 | iex> Paasaa.detect("Привет", min_length: 6) 81 | "rus" 82 | ``` 83 | 84 | ## Supported Languages 85 | 86 | For a full list of supported languages, please see [LANGUAGES.md](LANGUAGES.md). 87 | 88 | ## Contributing 89 | 90 | Contributions are welcome! Please feel free to open an issue or submit a pull request on [GitHub](https://github.com/minibikini/paasaa). 91 | 92 | If you are updating the language data, you can regenerate the necessary modules with the following command: 93 | 94 | ```shell 95 | mix run script/generate_language_data.exs 96 | ``` 97 | 98 | ## Derivation 99 | 100 | **Paasaa** is a derivative work from [Franc](https://github.com/wooorm/franc/) (JavaScript, MIT) by Titus Wormer. 101 | 102 | ## License 103 | 104 | MIT © Egor Kislitsyn 105 | -------------------------------------------------------------------------------- /test/paasaa_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PaasaaTest do 2 | use ExUnit.Case, async: true 3 | 4 | import Paasaa.Support.Fixtures 5 | 6 | doctest Paasaa 7 | 8 | @some_hebrew "הפיתוח הראשוני בשנות ה־80 התמקד בגנו ובמערכת הגרפית" 9 | 10 | describe "Paasaa.detect" do 11 | test "should work on unique-scripts with many latin characters" do 12 | fixture = "한국어 문서가 전 세계 웹에서 차지하는 비중은 2004년에 4.1%로, 13 | 이는 영어(35.8%), 중국어(14.1%), 일본어(9.6%), 스페인어(9%), 독일어(7%)에 14 | 이어 전 세계 6위이다. 한글 문서와 한국어 문서를 같은것으로 볼 때, 웹상에서의 15 | 한국어 사용 인구는 전 세계 69억여 명의 인구 중 약 1%에 해당한다." 16 | 17 | assert Paasaa.detect(fixture) == "kor" 18 | 19 | fixture = ~s(現行の学校文法では、英語にあるような「目的語」「補語」 20 | などの成分はないとする。英語文法では "I read a book." の "a book" 21 | はSVO文型の一部をなす目的語であり、また、"I go to the library." の 22 | "the library" は前置詞とともに付け加えられた修飾語と考えられる。) 23 | 24 | assert Paasaa.detect(fixture) == "jpn" 25 | end 26 | 27 | test "should work on weird values" do 28 | assert Paasaa.detect("the the the the the ") == "sco" 29 | end 30 | 31 | test "should return `und` on an undetermined value" do 32 | assert Paasaa.detect("XYZ") == "und" 33 | end 34 | 35 | test "should return `und` on a nil value" do 36 | assert Paasaa.detect(nil) == "und" 37 | end 38 | 39 | test "should return `und` for generic characters" do 40 | assert Paasaa.detect("987 654 321") == "und" 41 | end 42 | 43 | test "should accept `blacklist`" do 44 | str = fixtures() |> get_in(["eng", "fixture"]) 45 | 46 | language = Paasaa.detect(str) 47 | 48 | assert Paasaa.detect(str, blacklist: [language]) != language 49 | end 50 | 51 | test "should accept `whitelist`" do 52 | assert "pol" = 53 | "Pošto je priznavanje urođenog dostojanstva i jednakih i neotuđivih prava svih članova ljudske porodice temelj slobode, pravde i mira u svetu;\npošto je nepoštovanje i preziranje prava čoveka vodilo var" 54 | |> Paasaa.detect(whitelist: ["pol"]) 55 | end 56 | 57 | test "should accept `whitelist` for different scripts" do 58 | result = Paasaa.detect(@some_hebrew, whitelist: ["eng"]) 59 | assert result == "und" 60 | 61 | result = Paasaa.detect("熊生活在森林中,不喜歡人。", whitelist: ["eng", "jpn"]) 62 | assert result == "und" 63 | end 64 | 65 | test "should accept `:min_length`" do 66 | result = Paasaa.detect("the", min_length: 3) 67 | assert result == "sco" 68 | 69 | result = Paasaa.detect("the", min_length: 4) 70 | assert result == "und" 71 | 72 | result = Paasaa.detect("Привет", min_length: 6) 73 | assert result == "rus" 74 | end 75 | end 76 | 77 | describe "Paasaa.all" do 78 | test "should return a list containing language--probability tuples" do 79 | result = Paasaa.all("") 80 | assert result |> is_list 81 | 82 | [first_element | _] = result 83 | assert first_element |> is_tuple 84 | 85 | {lang, score} = Enum.at(result, 0) 86 | 87 | assert lang |> is_binary 88 | assert score |> is_number 89 | end 90 | 91 | test ~s(should return `[{"und", 1}]` for generic characters) do 92 | assert [{"und", 1}] = Paasaa.all("987 654 321") 93 | end 94 | 95 | test "should work on weird values" do 96 | result = Paasaa.all("the the the the the ") |> Enum.take(2) 97 | assert [{"sco", _}, {"eng", _}] = result 98 | end 99 | end 100 | 101 | describe "algorithm" do 102 | fixtures() 103 | |> Enum.each(fn {language, %{"iso6393" => iso6393, "fixture" => fixture}} -> 104 | @iso6393 iso6393 105 | @fixture fixture 106 | test "should classify #{language} with > 98% certainty" do 107 | result = Paasaa.all(@fixture) 108 | 109 | {_, percent} = Enum.find(result, {"no-match", 0}, fn {lang, _pct} -> lang == @iso6393 end) 110 | 111 | assert percent > 0.98 112 | 113 | Enum.each(result, fn {_, score} -> 114 | assert score <= 1 && score >= 0 115 | end) 116 | end 117 | end) 118 | end 119 | 120 | test "detect_script/1" do 121 | text = "ყველა ადამიანი იბადება თავისუფალი და თანასწორი თავისი ღირსებითა და უფლებებით" 122 | assert {"kat", _} = Paasaa.detect_script(text) 123 | end 124 | end 125 | -------------------------------------------------------------------------------- /LANGUAGES.md: -------------------------------------------------------------------------------- 1 | # Supported Languages 2 | 3 | Total Languages: 163 4 | 5 | | ISO Code | Language Name | 6 | |----------|---------------| 7 | | ace | Achinese | 8 | | ada | Adangme | 9 | | afr | Afrikaans | 10 | | als | Unknown Language | 11 | | amh | Amharic | 12 | | arb | Unknown Language | 13 | | ayr | Unknown Language | 14 | | azj | Unknown Language | 15 | | azj | Unknown Language | 16 | | bam | Bambara | 17 | | ban | Balinese | 18 | | bci | Unknown Language | 19 | | bcl | Unknown Language | 20 | | bel | Belarusian | 21 | | bem | Bemba | 22 | | bho | Bhojpuri | 23 | | bin | Bini; Edo | 24 | | bos | Bosnian | 25 | | bos | Bosnian | 26 | | bug | Buginese | 27 | | bul | Bulgarian | 28 | | bum | Unknown Language | 29 | | cat | Catalan; Valencian | 30 | | ceb | Cebuano | 31 | | ces | Czech | 32 | | cjk | Unknown Language | 33 | | ckb | Unknown Language | 34 | | dan | Danish | 35 | | deu | German | 36 | | dip | Unknown Language | 37 | | dyu | Dyula | 38 | | ekk | Unknown Language | 39 | | emk | Unknown Language | 40 | | eng | English | 41 | | epo | Esperanto | 42 | | ewe | Ewe | 43 | | fin | Finnish | 44 | | fon | Fon | 45 | | fra | French | 46 | | fuf | Unknown Language | 47 | | fuv | Unknown Language | 48 | | gaa | Ga | 49 | | glg | Galician | 50 | | hat | Haitian; Haitian Creole | 51 | | hau | Hausa | 52 | | heb | Hebrew | 53 | | hil | Hiligaynon | 54 | | hin | Hindi | 55 | | hms | Unknown Language | 56 | | hnj | Unknown Language | 57 | | hrv | Croatian | 58 | | hun | Hungarian | 59 | | ibb | Unknown Language | 60 | | ibo | Igbo | 61 | | ilo | Iloko | 62 | | ind | Indonesian | 63 | | ita | Italian | 64 | | jav | Javanese | 65 | | kaz | Kazakh | 66 | | kbd | Kabardian | 67 | | kbp | Unknown Language | 68 | | kde | Unknown Language | 69 | | khk | Unknown Language | 70 | | kin | Kinyarwanda | 71 | | kir | Kirghiz; Kyrgyz | 72 | | kmb | Kimbundu | 73 | | knc | Unknown Language | 74 | | kng | Unknown Language | 75 | | koi | Unknown Language | 76 | | lin | Lingala | 77 | | lit | Lithuanian | 78 | | lua | Luba-Lulua | 79 | | lug | Ganda | 80 | | lun | Lunda | 81 | | lvs | Unknown Language | 82 | | mad | Madurese | 83 | | mag | Magahi | 84 | | mai | Maithili | 85 | | mar | Marathi | 86 | | men | Mende | 87 | | min | Minangkabau | 88 | | mkd | Macedonian | 89 | | mos | Mossi | 90 | | mya | Burmese | 91 | | ndo | Ndonga | 92 | | nds | Low German; Low Saxon; German, Low; Saxon, Low | 93 | | nhn | Unknown Language | 94 | | nld | Dutch; Flemish | 95 | | nno | Norwegian Nynorsk; Nynorsk, Norwegian | 96 | | nob | Bokmål, Norwegian; Norwegian Bokmål | 97 | | npi | Unknown Language | 98 | | nso | Pedi; Sepedi; Northern Sotho | 99 | | nya | Chichewa; Chewa; Nyanja | 100 | | nyn | Nyankole | 101 | | pam | Pampanga; Kapampangan | 102 | | pbu | Unknown Language | 103 | | pes | Unknown Language | 104 | | plt | Unknown Language | 105 | | pol | Polish | 106 | | por | Portuguese | 107 | | prs | Unknown Language | 108 | | qug | Unknown Language | 109 | | quy | Unknown Language | 110 | | quz | Unknown Language | 111 | | rmn | Unknown Language | 112 | | ron | Romanian; Moldavian; Moldovan | 113 | | run | Rundi | 114 | | rup | Aromanian; Arumanian; Macedo-Romanian | 115 | | rus | Russian | 116 | | sag | Sango | 117 | | sco | Scots | 118 | | shn | Shan | 119 | | skr | Unknown Language | 120 | | slk | Slovak | 121 | | slv | Slovenian | 122 | | sna | Shona | 123 | | snk | Soninke | 124 | | som | Somali | 125 | | sot | Sotho, Southern | 126 | | spa | Spanish; Castilian | 127 | | src | Unknown Language | 128 | | srp | Serbian | 129 | | srp | Serbian | 130 | | ssw | Swati | 131 | | suk | Sukuma | 132 | | sun | Sundanese | 133 | | swe | Swedish | 134 | | swh | Unknown Language | 135 | | tat | Tatar | 136 | | tem | Timne | 137 | | tgk | Tajik | 138 | | tgl | Tagalog | 139 | | tir | Tigrinya | 140 | | tiv | Tiv | 141 | | toi | Unknown Language | 142 | | tpi | Tok Pisin | 143 | | tsn | Tswana | 144 | | tso | Tsonga | 145 | | tuk | Turkmen | 146 | | tuk | Turkmen | 147 | | tur | Turkish | 148 | | tzm | Unknown Language | 149 | | uig | Uighur; Uyghur | 150 | | uig | Uighur; Uyghur | 151 | | ukr | Ukrainian | 152 | | umb | Umbundu | 153 | | urd | Urdu | 154 | | uzn | Unknown Language | 155 | | uzn | Unknown Language | 156 | | vec | Unknown Language | 157 | | ven | Venda | 158 | | vie | Vietnamese | 159 | | vmw | Unknown Language | 160 | | war | Waray | 161 | | wol | Wolof | 162 | | xho | Xhosa | 163 | | yao | Yao | 164 | | ydd | Unknown Language | 165 | | yor | Yoruba | 166 | | zlm | Unknown Language | 167 | | zlm | Unknown Language | 168 | | zul | Zulu | 169 | | zyb | Unknown Language | -------------------------------------------------------------------------------- /.github/workflows/elixir.yml: -------------------------------------------------------------------------------- 1 | name: Elixir CI 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | 8 | jobs: 9 | test: 10 | runs-on: ubuntu-latest 11 | name: mix test (OTP ${{matrix.otp}} | Elixir ${{matrix.elixir}}) 12 | strategy: 13 | fail-fast: false 14 | matrix: 15 | otp: [25.x, 26.x, 27.x, 28.x] 16 | elixir: [1.18.x] 17 | 18 | steps: 19 | - uses: actions/checkout@v4.2.2 20 | with: 21 | fetch-depth: 0 22 | 23 | - name: Install CA certificates 24 | run: sudo apt-get update && sudo apt-get install -y ca-certificates 25 | 26 | - uses: erlef/setup-beam@v1 27 | with: 28 | otp-version: ${{matrix.otp}} 29 | elixir-version: ${{matrix.elixir}} 30 | 31 | - name: Cache Mix Dependencies 32 | uses: actions/cache@v4 33 | with: 34 | path: | 35 | ~/.mix 36 | _build 37 | key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }} 38 | restore-keys: ${{ runner.os }}-mix- 39 | 40 | - name: Install Dependencies 41 | run: | 42 | mix local.rebar --force 43 | mix local.hex --force 44 | mix deps.get 45 | mix deps.compile 46 | - name: Compile 47 | run: mix compile --warnings-as-errors 48 | - name: Run Tests 49 | run: mix test --trace 50 | 51 | format: 52 | runs-on: ubuntu-latest 53 | steps: 54 | - uses: actions/checkout@v4.2.2 55 | - uses: erlef/setup-beam@v1 56 | with: 57 | elixir-version: 1.18.x 58 | otp-version: 28.x 59 | - name: Cache Mix Dependencies 60 | uses: actions/cache@v4 61 | with: 62 | path: | 63 | ~/.mix 64 | _build 65 | key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }} 66 | restore-keys: ${{ runner.os }}-mix- 67 | - name: Install Dependencies 68 | run: | 69 | mix local.rebar --force 70 | mix local.hex --force 71 | mix deps.get 72 | mix deps.compile 73 | - name: Check Format 74 | run: mix format --check-formatted --dry-run 75 | 76 | credo: 77 | runs-on: ubuntu-latest 78 | steps: 79 | - uses: actions/checkout@v4.2.2 80 | - uses: erlef/setup-beam@v1 81 | with: 82 | elixir-version: 1.18.x 83 | otp-version: 28.x 84 | - name: Cache Mix Dependencies 85 | uses: actions/cache@v4 86 | with: 87 | path: | 88 | ~/.mix 89 | _build 90 | key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }} 91 | restore-keys: ${{ runner.os }}-mix- 92 | - name: Install Dependencies 93 | run: | 94 | mix local.rebar --force 95 | mix local.hex --force 96 | mix deps.get 97 | mix deps.compile 98 | - name: Run Credo 99 | run: mix credo --format sarif > credo-results.sarif 100 | - name: Upload Credo results 101 | uses: github/codeql-action/upload-sarif@v3 102 | with: 103 | sarif_file: credo-results.sarif 104 | wait-for-processing: true 105 | 106 | unused-deps: 107 | runs-on: ubuntu-latest 108 | steps: 109 | - uses: actions/checkout@v4.2.2 110 | - uses: erlef/setup-beam@v1 111 | with: 112 | elixir-version: 1.18.x 113 | otp-version: 28.x 114 | - name: Cache Mix Dependencies 115 | uses: actions/cache@v4 116 | with: 117 | path: | 118 | ~/.mix 119 | _build 120 | key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }} 121 | restore-keys: ${{ runner.os }}-mix- 122 | - name: Install Dependencies 123 | run: | 124 | mix local.rebar --force 125 | mix local.hex --force 126 | mix deps.get 127 | mix deps.compile 128 | - name: Check for unused dependencies 129 | run: mix deps.unlock --check-unused 130 | 131 | dialyzer: 132 | runs-on: ubuntu-latest 133 | steps: 134 | - uses: actions/checkout@v4.2.2 135 | - uses: erlef/setup-beam@v1 136 | with: 137 | elixir-version: 1.18.x 138 | otp-version: 28.x 139 | - name: Cache Mix Dependencies 140 | uses: actions/cache@v4 141 | with: 142 | path: | 143 | ~/.mix 144 | _build 145 | key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }} 146 | restore-keys: ${{ runner.os }}-mix- 147 | - name: Install Dependencies 148 | run: | 149 | mix local.rebar --force 150 | mix local.hex --force 151 | mix deps.get 152 | mix deps.compile 153 | - name: Run Dialyzer 154 | run: mix dialyzer 155 | -------------------------------------------------------------------------------- /lib/paasaa.ex: -------------------------------------------------------------------------------- 1 | defmodule Paasaa do 2 | @moduledoc """ 3 | Provides language detection functions 4 | 5 | ## Examples 6 | 7 | iex> Paasaa.detect "Detect this!" 8 | "eng" 9 | 10 | """ 11 | 12 | @languages Paasaa.Data.languages() 13 | 14 | @max_difference 300 15 | 16 | @type result :: [{language :: String.t(), score :: number}] 17 | 18 | @type options :: [ 19 | min_length: integer, 20 | max_length: integer, 21 | whitelist: [String.t()], 22 | blacklist: [String.t()] 23 | ] 24 | 25 | @default_options [ 26 | min_length: 10, 27 | max_length: 2048, 28 | whitelist: [], 29 | blacklist: [] 30 | ] 31 | 32 | @und [{"und", 1}] 33 | 34 | @doc """ 35 | Detects a language. Returns a string with ISO6393 language code (e.g. "eng"). 36 | 37 | ## Parameters 38 | 39 | - `str` - a text string 40 | - `options` - a keyword list with options: 41 | - `:min_length` - If the text is shorter than `:min_length` it will return `und`. Default: `10`. 42 | - `:max_length` - Maximum length to analyze. Default: `2048`. 43 | - `:whitelist` - Allow languages. Default: `[]`. 44 | - `:blacklist` - Disallow languages. Default: `[]`. 45 | 46 | ## Examples 47 | 48 | Detect a string: 49 | 50 | iex> Paasaa.detect "Detect this!" 51 | "eng" 52 | 53 | With the `:blacklist` option: 54 | 55 | iex> Paasaa.detect "Detect this!", blacklist: ["eng"] 56 | "sco" 57 | 58 | With the `:min_length` option: 59 | 60 | iex> Paasaa.detect "Привет", min_length: 6 61 | "rus" 62 | 63 | It returns `und` for undetermined language: 64 | iex> Paasaa.detect "1234567890" 65 | "und" 66 | """ 67 | 68 | @spec detect(str :: String.t(), options) :: language :: String.t() 69 | def detect(str, options \\ @default_options) do 70 | str 71 | |> all(options) 72 | |> List.first() 73 | |> elem(0) 74 | end 75 | 76 | @doc """ 77 | Detects a language. Returns a list of languages scored by probability. 78 | 79 | ## Parameters 80 | 81 | - `str` - a text string 82 | - `options` - a keyword list with options, see `detect/2` for details. 83 | 84 | ## Examples 85 | 86 | Detect language and limit results to 5: 87 | 88 | iex> Paasaa.all("Detect this!") |> Enum.take(5) 89 | [ 90 | {"eng", 1.0}, 91 | {"sco", 0.8230731943771207}, 92 | {"nob", 0.6030053320407174}, 93 | {"nno", 0.5525933107125545}, 94 | {"swe", 0.508482792050412} 95 | ] 96 | """ 97 | 98 | @spec all(str :: String.t(), options) :: result 99 | def all(str, options \\ @default_options) 100 | def all("", _), do: @und 101 | def all(nil, _), do: @und 102 | 103 | def all(str, options) do 104 | options = Keyword.merge(@default_options, options) 105 | 106 | if String.length(str) < options[:min_length] do 107 | @und 108 | else 109 | process(str, options) 110 | end 111 | end 112 | 113 | @spec process(str :: String.t(), options) :: result 114 | defp process(str, options) do 115 | str = String.slice(str, 0, options[:max_length]) 116 | 117 | {script, count} = detect_script(str) 118 | 119 | cond do 120 | count == 0 -> 121 | @und 122 | 123 | Map.has_key?(@languages, script) -> 124 | str 125 | |> get_clean_trigrams() 126 | |> get_distances(@languages[script], options) 127 | |> normalize(str) 128 | 129 | true -> 130 | if allowed?(script, options) do 131 | [{script, 1}] 132 | else 133 | @und 134 | end 135 | end 136 | end 137 | 138 | defp allowed?(lang, options) do 139 | white = options[:whitelist] 140 | black = options[:blacklist] 141 | (Enum.empty?(white) || Enum.member?(white, lang)) && !Enum.member?(black, lang) 142 | end 143 | 144 | @doc """ 145 | Detects a script. 146 | 147 | ## Parameters 148 | 149 | - `str` - a text string 150 | 151 | ## Examples 152 | 153 | iex> Paasaa.detect_script("Detect this!") 154 | {"Latin", 0.8333333333333334} 155 | """ 156 | 157 | @spec detect_script(str :: String.t()) :: {String.t(), number} 158 | def detect_script(str) do 159 | len = String.length(str) 160 | 161 | Paasaa.Data.scripts() 162 | |> Enum.map(fn {name, re} -> {name, get_occurrence(str, re, len)} end) 163 | |> Enum.max_by(fn {_, count} -> count end) 164 | end 165 | 166 | @spec get_occurrence(str :: String.t(), re :: Regex.t(), str_len :: non_neg_integer) :: float 167 | defp get_occurrence(str, re, str_len) do 168 | Enum.count(Regex.scan(re, str)) / str_len 169 | end 170 | 171 | @spec get_distances([String.t()], Enumerable.t(), options) :: result 172 | defp get_distances(trigrams, languages, options) do 173 | languages 174 | |> filter_languages(options) 175 | |> Enum.map(fn {lang, model} -> {lang, get_distance(trigrams, model)} end) 176 | |> Enum.sort(&(elem(&1, 1) < elem(&2, 1))) 177 | end 178 | 179 | @spec get_distance([String.t()], Enumerable.t()) :: number 180 | defp get_distance(trigrams, model) do 181 | Enum.reduce(trigrams, 0, fn {name, val}, distance -> 182 | distance + 183 | if Map.has_key?(model, name) do 184 | abs(val - model[name] - 1) 185 | else 186 | @max_difference 187 | end 188 | end) 189 | end 190 | 191 | @spec filter_languages([String.t()], Enumerable.t()) :: Enumerable.t() 192 | defp filter_languages(languages, options) do 193 | white = options[:whitelist] 194 | black = options[:blacklist] 195 | 196 | if Enum.empty?(white) && Enum.empty?(black) do 197 | languages 198 | else 199 | Enum.filter(languages, fn {lang, _} -> 200 | allowed?(lang, options) 201 | end) 202 | end 203 | end 204 | 205 | @spec normalize(result, String.t()) :: result 206 | defp normalize([], _str), do: @und 207 | 208 | defp normalize(distances, str) do 209 | min = distances |> List.first() |> elem(1) 210 | max = String.length(str) * @max_difference - min 211 | 212 | Enum.map(distances, fn {lang, dist} -> 213 | dist = if max == 0, do: 0, else: 1 - (dist - min) / max 214 | 215 | {lang, dist} 216 | end) 217 | end 218 | 219 | @spec get_clean_trigrams(String.t()) :: result 220 | defp get_clean_trigrams(str) do 221 | str 222 | |> clean() 223 | |> pad() 224 | |> n_grams() 225 | |> Enum.reduce(%{}, fn trigram, acc -> 226 | count = (acc[trigram] && acc[trigram] + 1) || 1 227 | Map.put(acc, trigram, count) 228 | end) 229 | |> Map.to_list() 230 | end 231 | 232 | @spec clean(str :: String.t()) :: String.t() 233 | defp clean(str) do 234 | expression_symbols = ~r/[\x{0021}-\x{0040}]+/u 235 | 236 | str 237 | |> String.replace(expression_symbols, " ") 238 | |> String.replace(~r/\s+/, " ") 239 | |> String.trim() 240 | |> String.downcase() 241 | end 242 | 243 | defp pad(str), do: " #{str} " 244 | 245 | @spec n_grams(str :: String.t(), n :: number) :: [String.t()] 246 | defp n_grams(str, n \\ 3) do 247 | str 248 | |> String.graphemes() 249 | |> Enum.chunk_every(n, 1, :discard) 250 | |> Enum.map(&Enum.join/1) 251 | end 252 | end 253 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, 3 | "credo": {:hex, :credo, "1.7.12", "9e3c20463de4b5f3f23721527fcaf16722ec815e70ff6c60b86412c695d426c1", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8493d45c656c5427d9c729235b99d498bd133421f3e0a683e5c1b561471291e5"}, 4 | "dialyxir": {:hex, :dialyxir, "1.4.6", "7cca478334bf8307e968664343cbdb432ee95b4b68a9cba95bdabb0ad5bdfd9a", [:mix], [{:erlex, ">= 0.2.7", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "8cf5615c5cd4c2da6c501faae642839c8405b49f8aa057ad4ae401cb808ef64d"}, 5 | "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, 6 | "erlex": {:hex, :erlex, "0.2.7", "810e8725f96ab74d17aac676e748627a07bc87eb950d2b83acd29dc047a30595", [:mix], [], "hexpm", "3ed95f79d1a844c3f6bf0cea61e0d5612a42ce56da9c03f01df538685365efb0"}, 7 | "ex_doc": {:hex, :ex_doc, "0.38.3", "ddafe36b8e9fe101c093620879f6604f6254861a95133022101c08e75e6c759a", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "ecaa785456a67f63b4e7d7f200e8832fa108279e7eb73fd9928e7e66215a01f9"}, 8 | "excoveralls": {:hex, :excoveralls, "0.18.5", "e229d0a65982613332ec30f07940038fe451a2e5b29bce2a5022165f0c9b157e", [:mix], [{:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "523fe8a15603f86d64852aab2abe8ddbd78e68579c8525ae765facc5eae01562"}, 9 | "expo": {:hex, :expo, "1.1.0", "f7b9ed7fb5745ebe1eeedf3d6f29226c5dd52897ac67c0f8af62a07e661e5c75", [:mix], [], "hexpm", "fbadf93f4700fb44c331362177bdca9eeb8097e8b0ef525c9cc501cb9917c960"}, 10 | "file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"}, 11 | "finch": {:hex, :finch, "0.20.0", "5330aefb6b010f424dcbbc4615d914e9e3deae40095e73ab0c1bb0968933cadf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2658131a74d051aabfcba936093c903b8e89da9a1b63e430bee62045fa9b2ee2"}, 12 | "gettext": {:hex, :gettext, "0.26.2", "5978aa7b21fada6deabf1f6341ddba50bc69c999e812211903b169799208f2a8", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "aa978504bcf76511efdc22d580ba08e2279caab1066b76bb9aa81c4a1e0a32a5"}, 13 | "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, 14 | "iso_lang": {:hex, :iso_lang, "0.4.0", "c5953c8b6d19abc2446d4ef38389c8807b62eca88d7c037f1bd632c1a7f0673c", [:mix], [{:gettext, ">= 0.19.1", [hex: :gettext, repo: "hexpm", optional: false]}], "hexpm", "443745ae6bd6fc61cb6d2c476a172c6cb02630c937b28431e2cb7cde743243be"}, 15 | "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, 16 | "jsx": {:hex, :jsx, "3.1.0", "d12516baa0bb23a59bb35dccaf02a1bd08243fcbb9efe24f2d9d056ccff71268", [:rebar3], [], "hexpm", "0c5cc8fdc11b53cc25cf65ac6705ad39e54ecc56d1c22e4adb8f5a53fb9427f3"}, 17 | "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, 18 | "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [: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", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, 19 | "makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"}, 20 | "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, 21 | "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, 22 | "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, 23 | "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, 24 | "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, 25 | "req": {:hex, :req, "0.5.15", "662020efb6ea60b9f0e0fac9be88cd7558b53fe51155a2d9899de594f9906ba9", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "a6513a35fad65467893ced9785457e91693352c70b58bbc045b47e5eb2ef0c53"}, 26 | "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, 27 | } 28 | --------------------------------------------------------------------------------