Initial commit

This commit is contained in:
Mikko Ahlroth 2021-09-03 21:43:37 +03:00
commit 04a2595a88
17 changed files with 4784 additions and 0 deletions

4
.formatter.exs Normal file
View file

@ -0,0 +1,4 @@
# Used by "mix format"
[
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
]

26
.gitignore vendored Normal file
View file

@ -0,0 +1,26 @@
# The directory Mix will write compiled artifacts to.
/_build/
# If you run "mix test --cover", coverage assets end up here.
/cover/
# The directory Mix downloads your dependencies sources to.
/deps/
# Where third-party dependencies like ExDoc output generated docs.
/doc/
# Ignore .fetch files in case you like to edit your project deps locally.
/.fetch
# If the VM crashes, it generates a dump, let's ignore it too.
erl_crash.dump
# Also ignore archive artifacts (built via "mix archive.build").
*.ez
# Ignore package tarball (built via "mix hex.build").
language_colours-*.tar
# Temporary files, for example, from tests.
/tmp/

2
.tool-versions Normal file
View file

@ -0,0 +1,2 @@
elixir 1.12.1-otp-24
erlang 24.0.3

19
README.md Normal file
View file

@ -0,0 +1,19 @@
# LanguageColours
## Installation
If [available in Hex](https://hex.pm/docs/publish), the package can be installed
by adding `language_colours` to your list of dependencies in `mix.exs`:
```elixir
def deps do
[
{:language_colours, "~> 1.0.0"}
]
end
```
Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc)
and published on [HexDocs](https://hexdocs.pm). Once published, the docs can
be found at [https://hexdocs.pm/language_colours](https://hexdocs.pm/language_colours).

7
config/config.exs Normal file
View file

@ -0,0 +1,7 @@
import Config
config :language_colours, json_parser: Jason
if Config.config_env() == "test" do
import_config("test.exs")
end

12
config/test.exs Normal file
View file

@ -0,0 +1,12 @@
import Config
config :language_colours,
databases: %{
test_db: %{
db: LanguageColours.ETSDatabase,
ets_table: :lc_ets_test,
file: "test/colours.json",
server_name: TestETSDatabase,
fallback: true
}
}

6
lib/colour.ex Normal file
View file

@ -0,0 +1,6 @@
defmodule LanguageColours.Colour do
@typedoc """
A colour returned by the API, a hex colour of six characters with a hash prepended.
"""
@type t :: String.t()
end

20
lib/database.ex Normal file
View file

@ -0,0 +1,20 @@
defmodule LanguageColours.Database do
@moduledoc """
Behaviour that must be implemented by a database.
Each function gets a database configuration as argument. This configuration should be used to
talk to the correct database instance.
It is not necessary to deal with the fallback as the main module handles that.
"""
@doc """
Get a colour for the given language, or `nil`.
"""
@callback get(language :: String.t(), db_config :: map()) :: LanguageColours.Colour.t() | nil
@doc """
Update the language dataset using the configuration.
"""
@callback update(db_config :: map()) :: :ok
end

143
lib/ets_database.ex Normal file
View file

@ -0,0 +1,143 @@
defmodule LanguageColours.ETSDatabase do
@moduledoc """
Database that stores the language colours in an ETS table. Get operations will directly read
from the database, update operations will be serialized through the database process.
Please see [the main module documentation](LanguageColours.html#module-configuration) for the
available configuration options and how to set up the database.
"""
use GenServer
require Logger
@behaviour LanguageColours.Database
@table :language_colours_etsdatabase
defmodule Options do
@enforce_keys [:file]
defstruct [:file, server_opts: [], ets_table: :language_colours_etsdatabase]
@typedoc """
Options to give to the `LanguageColours.ETSDatabase.start_link/1` function.
* `:server_opts`: Any options to give to `GenServer.start_link/3`.
* `:file`: The file to read JSON data from.
* `:ets_table`: Name of the ETS table to store the data in.
"""
@type t :: %__MODULE__{
server_opts: GenServer.options(),
file: Path.t(),
ets_table: atom()
}
end
defmodule State do
@moduledoc false
@enforce_keys [:ets_table, :init_file]
defstruct [:ets_table, :init_file]
@type t :: %__MODULE__{
ets_table: :ets.tid() | atom(),
init_file: Path.t()
}
end
@spec start_link(Options.t()) :: GenServer.on_start()
def start_link(%Options{} = opts) do
opts = update_in(opts.server_opts[:name], &(&1 || __MODULE__))
GenServer.start_link(__MODULE__, opts, opts.server_opts)
end
@impl true
@spec init(Options.t()) :: {:ok, State.t()}
def init(%Options{} = opts) do
tid =
:ets.new(opts.ets_table, [
:named_table,
:set,
:protected,
read_concurrency: true
])
load_file(tid, opts.file)
{:ok, %State{ets_table: tid, init_file: opts.file}}
end
@impl true
def handle_call(msg, from, state)
def handle_call({:update, file}, _from, %State{} = state) do
file = file || state.init_file
load_file(state.ets_table, file)
{:reply, :ok, state}
end
@impl true
def get(language, db_config) do
table = Map.get(db_config, :ets_table, @table)
case :ets.lookup(table, String.downcase(language)) do
[] -> nil
[{_, colour}] -> colour
end
end
@impl true
def update(db_config) do
server = Map.get(db_config, :server_name, __MODULE__)
file = Map.get(db_config, :file)
GenServer.call(server, {:update, file})
end
@doc """
Get an `t:LanguageColours.ETSDatabase.Options.t/0` struct corresponding to the given database
configuration.
This is a helper for forming the options struct for starting the database in a supervisor.
NOTE: When using this function, the `:file` option is required.
"""
@spec startup_options(map()) :: Options.t()
def startup_options(db_config) do
%Options{
file: Map.fetch!(db_config, :file),
ets_table: Map.get(db_config, :ets_table, @table),
server_opts: [name: Map.get(db_config, :server_name)]
}
end
@spec load_file(:ets.tid() | atom(), Path.t()) :: :ok
defp load_file(tid, file) do
json_parser = Application.get_env(:language_colours, :json_parser, Jason)
with {:read, {:ok, data}} <- {:read, File.read(file)},
{:json, {:ok, parsed}} <- {:json, json_parser.decode(data)} do
data =
Enum.map(parsed, fn {key, value} ->
{String.downcase(key), Map.get(value, "color")}
end)
:ets.insert(tid, data)
# Delete any languages that were not in updated data
new_languages = parsed |> Map.keys() |> MapSet.new(&String.downcase/1)
all_languages = tid |> :ets.tab2list() |> Enum.map(&elem(&1, 0)) |> MapSet.new()
old_languages = MapSet.difference(all_languages, new_languages)
Enum.each(old_languages, &:ets.delete(tid, &1))
Logger.debug(
"Read #{Enum.count(data)} languages from JSON and deleted #{Enum.count(old_languages)} stale."
)
else
{:read, _} -> raise "Could not read file #{file}"
{:json, err} -> raise "Could not parse file #{file}: #{inspect(err)}"
end
end
end

177
lib/language_colours.ex Normal file
View file

@ -0,0 +1,177 @@
defmodule LanguageColours do
@moduledoc """
LanguageColours offers an API for retrieving colours for programming languages based on GitHub
colour data (or some other dataset).
```iex
iex> LanguageColours.get("Ada", :my_db)
"#02f88c"
```
To install, add LanguageColours as a dependency of your Mix project:
```elixir
defp deps do
[
{:language_colours, "~> 1.0.0"},
...
]
end
```
## Databases
LanguageColours can be served by different databases. Databases must conform to the
`LanguageColours.Database` behaviour. There is one database included, the
`LanguageColours.ETSDatabase`, that uses ETS to store the language colour data. As a source, it
uses a JSON file in the format found in the https://github.com/ozh/github-colors/ repo. This
file can be downloaded in a development environment with `mix language.colours.dl.data`.
## Fallback colours
If a language is not found in the database, `nil` will be returned by default. Optionally, the
[Rainbow](https://hex.pm/packages/rainbow) package can be used to generate a random colour based
on the language name as fallback. To use the fallback, set the database configuration option
`:fallback` to `true`, and install Rainbow as a dependency:
```elixir
defp deps do
[
{:rainbow, "~> 0.1.0"},
...
]
end
```
## Configuration
To configure a database, you can give the configuration directly in the function call:
```elixir
config = %{...}
LanguageColours.get("C++", config)
```
Or you can configure database aliases in your favourite config file (`runtime.exs` is
recommended so you don't need to recompile the app on config changes):
```elixir
# Config file
config :language_colours,
databases: %{
foo_db: %{
...
}
}
```
Then you can use this name in the function call:
```elixir
LanguageColours.get("COBOL", :foo_db)
```
The supported configuration options are:
* `:json_parser`: Module name of the JSON parser to use. Default is
[Jason](https://hex.pm/packages/jason).
* `:databases`: An atom-keyed map of database specific configurations, which can be maps
themselves or keyword lists.
The database specific configuration options are:
* `:db`: The database module to use. This option is required.
* `:fallback`: Boolean specifying whether to get a fallback colour from Rainbow or not, if
language was not found in database. Default `false`.
Additionally, the ETSDatabase has its own database specific options:
* `:file`: Path to the file to read the JSON data from.
* `:server_name`: The name to use for the database process. Must conform to
`t:GenServer.name/0`. By default the module name is used.
* `:ets_table`: Name of the ETS table to use.
## Setting up using ETSDatabase
First configure your database. Then start the database in your supervisor using the
`LanguageColours.ETSDatabase.startup_options/1` function like this:
```elixir
config = LanguageColours.config!(:foo_db)
children = [
...,
{LanguageColours.ETSDatabase, LanguageColours.ETSDatabase.startup_options(config)},
...
]
```
Alternatively you can directly give the `t:LanguageColours.ETSDatabase.Options.t/0` struct as
the argument.
"""
@doc """
Get the colour corresponding to the given language.
The database specified in the second argument (as configuration or preconfigured database name)
is used to fetch the language. If the language is not found and fallback is not configured,
`nil` is returned.
"""
@spec get(String.t(), atom() | keyword() | map()) :: LanguageColours.Colour.t() | nil
def get(language, db_or_config)
def get(language, db) when is_atom(db) do
config = config!(db)
get(language, config)
end
def get(language, config) when is_list(config) do
get(language, Map.new(config))
end
def get(language, config) when is_map(config) do
colour = config.db.get(language, config)
if is_nil(colour) && Map.get(config, :fallback) do
Rainbow.colorize(language, format: "hexcolor")
else
colour
end
end
@doc """
Update the database specified in the argument.
The argument may be a preconfigured database name or a configuration map/kwlist.
"""
@spec update(atom() | keyword() | map()) :: :ok
def update(db_or_config)
def update(db) when is_atom(db) do
config = config!(db)
update(config)
end
def update(config) when is_list(config) do
update(Map.new(config))
end
def update(config) when is_map(config) do
config.db.update(config)
end
@doc """
Retrieve the configuration for the given database name.
If the database is not configured, an error will be raised.
"""
@spec config!(atom()) :: map()
def config!(db) do
conf =
Application.get_env(:language_colours, :databases) ||
raise "LanguageColours databases not configured!"
Map.get(conf, db) || raise "Configuration for #{inspect(db)} colour database not found!"
end
end

View file

@ -0,0 +1,36 @@
defmodule Mix.Tasks.Language.Colours.Dl.Data do
use Mix.Task
@moduledoc """
Download the GitHub colours JSON dataset (or any other specified dataset).
**NOTE**: This function does not check the HTTPS certificate! Check the file after downloading
and only use this task as a helper during development.
You can specify the download URL and file path to put the download to with flags:
```bash
mix language.colours.dl.data --url 'https://raw.githubusercontent.com/ozh/github-colors/master/colors.json' --path 'priv/language_colours/data.json'
```
The directories leading to the output file will be created if necessary.
"""
@default_url "https://raw.githubusercontent.com/ozh/github-colors/master/colors.json"
@doc false
@impl true
def run(argv) do
{opts, _, _} = OptionParser.parse(argv, strict: [url: :string, path: :string])
url = opts |> Keyword.get(:url, @default_url) |> String.to_charlist()
path = Keyword.get(opts, :path, Path.join(File.cwd!(), "colours.json"))
Application.ensure_all_started(:inets)
Application.ensure_all_started(:ssl)
{:ok, {_, _, data}} = :httpc.request(:get, {url, []}, [], [])
path |> Path.dirname() |> File.mkdir_p!()
File.write!(path, data)
end
end

38
mix.exs Normal file
View file

@ -0,0 +1,38 @@
defmodule LanguageColours.MixProject do
use Mix.Project
def project do
[
app: :language_colours,
version: "1.0.0",
elixir: "~> 1.12",
start_permanent: Mix.env() == :prod,
deps: deps(),
# Docs
name: "LanguageColours",
source_url: "https://gitlab.com/Nicd/language_colours",
homepage_url: "https://gitlab.com/Nicd/language_colours",
docs: [
# The main page in the docs
main: "LanguageColours"
]
]
end
# Run "mix help compile.app" to learn about applications.
def application do
[
extra_applications: [:logger]
]
end
# Run "mix help deps" to learn about dependencies.
defp deps do
[
{:ex_doc, "~> 0.24", only: :dev, runtime: false},
{:rainbow, "~> 0.1.0", optional: true},
{:jason, "~> 1.2.2", optional: true}
]
end
end

10
mix.lock Normal file
View file

@ -0,0 +1,10 @@
%{
"earmark_parser": {:hex, :earmark_parser, "1.4.15", "b29e8e729f4aa4a00436580dcc2c9c5c51890613457c193cc8525c388ccb2f06", [:mix], [], "hexpm", "044523d6438ea19c1b8ec877ec221b008661d3c27e3b848f4c879f500421ca5c"},
"ex_doc": {:hex, :ex_doc, "0.25.2", "4f1cae793c4d132e06674b282f1d9ea3bf409bcca027ddb2fe177c4eed6a253f", [:mix], [{:earmark_parser, "~> 1.4.0", [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", "5b0c172e87ac27f14dfd152d52a145238ec71a95efbf29849550278c58a393d6"},
"jason": {:hex, :jason, "1.2.2", "ba43e3f2709fd1aa1dce90aaabfd039d000469c05c56f0b8e31978e03fa39052", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "18a228f5f0058ee183f29f9eae0805c6e59d61c3b006760668d8d18ff0d12179"},
"makeup": {:hex, :makeup, "1.0.5", "d5a830bc42c9800ce07dd97fa94669dfb93d3bf5fcf6ea7a0c67b2e0e4a7f26c", [:mix], [{:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cfa158c02d3f5c0c665d0af11512fed3fba0144cf1aadee0f2ce17747fba2ca9"},
"makeup_elixir": {:hex, :makeup_elixir, "0.15.1", "b5888c880d17d1cc3e598f05cdb5b5a91b7b17ac4eaf5f297cb697663a1094dd", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.1", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "db68c173234b07ab2a07f645a5acdc117b9f99d69ebf521821d89690ae6c6ec8"},
"makeup_erlang": {:hex, :makeup_erlang, "0.1.1", "3fcb7f09eb9d98dc4d208f49cc955a34218fc41ff6b84df7c75b3e6e533cc65f", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "174d0809e98a4ef0b3309256cbf97101c6ec01c4ab0b23e926a9e17df2077cbb"},
"nimble_parsec": {:hex, :nimble_parsec, "1.1.0", "3a6fca1550363552e54c216debb6a9e95bd8d32348938e13de5eda962c0d7f89", [:mix], [], "hexpm", "08eb32d66b706e913ff748f11694b17981c0b04a33ef470e33e11b3d3ac8f54b"},
"rainbow": {:hex, :rainbow, "0.1.0", "8d7da09f978997bd45c99315cc01319704516edd40d3c20d11cf4abd0e4f3d80", [:mix], [], "hexpm", "301a771cbbd2898bebddfddbfa5ae2bef27fe1edb8cd9574e6452239eb8eb80b"},
}

File diff suppressed because it is too large Load diff

2110
test/colours.json Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,67 @@
defmodule LanguageColoursTest do
use ExUnit.Case
setup_all do
start_supervised!(
{LanguageColours.ETSDatabase,
LanguageColours.ETSDatabase.startup_options(
Application.get_env(:language_colours, :databases).test_db
)}
)
:ok
end
test "gets the correct colour" do
assert LanguageColours.get("Ada", :test_db) == "#02f88c"
end
test "gets fallback if colour doesn't exist" do
assert "#" <> <<_rest::binary-size(6)>> = LanguageColours.get("Aaaaaaadaaaaaaa", :test_db)
end
test "is case insensitive" do
assert LanguageColours.get("AdA", :test_db) == "#02f88c"
end
test "returns nil if there is no fallback and language wasn't found" do
config =
Application.get_env(:language_colours, :databases).test_db
|> Map.put(:fallback, false)
|> Map.put(:server_name, :test_db_2)
|> Map.put(:ets_table, :best_table)
start_supervised!(
{LanguageColours.ETSDatabase, LanguageColours.ETSDatabase.startup_options(config)}
)
assert is_nil(LanguageColours.get("Aaaaaaadaaaaaaa", config))
end
test "language is deleted if no longer in updated dataset" do
config =
Application.get_env(:language_colours, :databases).test_db
|> Map.put(:file, "test/colours-without-ada.json")
assert LanguageColours.update(config) == :ok
assert LanguageColours.get("Ada", config) != "#02f88c"
# Teardown
config = Application.get_env(:language_colours, :databases).test_db
assert LanguageColours.update(config) == :ok
end
test "languages are updated correctly" do
config =
Application.get_env(:language_colours, :databases).test_db
|> Map.put(:file, "test/colours-without-ada.json")
assert LanguageColours.get("1C Enterprise", config) == "#814CCC"
assert LanguageColours.update(config) == :ok
assert LanguageColours.get("Ada", config) != "#ABCDEF"
# Teardown
config = Application.get_env(:language_colours, :databases).test_db
assert LanguageColours.update(config) == :ok
end
end

1
test/test_helper.exs Normal file
View file

@ -0,0 +1 @@
ExUnit.start()