├── test ├── test_helper.exs ├── formatter_test.exs └── ex_samples_test.exs ├── .gitignore ├── .formatter.exs ├── mix.lock ├── lib ├── exsamples.ex ├── samples.ex └── formatter_plugin.ex ├── mix.exs ├── .github └── workflows │ └── ci.yml ├── LICENSE ├── README.md └── guides └── usage.livemd /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /_build 2 | /deps 3 | erl_crash.dump 4 | *.ez 5 | /cover 6 | .DS_Store 7 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | plugins: [Samples.FormatterPlugin], 3 | inputs: ["*.{ex,exs}", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "sourceror": {:hex, :sourceror, "0.9.0", "77e8f883be9455812d15913582d2985048ef65d7d931072c548e025a6ea58d5a", [:mix], [], "hexpm", "f56fb5b935df7784504f7d1ba074e0aa83299e2ebd64f75268ffcae62a28f331"}, 3 | } 4 | -------------------------------------------------------------------------------- /lib/exsamples.ex: -------------------------------------------------------------------------------- 1 | defmodule ExSamples do 2 | defmacro samples([as: type], contents) do 3 | Samples.extract(contents, type) 4 | end 5 | 6 | defmacro samples(contents) do 7 | Samples.extract(contents) 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule ExSamples.Mixfile do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :exsamples, 7 | version: "0.1.0", 8 | elixir: "~> 1.13 and >= 1.13.2", 9 | description: description(), 10 | package: package(), 11 | deps: deps() 12 | ] 13 | end 14 | 15 | def application do 16 | [] 17 | end 18 | 19 | defp deps do 20 | [ 21 | {:sourceror, "~> 0.9"} 22 | ] 23 | end 24 | 25 | defp description do 26 | """ 27 | Initializes lists of maps, structs or keyword lists using tabular data in Elixir. 28 | """ 29 | end 30 | 31 | defp package do 32 | [ 33 | files: ["lib", "mix.exs", "README.md", "LICENSE"], 34 | maintainers: ["Marlus Saraiva"], 35 | licenses: ["MIT"], 36 | links: %{"GitHub" => "https://github.com/msaraiva/exsamples"} 37 | ] 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [push, pull_request] 3 | jobs: 4 | build: 5 | strategy: 6 | matrix: 7 | # https://hexdocs.pm/elixir/compatibility-and-deprecations.html 8 | # https://github.com/erlef/setup-beam#compatibility-between-operating-system-and-erlangotp 9 | elixir: ["1.13.2", "1.14"] 10 | otp: ["25", "24", "23", "22"] 11 | os: ["ubuntu-20.04"] 12 | exclude: 13 | - otp: 22 14 | elixir: 1.14 15 | # Erlang 25 is only compatible with Elixir >= 1.13.4 16 | - otp: 25 17 | elixir: 1.13.2 18 | 19 | runs-on: ${{ matrix.os }} 20 | steps: 21 | - uses: actions/checkout@v2 22 | - uses: erlef/setup-beam@v1.9.0 23 | with: 24 | otp-version: ${{ matrix.otp }} 25 | elixir-version: ${{ matrix.elixir }} 26 | - run: mix deps.get 27 | - run: mix deps.compile 28 | - run: mix compile --warnings-as-errors 29 | - run: mix test 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Marlus Saraiva 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /test/formatter_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Samples.FormatterPluginTest do 2 | use ExUnit.Case 3 | 4 | test "format all samples" do 5 | code = """ 6 | samples do 7 | :name | :country | :city 8 | "Christian" | "United States" | "New York City" 9 | "Peter" | "Austria" | "Vienna" 10 | end 11 | 12 | samples do 13 | :name | :country | :city 14 | "Christian" | "United States" | "New York City" 15 | "Peter" | "Austria" | "Vienna" 16 | end 17 | """ 18 | 19 | assert Samples.FormatterPlugin.format(code, []) == """ 20 | samples do 21 | :name | :country | :city 22 | "Christian" | "United States" | "New York City" 23 | "Peter" | "Austria" | "Vienna" 24 | end 25 | 26 | samples do 27 | :name | :country | :city 28 | "Christian" | "United States" | "New York City" 29 | "Peter" | "Austria" | "Vienna" 30 | end 31 | """ 32 | end 33 | 34 | test "format samples with :as option" do 35 | code = """ 36 | users = samples as: User do 37 | :name | :country | :city 38 | "Christian" | "United States" | "New York City" 39 | "Peter" | "Austria" | "Vienna" 40 | end 41 | """ 42 | 43 | assert Samples.FormatterPlugin.format(code, []) == """ 44 | users = 45 | samples as: User do 46 | :name | :country | :city 47 | "Christian" | "United States" | "New York City" 48 | "Peter" | "Austria" | "Vienna" 49 | end 50 | """ 51 | end 52 | 53 | test "format empty samples" do 54 | code = """ 55 | users = samples do 56 | end 57 | 58 | users = samples do 59 | 60 | end 61 | """ 62 | 63 | assert Samples.FormatterPlugin.format(code, []) == """ 64 | users = 65 | samples do 66 | end 67 | 68 | users = 69 | samples do 70 | end 71 | """ 72 | end 73 | 74 | test "keep formating after empty samples" do 75 | code = """ 76 | users = 77 | samples do 78 | end 79 | 80 | samples do 81 | User | :name | :country | :city 82 | user1 | "Christian" | country | "New York City" 83 | end 84 | """ 85 | 86 | assert Samples.FormatterPlugin.format(code, []) == """ 87 | users = 88 | samples do 89 | end 90 | 91 | samples do 92 | User | :name | :country | :city 93 | user1 | "Christian" | country | "New York City" 94 | end 95 | """ 96 | end 97 | 98 | test "align columns with numbers to the right" do 99 | code = """ 100 | samples do 101 | :id | :name | :currency | :language | :population | :inflation 102 | 1 | "Brazil" | "Real (BRL)" | "Portuguese" | 204451000 | 7.70 103 | 3 | "Austria" | "Euro (EUR)" | "German" | 8623073 | 2.45 104 | 1234 | "Sweden" | "Swedish krona (SEK)" | "Swedish" | 9801616 | 3.60 105 | end 106 | """ 107 | 108 | assert Samples.FormatterPlugin.format(code, []) == """ 109 | samples do 110 | :id | :name | :currency | :language | :population | :inflation 111 | 1 | "Brazil" | "Real (BRL)" | "Portuguese" | 204_451_000 | 7.70 112 | 3 | "Austria" | "Euro (EUR)" | "German" | 8_623_073 | 2.45 113 | 1234 | "Sweden" | "Swedish krona (SEK)" | "Swedish" | 9_801_616 | 3.60 114 | end 115 | """ 116 | end 117 | end 118 | -------------------------------------------------------------------------------- /lib/samples.ex: -------------------------------------------------------------------------------- 1 | defmodule Samples do 2 | def extract(contents, type) do 3 | contents 4 | |> extract_table_parts 5 | |> (fn {vars, _type, keyword_lists} -> {vars, type, keyword_lists} end).() 6 | |> process_table_parts 7 | end 8 | 9 | def extract(contents) do 10 | contents 11 | |> extract_table_parts 12 | |> process_table_parts 13 | end 14 | 15 | defp extract_table_parts(contents) do 16 | {vars, type, fields, fields_values} = 17 | contents 18 | |> normalize_contents 19 | |> contents_to_table 20 | |> slice_table 21 | 22 | keyword_lists = zip_fields_and_values(fields, fields_values) 23 | {vars, type, keyword_lists} 24 | end 25 | 26 | defp process_table_parts({[], type, keyword_lists}) do 27 | to_typed_list(keyword_lists, type) 28 | end 29 | 30 | defp process_table_parts({vars, type, keyword_lists}) do 31 | to_assignments(vars, type, keyword_lists) 32 | end 33 | 34 | defp slice_table(table) do 35 | [header | rows] = extract_header_rows(table) 36 | {type, fields} = extract_type_and_fields(header) 37 | {vars, fields_values} = extract_vars_and_fields_values(type, rows) 38 | 39 | {vars, type, fields, fields_values} 40 | end 41 | 42 | defp to_assignments(vars, type, keyword_lists) do 43 | vars 44 | |> Enum.zip(keyword_lists) 45 | |> Enum.map(fn {var_name, value} -> 46 | var = Macro.var(var_name, nil) 47 | 48 | quote do 49 | unquote(var) = unquote(replace_value(type, value)) 50 | end 51 | end) 52 | end 53 | 54 | defp to_typed_list(contents, nil) do 55 | to_typed_list(contents, {:%{}, [], []}) 56 | end 57 | 58 | defp to_typed_list(contents, type) do 59 | Enum.map(contents, fn item -> 60 | replace_value(type, item) 61 | end) 62 | end 63 | 64 | defp extract_header_rows([]), do: [[nil]] 65 | defp extract_header_rows(table), do: table 66 | 67 | def extract_type_and_fields([type = {atom, _, []} | fields]) when atom == :%{} do 68 | {type, fields} 69 | end 70 | 71 | def extract_type_and_fields([{:__aliases__, _, [_]} = type | fields]) do 72 | {type, fields} 73 | end 74 | 75 | def extract_type_and_fields(fields = [{field, [_], _} | _]) when is_atom(field) do 76 | {nil, Enum.map(fields, fn {field, [_], _} -> field end)} 77 | end 78 | 79 | def extract_type_and_fields(fields = [field | _]) when is_atom(field) do 80 | {nil, fields} 81 | end 82 | 83 | def extract_type_and_fields(fields = [field | _]) when is_binary(field) do 84 | {nil, fields} 85 | end 86 | 87 | def extract_type_and_fields([type | fields]) do 88 | {type, fields} 89 | end 90 | 91 | def extract_vars_and_fields_values(nil, rows) do 92 | {[], rows} 93 | end 94 | 95 | def extract_vars_and_fields_values(_type, rows) do 96 | rows 97 | |> Enum.map(fn [{var, [line: _line], _} | fields_values] -> {var, fields_values} end) 98 | |> :lists.unzip() 99 | end 100 | 101 | defp zip_fields_and_values(fields, rows) do 102 | Enum.map(rows, fn row -> 103 | Enum.zip(fields, row) 104 | end) 105 | end 106 | 107 | # As structs by module name 108 | defp replace_value({:__aliases__, [counter: _, line: _], [module]}, value) do 109 | {:%, [], [{:__aliases__, [], [module]}, {:%{}, [], value}]} 110 | end 111 | 112 | defp replace_value({:__aliases__, [line: _], [module]}, value) do 113 | {:%, [], [{:__aliases__, [], [module]}, {:%{}, [], value}]} 114 | end 115 | 116 | # As structs 117 | defp replace_value({:%, meta, [lhs, {:%{}, _, _value}]}, value) do 118 | {:%, meta, [lhs, {:%{}, [], value}]} 119 | end 120 | 121 | # As maps 122 | defp replace_value({:%{}, meta, []}, value) do 123 | {:%{}, meta, value} 124 | end 125 | 126 | # As keyword list 127 | defp replace_value([], value) do 128 | value 129 | end 130 | 131 | defp contents_to_table(contents) do 132 | case contents do 133 | [do: nil] -> [] 134 | nil -> [] 135 | _ -> extract_rows(contents) 136 | end 137 | end 138 | 139 | defp extract_rows(contents) do 140 | contents |> Enum.map(&extract_row(&1)) 141 | end 142 | 143 | defp extract_row([row]) do 144 | row |> extract_row 145 | end 146 | 147 | defp extract_row(row) do 148 | row |> extract_cells([]) |> Enum.reverse() 149 | end 150 | 151 | defp extract_cells({:|, _, [lhs, rhs]}, values) do 152 | rhs |> extract_cells([lhs | values]) 153 | end 154 | 155 | defp extract_cells(value, values) do 156 | [value | values] 157 | end 158 | 159 | defp normalize_contents(contents) do 160 | case contents do 161 | [do: {:__block__, _, code}] -> code 162 | [do: code] -> [code] 163 | end 164 | end 165 | end 166 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ExSamples 2 | 3 | Initializes lists of maps, structs or keyword lists using tabular data in Elixir. 4 | 5 | ExSamples helps you to describe data of the same type in a more **compact** and **readable** way. Specially useful when defining sample data (e.g. for tests). Here is an example: 6 | 7 | ```elixir 8 | countries = 9 | samples do 10 | :id | :name | :currency | :language | :population 11 | 1 | "Brazil" | "Real (BRL)" | "Portuguese" | 204_451_000 12 | 2 | "United States" | "United States Dollar (USD)" | "English" | 321_605_012 13 | 3 | "Austria" | "Euro (EUR)" | "German" | 8_623_073 14 | 4 | "Sweden" | "Swedish krona (SEK)" | "Swedish" | 9_801_616 15 | end 16 | ``` 17 | 18 | ```elixir 19 | iex> IO.inspect(countries) 20 | [ 21 | %{ 22 | currency: "Real (BRL)", 23 | id: 1, 24 | language: "Portuguese", 25 | name: "Brazil", 26 | population: 204451000 27 | }, 28 | %{ 29 | currency: "United States Dollar (USD)", 30 | id: 2, 31 | language: "English", 32 | name: "United States", 33 | population: 321605012 34 | }, 35 | %{ 36 | currency: "Euro (EUR)", 37 | id: 3, 38 | language: "German", 39 | name: "Austria", 40 | population: 8623073 41 | }, 42 | %{ 43 | currency: "Swedish krona (SEK)", 44 | id: 4, 45 | language: "Swedish", 46 | name: "Sweden", 47 | population: 9801616 48 | } 49 | ] 50 | ``` 51 | 52 | You can see it in action with [livebook](https://livebook.dev/) with [guides/usage.livemd](guides/usage.livemd). 53 | 54 | [![Run in Livebook](https://livebook.dev/badge/v1/blue.svg)](https://livebook.dev/run?url=https%3A%2F%2Fgithub.com%2Fmsaraiva%2Fexsamples%2Fblob%2Fmaster%2Fguides%2Fusage.livemd) 55 | 56 | ## Installation 57 | 58 | Add `:exsamples` as a dependency in your `mix.exs` file. 59 | 60 | ```elixir 61 | def deps do 62 | [ { :exsamples, "~> 0.1.0" } ] 63 | end 64 | ``` 65 | 66 | ## Configure the formatter (only for Elixir >= `v1.13.2`) 67 | 68 | Add `Samples.FormatterPlugin` to the list of plugins in your `.formatter.exs`: 69 | 70 | ```elixir 71 | [ 72 | plugins: [Samples.FormatterPlugin], 73 | ... 74 | ] 75 | 76 | ``` 77 | 78 | If you don't configure the formatter, `mix format` will remove all extra spaces you add 79 | to make your tables look nice. 80 | 81 | ## Usage 82 | 83 | ```elixir 84 | import ExSamples 85 | 86 | samples do 87 | :name | :country | :city | :admin 88 | "Christian" | "United States" | "New York City" | false 89 | "Peter" | "Germany" | "Berlin" | true 90 | "José" | "Brazil" | "São Paulo" | false 91 | "Ingrid" | "Austria" | "Salzburg" | false 92 | "Lucas" | "Brazil" | "Fortaleza" | true 93 | end 94 | ``` 95 | 96 | By default `samples` initializes a list of maps. But you can also define structs and keyword lists. 97 | 98 | ### Initializing structs 99 | 100 | ```elixir 101 | import ExSamples 102 | 103 | defmodule Country do 104 | defstruct [:id, :name, :currency, :language, :population] 105 | end 106 | 107 | samples as: Country do 108 | :id | :name | :currency | :language | :population 109 | 1 | "Brazil" | "Real (BRL)" | "Portuguese" | 204_451_000 110 | 2 | "United States" | "United States Dollar (USD)" | "English" | 321_605_012 111 | end 112 | ``` 113 | 114 | ### Initializing keyword lists 115 | 116 | ```elixir 117 | samples as: [] do 118 | :id | :name | :currency | :language | :population 119 | 3 | "Austria" | "Euro (EUR)" | "German" | 8_623_073 120 | 4 | "Sweden" | "Swedish krona (SEK)" | "Swedish" | 9_801_616 121 | end 122 | ``` 123 | 124 | ### Assigning variables as structs 125 | 126 | ```elixir 127 | 128 | defmodule Country do 129 | defstruct [:name, :currency, :language] 130 | end 131 | 132 | defmodule User do 133 | defstruct [:id, :name, :country, :admin, :last_login] 134 | end 135 | 136 | samples do 137 | Country | :name | :currency | :language 138 | country1 | "Brazil" | "Real (BRL)" | "Portuguese" 139 | country2 | "United States" | "United States Dollar (USD)" | "English" 140 | country3 | "Austria" | "Euro (EUR)" | "German" 141 | end 142 | 143 | samples do 144 | User | :id | :name | :country | :admin | :last_login 145 | user1 | 16 | "Lucas" | country1 | false | {2015, 10, 08} 146 | user2 | 327 | "Ingrid" | country3 | true | {2014, 09, 12} 147 | user3 | 34 | "Christian" | country2 | false | {2015, 01, 24} 148 | end 149 | 150 | ``` 151 | 152 | ``` 153 | iex> IO.puts "Name: #{user1.name}, Country: #{user1.country.name}" 154 | Name: Lucas, Country: Brazil 155 | ``` 156 | 157 | ### Assigning variables as maps 158 | 159 | ```elixir 160 | samples do 161 | %{} | :name | :country | :city 162 | user1 | "Christian" | "United States" | "New York City" 163 | user2 | "Ingrid" | "Austria" | "Salzburg" 164 | end 165 | ``` 166 | 167 | ### Assigning variables as keyword lists 168 | 169 | ```elixir 170 | samples do 171 | [] | :name | :country | :city 172 | user1 | "Christian" | "United States" | "New York City" 173 | user2 | "Ingrid" | "Austria" | "Salzburg" 174 | end 175 | 176 | ``` 177 | 178 | ##License 179 | (The MIT License) 180 | 181 | Copyright (c) 2022 Marlus Saraiva 182 | 183 | Permission is hereby granted, free of charge, to any person obtaining 184 | a copy of this software and associated documentation files (the 185 | 'Software'), to deal in the Software without restriction, including 186 | without limitation the rights to use, copy, modify, merge, publish, 187 | distribute, sublicense, and/or sell copies of the Software, and to 188 | permit persons to whom the Software is furnished to do so, subject to 189 | the following conditions: 190 | 191 | The above copyright notice and this permission notice shall be 192 | included in all copies or substantial portions of the Software. 193 | 194 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, 195 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 196 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 197 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 198 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 199 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 200 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 201 | -------------------------------------------------------------------------------- /guides/usage.livemd: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | # ExSamples Guide 6 | 7 | ## Setup 8 | 9 | ```elixir 10 | Mix.install([ 11 | :exsamples 12 | ]) 13 | ``` 14 | 15 | ```output 16 | :ok 17 | ``` 18 | 19 | ## Usage 20 | 21 | Initializes lists of maps, structs or keyword lists using tabular data in Elixir. 22 | 23 | ExSamples helps you to describe data of the same type in a more **compact** and **readable** way. Specially useful when defining sample data (e.g. for tests). Here is an example: 24 | 25 | 26 | 27 | ```elixir 28 | import ExSamples 29 | 30 | countries = samples do 31 | :id | :name | :currency | :language | :population 32 | 1 | "Brazil" | "Real (BRL)" | "Portuguese" | 204_451_000 33 | 2 | "United States" | "United States Dollar (USD)" | "English" | 321_605_012 34 | 3 | "Austria" | "Euro (EUR)" | "German" | 8_623_073 35 | 4 | "Sweden" | "Swedish krona (SEK)" | "Swedish" | 9_801_616 36 | end 37 | 38 | countries |> Enum.at(1) 39 | ``` 40 | 41 | ```output 42 | %{ 43 | currency: "United States Dollar (USD)", 44 | id: 2, 45 | language: "English", 46 | name: "United States", 47 | population: 321605012 48 | } 49 | ``` 50 | 51 | 52 | 53 | ```elixir 54 | import ExSamples 55 | 56 | users = 57 | samples do 58 | :name | :country | :city | :admin 59 | "Christian" | "United States" | "New York City" | false 60 | "Peter" | "Germany" | "Berlin" | true 61 | "José" | "Brazil" | "São Paulo" | false 62 | "Ingrid" | "Austria" | "Salzburg" | false 63 | "Lucas" | "Brazil" | "Fortaleza" | true 64 | end 65 | ``` 66 | 67 | ```output 68 | [ 69 | %{admin: false, city: "New York City", country: "United States", name: "Christian"}, 70 | %{admin: true, city: "Berlin", country: "Germany", name: "Peter"}, 71 | %{admin: false, city: "São Paulo", country: "Brazil", name: "José"}, 72 | %{admin: false, city: "Salzburg", country: "Austria", name: "Ingrid"}, 73 | %{admin: true, city: "Fortaleza", country: "Brazil", name: "Lucas"} 74 | ] 75 | ``` 76 | 77 | As you can see, after macro expansion you get a regular list. 78 | 79 | You can use `for` comprehensions for mapping and filtering your data just like with any other Enumerable. 80 | 81 | ```elixir 82 | for %{name: name, country: country, city: city} <- users, country == "Brazil" do 83 | {name, city} 84 | end 85 | ``` 86 | 87 | ```output 88 | [{"José", "São Paulo"}, {"Lucas", "Fortaleza"}] 89 | ``` 90 | 91 | ## Data Types 92 | 93 | By default `samples` initializes a list of maps. But you can also define structs and keyword lists. 94 | 95 | ### Initializing structs 96 | 97 | 98 | 99 | ```elixir 100 | import ExSamples 101 | 102 | defmodule Country do 103 | defstruct [:id, :name, :currency, :language, :population] 104 | end 105 | ``` 106 | 107 | ```output 108 | {:module, Country, <<70, 79, 82, 49, 0, 0, 7, ...>>, 109 | %Country{currency: nil, id: nil, language: nil, name: nil, population: nil}} 110 | ``` 111 | 112 | ```elixir 113 | samples as: Country do 114 | :id | :name | :currency | :language | :population 115 | 1 | "Brazil" | "Real (BRL)" | "Portuguese" | 204_451_000 116 | 2 | "United States" | "United States Dollar (USD)" | "English" | 321_605_012 117 | end 118 | ``` 119 | 120 | ```output 121 | [ 122 | %Country{ 123 | currency: "Real (BRL)", 124 | id: 1, 125 | language: "Portuguese", 126 | name: "Brazil", 127 | population: 204451000 128 | }, 129 | %Country{ 130 | currency: "United States Dollar (USD)", 131 | id: 2, 132 | language: "English", 133 | name: "United States", 134 | population: 321605012 135 | } 136 | ] 137 | ``` 138 | 139 | ### Initializing keyword lists 140 | 141 | 142 | 143 | ```elixir 144 | import ExSamples 145 | 146 | samples as: [] do 147 | :id | :name | :currency | :language | :population 148 | 3 | "Austria" | "Euro (EUR)" | "German" | 8_623_073 149 | 4 | "Sweden" | "Swedish krona (SEK)" | "Swedish" | 9_801_616 150 | end 151 | ``` 152 | 153 | ```output 154 | [ 155 | [id: 3, name: "Austria", currency: "Euro (EUR)", language: "German", population: 8623073], 156 | [id: 4, name: "Sweden", currency: "Swedish krona (SEK)", language: "Swedish", population: 9801616] 157 | ] 158 | ``` 159 | 160 | ### Assigning variables as structs 161 | 162 | 163 | 164 | ```elixir 165 | import ExSamples 166 | 167 | defmodule Country do 168 | defstruct [:name, :currency, :language] 169 | end 170 | 171 | defmodule User do 172 | defstruct [:id, :name, :country, :admin, :last_login] 173 | end 174 | ``` 175 | 176 | ```output 177 | {:module, User, <<70, 79, 82, 49, 0, 0, 7, ...>>, 178 | %User{admin: nil, country: nil, id: nil, last_login: nil, name: nil}} 179 | ``` 180 | 181 | ```elixir 182 | samples do 183 | Country | :name | :currency | :language 184 | country1 | "Brazil" | "Real (BRL)" | "Portuguese" 185 | country2 | "United States" | "United States Dollar (USD)" | "English" 186 | country3 | "Austria" | "Euro (EUR)" | "German" 187 | end 188 | 189 | samples do 190 | User | :id | :name | :country | :admin | :last_login 191 | user1 | 16 | "Lucas" | country1 | false | {2015, 10, 08} 192 | user2 | 327 | "Ingrid" | country3 | true | {2014, 09, 12} 193 | user3 | 34 | "Christian" | country2 | false | {2015, 01, 24} 194 | end 195 | 196 | user1 197 | ``` 198 | 199 | ```output 200 | %User{ 201 | admin: false, 202 | country: %Country{currency: "Real (BRL)", language: "Portuguese", name: "Brazil"}, 203 | id: 16, 204 | last_login: {2015, 10, 8}, 205 | name: "Lucas" 206 | } 207 | ``` 208 | 209 | ```elixir 210 | IO.puts("Name: #{user1.name}, Country: #{user1.country.name}") 211 | ``` 212 | 213 | ```output 214 | Name: Lucas, Country: Brazil 215 | ``` 216 | 217 | ```output 218 | :ok 219 | ``` 220 | 221 | ### Assigning variables as maps 222 | 223 | 224 | 225 | ```elixir 226 | import ExSamples 227 | 228 | samples do 229 | %{} | :name | :country | :city 230 | user1 | "Christian" | "United States" | "New York City" 231 | user2 | "Ingrid" | "Austria" | "Salzburg" 232 | end 233 | 234 | user1 235 | ``` 236 | 237 | ```output 238 | %{city: "New York City", country: "United States", name: "Christian"} 239 | ``` 240 | 241 | ### Assigning variables as keyword lists 242 | 243 | 244 | 245 | ```elixir 246 | samples do 247 | [] | :name | :country | :city 248 | user1 | "Christian" | "United States" | "New York City" 249 | user2 | "Ingrid" | "Austria" | "Salzburg" 250 | end 251 | 252 | user1 253 | ``` 254 | 255 | ```output 256 | [name: "Christian", country: "United States", city: "New York City"] 257 | ``` 258 | -------------------------------------------------------------------------------- /lib/formatter_plugin.ex: -------------------------------------------------------------------------------- 1 | defmodule Samples.FormatterPlugin do 2 | @behaviour Mix.Tasks.Format 3 | 4 | @line_break ["\n", "\r\n", "\r"] 5 | 6 | def features(_opts) do 7 | [extensions: [".ex", ".exs"]] 8 | end 9 | 10 | def format(code, opts) do 11 | formatted_code = 12 | code 13 | |> Code.format_string!(opts) 14 | |> to_string() 15 | |> format_samples() 16 | 17 | formatted_code <> "\n" 18 | end 19 | 20 | defp format_samples(code, position \\ [line: 0, column: 0]) do 21 | case format_first_samples_from_position(code, position) do 22 | :noop -> 23 | code 24 | 25 | {updated_code, position_found} -> 26 | format_samples(updated_code, position_found) 27 | end 28 | end 29 | 30 | defp format_first_samples_from_position(code, position) do 31 | samples_zipper = 32 | code 33 | |> Sourceror.parse_string!() 34 | |> Sourceror.Zipper.zip() 35 | |> Sourceror.Zipper.find(fn 36 | {:samples, meta, _} -> 37 | has_do? = Keyword.has_key?(meta, :do) 38 | 39 | is_after_position? = 40 | cond do 41 | meta[:line] > position[:line] -> true 42 | meta[:line] == position[:line] and meta[:column] > meta[:column] -> true 43 | true -> false 44 | end 45 | 46 | has_do? and is_after_position? 47 | 48 | _ -> 49 | false 50 | end) 51 | 52 | case find_samples_nodes(samples_zipper) do 53 | {nil, _} -> 54 | :noop 55 | 56 | {samples_node, nil} -> 57 | {code, Sourceror.get_start_position(samples_node)} 58 | 59 | {samples_node, do_node} -> 60 | range = 61 | do_node 62 | |> Sourceror.get_range() 63 | |> Map.update!(:start, fn pos -> [line: pos[:line] + 1, column: 1] end) 64 | 65 | content = get_code_by_range(code, range) 66 | 67 | samples_column = Sourceror.get_column(samples_node) 68 | replacement = format_table(content <> "\n", samples_column + 1) 69 | 70 | patch = %{ 71 | change: replacement, 72 | range: range, 73 | preserve_indentation: false 74 | } 75 | 76 | {Sourceror.patch_string(code, [patch]), Sourceror.get_start_position(samples_node)} 77 | end 78 | end 79 | 80 | defp find_samples_nodes(nil) do 81 | {nil, nil} 82 | end 83 | 84 | defp find_samples_nodes(samples_zipper) do 85 | samples_node = Sourceror.Zipper.node(samples_zipper) 86 | 87 | do_node = 88 | samples_zipper 89 | |> Sourceror.Zipper.down() 90 | |> Sourceror.Zipper.rightmost() 91 | |> Sourceror.Zipper.down() 92 | |> Sourceror.Zipper.node() 93 | |> case do 94 | {{:__block__, _, [:do]}, {:__block__, _, []}} -> nil 95 | node -> node 96 | end 97 | 98 | {samples_node, do_node} 99 | end 100 | 101 | defp format_table(code, column_offset) do 102 | ast = code |> Code.string_to_quoted!(columns: true) 103 | 104 | {_, positions} = 105 | Macro.prewalk(ast, [], fn 106 | {:|, meta, _children} = node, acc -> 107 | {node, [{meta[:line], meta[:column]} | acc]} 108 | 109 | other, acc -> 110 | {other, acc} 111 | end) 112 | 113 | positions = Enum.reverse(positions) 114 | {rows, cols_info} = walk(code, 1, 1, positions, [], {[[]], %{}, 0}) 115 | last_col_index = map_size(cols_info) - 1 116 | 117 | for row <- rows do 118 | Enum.map_join(row, " | ", fn 119 | {^last_col_index, value} -> 120 | align_value(value, cols_info[last_col_index], true) 121 | 122 | {col_index, value} -> 123 | offset = if col_index == 0, do: String.duplicate(" ", column_offset), else: "" 124 | offset <> align_value(value, cols_info[col_index], false) 125 | end) 126 | end 127 | |> Enum.join("\n") 128 | end 129 | 130 | defp align_value(value, cols_info, last_col?) do 131 | cond do 132 | cols_info.is_number? -> 133 | String.pad_leading(value, cols_info.width) 134 | 135 | last_col? -> 136 | value 137 | 138 | true -> 139 | String.pad_trailing(value, cols_info.width) 140 | end 141 | end 142 | 143 | defp walk("\r\n" <> rest, line, _column, positions, buffer, acc) do 144 | acc = acc |> add_cell(buffer) |> new_line(positions) 145 | walk(rest, line + 1, 1, positions, [], acc) 146 | end 147 | 148 | defp walk("\n" <> rest, line, _column, positions, buffer, acc) do 149 | acc = acc |> add_cell(buffer) |> new_line(positions) 150 | walk(rest, line + 1, 1, positions, [], acc) 151 | end 152 | 153 | defp walk(<<_::utf8, rest::binary>>, line, column, [{line, column} | positions], buffer, acc) do 154 | walk(rest, line, column + 1, positions, [], add_cell(acc, buffer)) 155 | end 156 | 157 | defp walk(<>, line, column, positions, buffer, acc) do 158 | walk(rest, line, column + 1, positions, [<> | buffer], acc) 159 | end 160 | 161 | defp walk(<<>>, _line, _column, _positions, _buffer, {rows, cols_info, _col_index}) do 162 | {Enum.reverse(rows), cols_info} 163 | end 164 | 165 | defp add_cell({[cells | rows], cols_info, col_index}, cell) do 166 | value = cell |> Enum.reverse() |> to_string() |> String.trim() 167 | width = String.length(value) 168 | is_number? = is_number?(value) 169 | info = %{width: width, is_number?: is_number?} 170 | 171 | cols_info = 172 | Map.update(cols_info, col_index, info, fn info -> 173 | %{width: max(info.width, width), is_number?: info.is_number? or is_number?} 174 | end) 175 | 176 | {[[{col_index, value} | cells] | rows], cols_info, col_index + 1} 177 | end 178 | 179 | defp is_number?(value) do 180 | value = String.replace(value, "_", "") 181 | match?({_, ""}, Float.parse(value)) or match?({_, ""}, Integer.parse(value)) 182 | end 183 | 184 | defp new_line({[cells | rows], cols_info, _col_index}, []) do 185 | {[Enum.reverse(cells) | rows], cols_info, 0} 186 | end 187 | 188 | defp new_line({[cells | rows], cols_info, _col_index}, _positions) do 189 | {[[] | [Enum.reverse(cells) | rows]], cols_info, 0} 190 | end 191 | 192 | defp get_code_by_range(code, range) do 193 | {_, text_after} = split_at(code, range.start[:line], range.start[:column]) 194 | line = range.end[:line] - range.start[:line] + 1 195 | {text, _} = split_at(text_after, line, range.end[:column]) 196 | text 197 | end 198 | 199 | defp split_at(code, line, col) do 200 | pos = find_position(code, line, col, {0, 1, 1}) 201 | String.split_at(code, pos) 202 | end 203 | 204 | defp find_position(_text, line, col, {pos, line, col}) do 205 | pos 206 | end 207 | 208 | defp find_position(text, line, col, {pos, current_line, current_col}) do 209 | case String.next_grapheme(text) do 210 | {grapheme, rest} -> 211 | {new_pos, new_line, new_col} = 212 | if grapheme in @line_break do 213 | if current_line == line do 214 | # this is the line we're lookin for 215 | # but it's shorter than expected 216 | {pos, current_line, col} 217 | else 218 | {pos + 1, current_line + 1, 1} 219 | end 220 | else 221 | {pos + 1, current_line, current_col + 1} 222 | end 223 | 224 | find_position(rest, line, col, {new_pos, new_line, new_col}) 225 | 226 | nil -> 227 | pos 228 | end 229 | end 230 | end 231 | -------------------------------------------------------------------------------- /test/ex_samples_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExSamplesTest do 2 | use ExUnit.Case 3 | import ExSamples 4 | 5 | defmodule User do 6 | defstruct name: nil, country: nil, city: nil 7 | end 8 | 9 | test "initializing a list of maps (default)" do 10 | users = 11 | samples do 12 | :name | :country | :city 13 | "Christian" | "United States" | "New York City" 14 | "Peter" | "Austria" | "Vienna" 15 | end 16 | 17 | assert users == [ 18 | %{name: "Christian", country: "United States", city: "New York City"}, 19 | %{name: "Peter", country: "Austria", city: "Vienna"} 20 | ] 21 | end 22 | 23 | test "initializing a list of structs" do 24 | users = 25 | samples as: User do 26 | :name | :country | :city 27 | "Christian" | "United States" | "New York City" 28 | "Peter" | "Austria" | "Vienna" 29 | end 30 | 31 | assert users == [ 32 | %User{name: "Christian", country: "United States", city: "New York City"}, 33 | %User{name: "Peter", country: "Austria", city: "Vienna"} 34 | ] 35 | end 36 | 37 | test "initializing list of keyword lists" do 38 | users = 39 | samples as: [] do 40 | :name | :country | :city 41 | "Christian" | "United States" | "New York City" 42 | "Peter" | "Austria" | "Vienna" 43 | end 44 | 45 | assert users == [ 46 | [name: "Christian", country: "United States", city: "New York City"], 47 | [name: "Peter", country: "Austria", city: "Vienna"] 48 | ] 49 | end 50 | 51 | test "initializing list of keyword lists with string keys" do 52 | users = 53 | samples as: [] do 54 | "name" | "country" | "city" 55 | "Christian" | "United States" | "New York City" 56 | "Peter" | "Austria" | "Vienna" 57 | end 58 | 59 | assert users == [ 60 | [{"name", "Christian"}, {"country", "United States"}, {"city", "New York City"}], 61 | [{"name", "Peter"}, {"country", "Austria"}, {"city", "Vienna"}] 62 | ] 63 | end 64 | 65 | test "initializing variables in the first column as maps" do 66 | samples do 67 | %{} | :name | :country | :city 68 | user1 | "Christian" | "United States" | "New York City" 69 | user2 | "Peter" | "Austria" | "Vienna" 70 | end 71 | 72 | assert user1 == %{name: "Christian", country: "United States", city: "New York City"} 73 | assert user2 == %{name: "Peter", country: "Austria", city: "Vienna"} 74 | end 75 | 76 | test "initializing variables in the first column as maps with string keys" do 77 | samples do 78 | %{} | "name" | "country" | "city" 79 | user1 | "Christian" | "United States" | "New York City" 80 | user2 | "Peter" | "Austria" | "Vienna" 81 | end 82 | 83 | assert user1 == %{ 84 | "name" => "Christian", 85 | "country" => "United States", 86 | "city" => "New York City" 87 | } 88 | 89 | assert user2 == %{"name" => "Peter", "country" => "Austria", "city" => "Vienna"} 90 | end 91 | 92 | test "initializing variables in the first column as structs" do 93 | samples do 94 | User | :name | :country | :city 95 | user1 | "Christian" | "United States" | "New York City" 96 | user2 | "Peter" | "Austria" | "Vienna" 97 | end 98 | 99 | assert user1 == %User{name: "Christian", country: "United States", city: "New York City"} 100 | assert user2 == %User{name: "Peter", country: "Austria", city: "Vienna"} 101 | end 102 | 103 | test "initializing variables in the first column as keyword lists" do 104 | samples do 105 | [] | :name | :country | :city 106 | user1 | "Christian" | "United States" | "New York City" 107 | user2 | "Peter" | "Austria" | "Vienna" 108 | end 109 | 110 | assert user1 == [name: "Christian", country: "United States", city: "New York City"] 111 | assert user2 == [name: "Peter", country: "Austria", city: "Vienna"] 112 | end 113 | 114 | test "table with single line" do 115 | users = 116 | samples do 117 | User | :name | :country | :city 118 | user1 | "Christian" | "United States" | "New York City" 119 | end 120 | 121 | assert user1 == %User{name: "Christian", country: "United States", city: "New York City"} 122 | assert users == [user1] 123 | end 124 | 125 | test "table without body" do 126 | users = 127 | samples do 128 | User | :name | :country | :city 129 | end 130 | 131 | assert users == [] 132 | end 133 | 134 | test "empty table" do 135 | users = 136 | samples do 137 | end 138 | 139 | assert users == [] 140 | end 141 | 142 | test "with keys as string" do 143 | users = 144 | samples do 145 | "name" | "country" | "city" 146 | "Christian" | "United States" | "New York City" 147 | end 148 | 149 | assert users == [ 150 | %{"name" => "Christian", "country" => "United States", "city" => "New York City"} 151 | ] 152 | end 153 | 154 | test "with variables as values" do 155 | country = "United States" 156 | 157 | samples do 158 | User | :name | :country | :city 159 | user1 | "Christian" | country | "New York City" 160 | end 161 | 162 | assert user1 == %User{name: "Christian", country: "United States", city: "New York City"} 163 | end 164 | 165 | def country do 166 | "United States" 167 | end 168 | 169 | test "with functions as values" do 170 | samples do 171 | User | :name | :country | :city 172 | user1 | "Christian" | country() | "New York City" 173 | end 174 | 175 | assert user1 == %User{name: "Christian", country: "United States", city: "New York City"} 176 | end 177 | 178 | @country "United States" 179 | 180 | test "with module attributes as values" do 181 | samples do 182 | User | :name | :country | :city 183 | user1 | "Christian" | @country | "New York City" 184 | end 185 | 186 | assert user1 == %User{name: "Christian", country: "United States", city: "New York City"} 187 | end 188 | 189 | test "with diferent types" do 190 | samples do 191 | %{} | :string | :integer | :float | :atom | :boolean 192 | types | "some string" | 42 | 14.33 | :foo | true 193 | end 194 | 195 | assert types.string == "some string" 196 | assert types.integer == 42 197 | assert types.float == 14.33 198 | assert types.atom == :foo 199 | assert types.boolean == true 200 | end 201 | 202 | test "with diferent compound data types" do 203 | samples do 204 | %{} | :list | :tuple | :struct | :map 205 | types | [1, 2, 3] | {2, "foo", :bar} | %User{name: "Joe", city: "London"} | %{foo: "bar"} 206 | end 207 | 208 | assert types.list == [1, 2, 3] 209 | assert types.tuple == {2, "foo", :bar} 210 | assert types.struct == %User{name: "Joe", city: "London", country: nil} 211 | assert types.map == %{foo: "bar"} 212 | end 213 | 214 | test "field names as vars" do 215 | users = 216 | samples do 217 | name | country | city 218 | "Christian" | "United States" | "New York City" 219 | "Peter" | "Austria" | "Vienna" 220 | end 221 | 222 | assert users == [ 223 | %{name: "Christian", country: "United States", city: "New York City"}, 224 | %{name: "Peter", country: "Austria", city: "Vienna"} 225 | ] 226 | end 227 | end 228 | --------------------------------------------------------------------------------