├── .formatter.exs ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── README.md ├── benchmark.exs ├── lib ├── formular.ex └── formular │ ├── application.ex │ ├── compiler.ex │ └── default_functions.ex ├── mix.exs ├── mix.lock └── test ├── compiler ├── expr_clause_test.exs ├── expr_cond_test.exs ├── expr_for_test.exs ├── expr_with_test.exs ├── scope_test.exs └── top_level_test.exs ├── compiler_test.exs ├── features ├── access_get_test.exs ├── allowed_modules.exs ├── case_test.exs ├── compile_to_module_test.exs ├── custom_macro_test.exs ├── error_handling_test.exs ├── exception_test.exs ├── if_test.exs ├── in_test.exs ├── kernel_function_list_test.exs ├── list_test.exs ├── pipe_test.exs ├── sigil_test.exs ├── tap_test.exs ├── then_test.exs └── timeout_test.exs ├── formular_test.exs ├── security ├── exit_test.exs ├── heap_size_limit_test.exs ├── no_calling_module_function.exs └── no_importing.exs ├── support └── test_context.ex └── test_helper.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | # Created with GitHubActions version 0.1.0 2 | name: CI 3 | env: 4 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 5 | on: 6 | - pull_request 7 | - push 8 | jobs: 9 | linux: 10 | name: Test on Ubuntu (Elixir ${{ matrix.elixir }}, OTP ${{ matrix.otp }}) 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | elixir: 15 | - '1.16.3' 16 | - '1.17.3' 17 | - '1.18.2' 18 | otp: 19 | - '26.2' 20 | - '27.2' 21 | 22 | exclude: 23 | - elixir: '1.16.3' 24 | otp: '27.2' 25 | steps: 26 | - name: Checkout 27 | uses: actions/checkout@v2 28 | - name: Setup Elixir 29 | uses: erlef/setup-elixir@v1 30 | with: 31 | elixir-version: ${{ matrix.elixir }} 32 | otp-version: ${{ matrix.otp }} 33 | - name: Restore deps 34 | uses: actions/cache@v4 35 | with: 36 | path: deps 37 | key: deps-${{ runner.os }}-${{ matrix.elixir }}-${{ matrix.otp }}-${{ hashFiles(format('{0}{1}', github.workspace, '/mix.lock')) }} 38 | - name: Restore _build 39 | uses: actions/cache@v4 40 | with: 41 | path: _build 42 | key: _build-${{ runner.os }}-${{ matrix.elixir }}-${{ matrix.otp }}-${{ hashFiles(format('{0}{1}', github.workspace, '/mix.lock')) }} 43 | - name: Get dependencies 44 | run: mix deps.get 45 | - name: Compile dependencies 46 | run: MIX_ENV=test mix deps.compile 47 | - name: Compile project 48 | run: MIX_ENV=test mix compile --warnings-as-errors 49 | - name: Check code format 50 | if: ${{ contains(matrix.elixir, '1.12.2') && contains(matrix.otp, '24.0') }} 51 | run: MIX_ENV=test mix format --check-formatted 52 | - name: Lint code 53 | if: ${{ contains(matrix.elixir, '1.12.2') && contains(matrix.otp, '24.0') }} 54 | run: MIX_ENV=test mix credo --strict 55 | - name: Run tests with coverage 56 | run: mix coveralls.json 57 | - uses: codecov/codecov-action@v1 58 | with: 59 | token: ${{ secrets.CODECOV_TOKEN }} 60 | - name: Static code analysis 61 | run: mix dialyzer 62 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | formular_parser-*.tar 24 | 25 | 26 | # Temporary files for e.g. tests 27 | /tmp 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Formular is a tiny extendable DSL evaluator. ![CI badget](https://github.com/qhwa/formular/actions/workflows/ci.yml/badge.svg) 2 | 3 | It's a wrap around Elixir's `Code.eval_string/3` or `Code.eval_quoted/3`, with the following limitations: 4 | 5 | - No calling module functions; 6 | - No calling some functions which can cause VM to exit; 7 | - No sending messages; 8 | - (optional) memory usage limit; 9 | - (optional) execution time limit. 10 | 11 | **SECURITY NOTICE** 12 | 13 | Please be aware that, although it provides some security limitations, Formular does not aim to be a secure sandbox. The design purpose is more about compiling configurations into runnable code inside the application. So if the code comes from some untrusted user inputs, it could potentially damage the system. 14 | 15 | ## Installation 16 | 17 | ```elixir 18 | def deps do 19 | [ 20 | {:formular, "~> 0.2"} 21 | ] 22 | end 23 | ``` 24 | 25 | ## Usage 26 | 27 | ### A configuration code example 28 | 29 | ```elixir 30 | iex> discount_formula = ~s" 31 | ...> case order do 32 | ...> # old books get a big promotion 33 | ...> %{book: %{year: year}} when year < 2000 -> 34 | ...> 0.5 35 | ...> 36 | ...> %{book: %{tags: tags}} -> 37 | ...> # Elixir books! 38 | ...> if ~s{elixir} in tags do 39 | ...> 0.9 40 | ...> else 41 | ...> 1.0 42 | ...> end 43 | ...> 44 | ...> _ -> 45 | ...> 1.0 46 | ...> end 47 | ...> " 48 | ...> 49 | ...> book_order = %{ 50 | ...> book: %{ 51 | ...> title: "Elixir in Action", year: 2019, tags: ["elixir"] 52 | ...> } 53 | ...> } 54 | ...> 55 | ...> Formular.eval(discount_formula, [order: book_order]) 56 | {:ok, 0.9} 57 | ``` 58 | 59 | [Online documentation](https://hexdocs.pm/formular/Formular.html) 60 | 61 | ## License 62 | 63 | MIT 64 | -------------------------------------------------------------------------------- /benchmark.exs: -------------------------------------------------------------------------------- 1 | code = """ 2 | for n <- args do 3 | case n do 4 | _ when is_integer(n) -> 5 | n * n 6 | 7 | _ -> 8 | n 9 | end 10 | end 11 | """ 12 | 13 | defmodule Compiled do 14 | def eval(binding) do 15 | for n <- binding[:args] do 16 | case n do 17 | _ when is_integer(n) -> 18 | n * n 19 | 20 | _ -> 21 | n 22 | end 23 | end 24 | end 25 | end 26 | 27 | f = fn binding -> 28 | for n <- binding[:args] do 29 | case n do 30 | _ when is_integer(n) -> 31 | n * n 32 | 33 | _ -> 34 | n 35 | end 36 | end 37 | end 38 | 39 | ast = Code.string_to_quoted!(code) 40 | mod = Formular.compile_to_module!(code, :test_module) 41 | 42 | Benchee.run(%{ 43 | eval: fn -> {:ok, [9]} = Formular.eval(code, args: [3]) end, 44 | eval_ast: fn -> {:ok, [9]} = Formular.eval(ast, args: [3]) end, 45 | compiled_module: fn -> {:ok, [9]} = Formular.eval(mod, args: [3]) end, 46 | elixir_code_eval: fn -> {[9], _} = Code.eval_string(code, args: [3]) end, 47 | elixir_code_eval_ast: fn -> {[9], _} = Code.eval_quoted(ast, args: [3]) end, 48 | elixir_compiled_module: fn -> [9] = Compiled.eval(args: [3]) end, 49 | elixir_compiled_function: fn -> [9] = f.(args: [3]) end 50 | }) 51 | -------------------------------------------------------------------------------- /lib/formular.ex: -------------------------------------------------------------------------------- 1 | defmodule Formular do 2 | require Logger 3 | 4 | @kernel_functions Formular.DefaultFunctions.kernel_functions() 5 | @kernel_macros Formular.DefaultFunctions.kernel_macros() 6 | @default_eval_options [] 7 | @default_max_heap_size :infinity 8 | @default_timeout :infinity 9 | 10 | @moduledoc """ 11 | A tiny extendable DSL evaluator. It's a wrap around Elixir's `Code.eval_string/3` or `Code.eval_quoted/3`, with the following limitations: 12 | 13 | - No calling module functions; 14 | - No calling some functions which can cause VM to exit; 15 | - No sending messages; 16 | - (optional) memory usage limit; 17 | - (optional) execution time limit. 18 | 19 | Here's an example using this module to evaluate a discount number against an order struct: 20 | 21 | ```elixir 22 | iex> discount_formula = ~s" 23 | ...> case order do 24 | ...> # old books get a big promotion 25 | ...> %{book: %{year: year}} when year < 2000 -> 26 | ...> 0.5 27 | ...> 28 | ...> %{book: %{tags: tags}} -> 29 | ...> # Elixir books! 30 | ...> if ~s{elixir} in tags do 31 | ...> 0.9 32 | ...> else 33 | ...> 1.0 34 | ...> end 35 | ...> 36 | ...> _ -> 37 | ...> 1.0 38 | ...> end 39 | ...> " 40 | ...> 41 | ...> book_order = %{ 42 | ...> book: %{ 43 | ...> title: "Elixir in Action", year: 2019, tags: ["elixir"] 44 | ...> } 45 | ...> } 46 | ...> 47 | ...> Formular.eval(discount_formula, [order: book_order]) 48 | {:ok, 0.9} 49 | ``` 50 | 51 | The code being evaluated is just a piece of Elixir code, so it can be expressive when describing business rules. 52 | 53 | ## Literals 54 | 55 | ```elixir 56 | # number 57 | iex> Formular.eval("1", []) 58 | {:ok, 1} # <- note that it's an integer 59 | 60 | # plain string 61 | iex> Formular.eval(~s["some text"], []) 62 | {:ok, "some text"} 63 | 64 | # atom 65 | iex> Formular.eval(":foo", []) 66 | {:ok, :foo} 67 | 68 | # list 69 | iex> Formular.eval("[:foo, Bar]", []) 70 | {:ok, [:foo, Bar]} 71 | 72 | # keyword list 73 | iex> Formular.eval("[a: 1, b: :hi]", []) 74 | {:ok, [a: 1, b: :hi]} 75 | ``` 76 | 77 | ## Variables 78 | 79 | Variables can be passed within the `binding` parameter. 80 | 81 | ```elixir 82 | # bound value 83 | iex> Formular.eval("1 + foo", [foo: 42]) 84 | {:ok, 43} 85 | ``` 86 | 87 | ## Functions in the code 88 | 89 | ### Kernel functions and macros 90 | 91 | Kernel functions and macros are limitedly supported. Only a picked list of them are supported out of the box so that dangerouse functions such as `Kernel.exit/1` will not be invoked. 92 | 93 | Supported functions from `Kernel` are: 94 | 95 | ```elixir 96 | #{inspect(@kernel_functions, pretty: true)} 97 | ``` 98 | 99 | Supported macros from `Kernel` are: 100 | 101 | ```elixir 102 | #{inspect(@kernel_macros, pretty: true)} 103 | ``` 104 | 105 | Example: 106 | 107 | ```elixir 108 | # Kernel function 109 | iex> Formular.eval("min(5, 100)", []) 110 | {:ok, 5} 111 | 112 | iex> Formular.eval("max(5, 100)", []) 113 | {:ok, 100} 114 | ``` 115 | 116 | ### Custom functions 117 | 118 | Custom functions can be provided in two ways, either in a binding lambda: 119 | 120 | ```elixir 121 | # bound function 122 | iex> Formular.eval("1 + add.(-1, 5)", [add: &(&1 + &2)]) 123 | {:ok, 5} 124 | ``` 125 | ... or with a context module: 126 | 127 | ```elixir 128 | iex> defmodule MyContext do 129 | ...> def foo() do 130 | ...> 42 131 | ...> end 132 | ...> end 133 | 134 | ...> Formular.eval("10 + foo", [], context: MyContext) 135 | {:ok, 52} 136 | ``` 137 | 138 | **Directly calling to module functions in the code are disallowed** for security reason. For example: 139 | 140 | ```elixir 141 | iex> Formular.eval("Map.new", []) 142 | {:error, :no_calling_module_function} 143 | 144 | iex> Formular.eval("min(0, :os.system_time())", []) 145 | {:error, :no_calling_module_function} 146 | ``` 147 | 148 | unless you explicitly allow it via `allow_modules` option, as shown below: 149 | 150 | ```elixir 151 | iex> Formular.eval("Map.new([])", [], allow_modules: [Map]) 152 | {:ok, %{}} 153 | ``` 154 | 155 | ## Evaluating AST instead of plain string code 156 | 157 | You may want to use AST instead of string for performance consideration. In this case, an AST can be passed to `eval/3`: 158 | 159 | ```elixir 160 | iex> "a = b = 10; a * b" |> Code.string_to_quoted!() |> Formular.eval([]) 161 | {:ok, 100} 162 | ``` 163 | 164 | ...so that you don't have to parse it every time before evaluating it. 165 | 166 | ## Compiling the code into an Elixir module 167 | 168 | Most of the likelihood `Code.eval_*` functions are fast enough for your application. However, compiling to an Elixir module will significantly improve the performance. 169 | 170 | Code can be compiled into an Elixir module via `Formular.compile_to_module!/3` function, as the following: 171 | 172 | ```elixir 173 | iex> code = quote do: min(a, b) 174 | ...> compiled = Formular.compile_to_module!(code, MyCompiledMod) 175 | {:module, MyCompiledMod} 176 | ...> Formular.eval(compiled, [a: 5, b: 15], timeout: 5_000) 177 | {:ok, 5} 178 | ``` 179 | 180 | Alternatively, you can directly call `MyCompiledMod.run(a: 5, b: 15)` 181 | when none limitation of CPU or memory will apply. 182 | 183 | ## Limiting execution time 184 | 185 | The execution time can be limited with the `:timeout` option: 186 | 187 | ```elixir 188 | iex> sleep = fn -> :timer.sleep(:infinity) end 189 | ...> Formular.eval("sleep.()", [sleep: sleep], timeout: 10) 190 | {:error, :timeout} 191 | ``` 192 | 193 | Default timeout is 5_000 milliseconds. 194 | 195 | ## Limiting heap usage 196 | 197 | The evaluation can also be limited in heap size, with `:max_heap_size` option. When the limit is exceeded, an error `{:error, :killed}` will be returned. 198 | 199 | Example: 200 | 201 | ```elixir 202 | iex> code = "for a <- 0..999_999_999_999, do: to_string(a)" 203 | ...> Formular.eval(code, [], timeout: :infinity, max_heap_size: 1_000) 204 | {:error, :killed} 205 | ``` 206 | 207 | The default max heap size is 1_000_000 words. 208 | """ 209 | 210 | @supervisor Formular.Tasks 211 | 212 | @type code :: binary() | Macro.t() | {:module, module()} 213 | @type option :: 214 | {:context, module()} 215 | | {:allow_modules, [module()]} 216 | | {:max_heap_size, non_neg_integer() | :infinity} 217 | | {:timeout, non_neg_integer() | :infinity} 218 | 219 | @type options :: [option()] 220 | @type eval_result :: {:ok, term()} | {:error, term()} 221 | 222 | @doc """ 223 | Evaluate the code with binding context. 224 | 225 | ## Parameters 226 | 227 | - `code` : code to eval. Could be a binary, or parsed AST. 228 | - `binding` : the variable binding to support the evaluation 229 | - `options` : current these options are supported: 230 | - `context` : The modules to import before evaluation. 231 | - `allow_modules` : The modules allowed to use in the code. 232 | - `timeout` : A timer used to terminate the evaluation after x milliseconds. `#{@default_timeout}` milliseconds by default. 233 | - `max_heap_size` : A limit on heap memory usage. If set to zero, the max heap size limit is disabled. `#{@default_max_heap_size}` words by default. 234 | 235 | ## Examples 236 | 237 | ```elixir 238 | iex> Formular.eval("1", []) 239 | {:ok, 1} 240 | 241 | iex> Formular.eval(~s["some text"], []) 242 | {:ok, "some text"} 243 | 244 | iex> Formular.eval("min(5, 100)", []) 245 | {:ok, 5} 246 | 247 | iex> Formular.eval("max(5, 100)", []) 248 | {:ok, 100} 249 | 250 | iex> Formular.eval("count * 5", [count: 6]) 251 | {:ok, 30} 252 | 253 | iex> Formular.eval("add.(1, 2)", [add: &(&1 + &2)]) 254 | {:ok, 3} 255 | 256 | iex> Formular.eval("Map.new", []) 257 | {:error, :no_calling_module_function} 258 | 259 | iex> Formular.eval("Enum.count([1])", []) 260 | {:error, :no_calling_module_function} 261 | 262 | iex> Formular.eval("min(0, :os.system_time())", []) 263 | {:error, :no_calling_module_function} 264 | 265 | iex> Formular.eval("inspect.(System.A)", [inspect: &Kernel.inspect/1]) 266 | {:ok, "System.A"} 267 | 268 | iex> Formular.eval "f = &IO.inspect/1", [] 269 | {:error, :no_calling_module_function} 270 | 271 | iex> Formular.eval("mod = IO; mod.inspect(1)", []) 272 | {:error, :no_calling_module_function} 273 | 274 | iex> "a = b = 10; a * b" |> Code.string_to_quoted!() |> Formular.eval([]) 275 | {:ok, 100} 276 | ``` 277 | """ 278 | 279 | @spec eval(code, binding :: keyword(), options()) :: eval_result() 280 | def eval(code, binding, opts \\ @default_eval_options) 281 | 282 | def eval({:module, mod}, binding, opts), 283 | do: spawn_and_exec(fn -> {:ok, mod.run(binding)} end, opts) 284 | 285 | def eval(text, binding, opts) when is_binary(text) do 286 | with {:ok, ast} <- Code.string_to_quoted(text) do 287 | eval_ast(ast, binding, opts) 288 | end 289 | end 290 | 291 | def eval(ast, binding, opts), 292 | do: eval_ast(ast, binding, opts) 293 | 294 | defp eval_ast(ast, binding, opts) do 295 | with :ok <- valid?(ast, opts) do 296 | spawn_and_exec( 297 | fn -> do_eval(ast, binding, opts[:context]) end, 298 | opts 299 | ) 300 | end 301 | end 302 | 303 | defp spawn_and_exec(fun, opts) do 304 | timeout = Keyword.get(opts, :timeout, @default_timeout) 305 | max_heap_size = Keyword.get(opts, :max_heap_size, @default_max_heap_size) 306 | 307 | case {timeout, max_heap_size} do 308 | {:infinity, :infinity} -> 309 | fun.() 310 | 311 | _ -> 312 | {pid, ref} = spawn_task(fun, max_heap_size) 313 | 314 | receive do 315 | {:result, ret} -> 316 | Process.demonitor(ref, [:flush]) 317 | ret 318 | 319 | {:DOWN, ^ref, :process, ^pid, reason} -> 320 | Logger.error("Evaluating process killed, reason: #{inspect(reason)}") 321 | {:error, :killed} 322 | after 323 | timeout -> 324 | Process.demonitor(ref, [:flush]) 325 | :ok = Task.Supervisor.terminate_child(@supervisor, pid) 326 | {:error, :timeout} 327 | end 328 | end 329 | end 330 | 331 | defp spawn_task(fun, max_heap_size) do 332 | parent = self() 333 | 334 | {:ok, pid} = 335 | Task.Supervisor.start_child( 336 | @supervisor, 337 | fn -> 338 | if max_heap_size != :infinity do 339 | Process.flag(:max_heap_size, max_heap_size) 340 | end 341 | 342 | ret = fun.() 343 | send(parent, {:result, ret}) 344 | end 345 | ) 346 | 347 | ref = Process.monitor(pid) 348 | {pid, ref} 349 | end 350 | 351 | defp do_eval(ast, binding, context) do 352 | {ret, _binding} = 353 | ast 354 | |> Code.eval_quoted( 355 | binding, 356 | %Macro.Env{ 357 | functions: imported_functions(context), 358 | macros: imported_macros(context), 359 | requires: [Elixir.Kernel] 360 | } 361 | ) 362 | 363 | {:ok, ret} 364 | rescue 365 | err -> 366 | {:error, err} 367 | end 368 | 369 | defp imported_functions(nil), 370 | do: [{Elixir.Kernel, @kernel_functions}] 371 | 372 | defp imported_functions(mod) when is_atom(mod), 373 | do: [ 374 | {mod, mod.__info__(:functions)}, 375 | {Elixir.Kernel, @kernel_functions} 376 | ] 377 | 378 | defp imported_macros(nil), 379 | do: [{Elixir.Kernel, @kernel_macros}] 380 | 381 | defp imported_macros(mod) when is_atom(mod), 382 | do: [ 383 | {mod, mod.__info__(:macros)}, 384 | {Elixir.Kernel, @kernel_macros} 385 | ] 386 | 387 | defp valid?(ast, opts) do 388 | # credo:disable-for-next-line 389 | case check_rules(ast, opts) do 390 | false -> 391 | :ok 392 | 393 | ret -> 394 | {:error, ret} 395 | end 396 | end 397 | 398 | defp check_rules({:., _pos, [Access, :get]}, _), 399 | do: false 400 | 401 | defp check_rules({:., _pos, [{:__aliases__, _, [mod]}, func]}, opts) when is_atom(func) do 402 | allow_modules = Keyword.get(opts, :allow_modules, []) 403 | 404 | if Enum.member?(allow_modules, expand_alias(mod)) do 405 | false 406 | else 407 | :no_calling_module_function 408 | end 409 | end 410 | 411 | defp check_rules({:., _pos, [_mod, func]}, _opts) when is_atom(func) do 412 | :no_calling_module_function 413 | end 414 | 415 | defp check_rules({import_or_require, _pos, [{:__aliases__, _, [mod]} | _]}, opts) 416 | when import_or_require in [:import, :require] do 417 | allow_modules = Keyword.get(opts, :allow_modules, []) 418 | 419 | if Enum.member?(allow_modules, expand_alias(mod)) do 420 | false 421 | else 422 | :no_import_or_require 423 | end 424 | end 425 | 426 | defp check_rules({import_or_require, _pos, [_ | _]}, _opts) 427 | when import_or_require in [:import, :require] do 428 | :no_import_or_require 429 | end 430 | 431 | defp check_rules({op, _pos, args}, opts), 432 | do: check_rules(op, opts) || check_rules(args, opts) 433 | 434 | defp check_rules([], _), 435 | do: false 436 | 437 | defp check_rules([ast | rest], opts), 438 | do: check_rules(ast, opts) || check_rules(rest, opts) 439 | 440 | defp check_rules(_, _opts), 441 | do: false 442 | 443 | defp expand_alias(mod) when is_atom(mod), 444 | do: Module.concat(:"Elixir", mod) 445 | 446 | @doc """ 447 | Compile the code into an Elixir module function. 448 | """ 449 | @spec compile_to_module!(code(), module(), module() | options()) :: {:module, module()} 450 | def compile_to_module!(code, mod, opts \\ []) 451 | 452 | def compile_to_module!(code, mode, context) when is_atom(context), 453 | do: compile_to_module!(code, mode, context: context) 454 | 455 | def compile_to_module!(code, mod, opts) when is_binary(code) and is_list(opts), 456 | do: 457 | code 458 | |> Code.string_to_quoted!() 459 | |> compile_ast_to_module!(mod, opts) 460 | 461 | def compile_to_module!(ast, mod, opts), 462 | do: compile_ast_to_module!(ast, mod, opts) 463 | 464 | defp compile_ast_to_module!(ast, mod, opts) do 465 | with :ok <- valid?(ast, opts) do 466 | env = %Macro.Env{ 467 | context_modules: opts[:allow_modules], 468 | functions: imported_functions(opts[:context]), 469 | macros: imported_macros(opts[:context]), 470 | requires: [Elixir.Kernel] 471 | } 472 | 473 | Formular.Compiler.create_module(mod, ast, env) 474 | end 475 | end 476 | 477 | @doc """ 478 | Returns used variables in the code. This can be helpful if 479 | you intend to build some UI based on the variables, or to 480 | validate if the code is using variables within the allowed 481 | list. 482 | 483 | ## Example 484 | 485 | ```elixir 486 | iex> code = "f.(a + b)" 487 | ...> Formular.used_vars(code) |> Enum.sort() 488 | [:a, :b, :f] 489 | ``` 490 | """ 491 | @spec used_vars(code()) :: [atom()] 492 | def used_vars(code) when is_binary(code), 493 | do: code |> Code.string_to_quoted!() |> used_vars() 494 | 495 | def used_vars(code), 496 | do: Formular.Compiler.extract_vars(code) 497 | end 498 | -------------------------------------------------------------------------------- /lib/formular/application.ex: -------------------------------------------------------------------------------- 1 | defmodule Formular.Application do 2 | @moduledoc false 3 | 4 | use Application 5 | 6 | @impl true 7 | def start(_type, _args) do 8 | children = [ 9 | {Task.Supervisor, name: Formular.Tasks} 10 | ] 11 | 12 | Supervisor.start_link(children, strategy: :one_for_one) 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/formular/compiler.ex: -------------------------------------------------------------------------------- 1 | defmodule Formular.Compiler do 2 | @moduledoc """ 3 | This module is used to compile the code into Elixir modules. 4 | """ 5 | 6 | @scope_and_binding_ops ~w[-> def]a 7 | @scope_ops ~w[for]a 8 | @binding_ops ~w[<- =]a 9 | 10 | @doc """ 11 | Create an Elixir module from the raw code (AST). 12 | 13 | The created module will have to public functions: 14 | 15 | - `run/1` which accepts a binding keyword list and execute the code. 16 | - `used_variables/0` which returns a list of variable names that have 17 | been used in the code. 18 | 19 | ## Usage 20 | 21 | ```elixir 22 | iex> ast = quote do: a + b 23 | ...> Formular.Compiler.create_module(MyMod, ast) 24 | ...> MyMod.run(a: 1, b: 2) 25 | 3 26 | 27 | ...> MyMod.used_variables() 28 | [:a, :b] 29 | ``` 30 | """ 31 | @spec create_module(module(), Macro.t(), Macro.Env.t()) :: {:module, module()} 32 | def create_module(module, raw_ast, env \\ %Macro.Env{}) do 33 | Module.create( 34 | module, 35 | mod_body(raw_ast, env), 36 | env 37 | ) 38 | 39 | {:module, module} 40 | end 41 | 42 | defp mod_body(raw_ast, env) do 43 | quote do 44 | unquote(importing(env)) 45 | unquote(def_run(raw_ast)) 46 | unquote(def_used_variables(raw_ast)) 47 | end 48 | end 49 | 50 | defp importing(%{functions: functions, macros: macros}) do 51 | default = [{Kernel, [def: 2]}] 52 | merge_f = fn _, a, b -> a ++ b end 53 | 54 | imports = 55 | default 56 | |> Keyword.merge(functions, merge_f) 57 | |> Keyword.merge(macros, merge_f) 58 | 59 | for {mod, fun_list} <- imports do 60 | quote do 61 | import unquote(mod), only: unquote(fun_list) 62 | end 63 | end 64 | end 65 | 66 | defp def_run(raw_ast) do 67 | {ast, args} = inject_vars(raw_ast) 68 | 69 | quote do 70 | def run(binding) do 71 | unquote(def_args(args)) 72 | unquote(ast) 73 | end 74 | end 75 | end 76 | 77 | defp def_args(args) do 78 | for arg <- args do 79 | quote do 80 | unquote(Macro.var(arg, __MODULE__)) = Keyword.fetch!(binding, unquote(arg)) 81 | end 82 | end 83 | end 84 | 85 | @doc false 86 | def extract_vars(ast), 87 | do: do_extract_vars(ast) |> MapSet.to_list() 88 | 89 | defp inject_vars(ast) do 90 | collection = do_extract_vars(ast) 91 | 92 | { 93 | set_hygiene(ast, __MODULE__), 94 | MapSet.to_list(collection) 95 | } 96 | end 97 | 98 | defp do_extract_vars(ast) do 99 | initial_vars = { 100 | _bound_scopes = [MapSet.new([])], 101 | _collection = MapSet.new([]) 102 | } 103 | 104 | pre = fn 105 | {:cond, _, [[do: cond_do_bock]]} = ast, acc -> 106 | acc = 107 | for {:->, _, [left, _right]} <- cond_do_bock, 108 | unbind_var <- do_extract_vars(left), 109 | reduce: acc do 110 | acc -> 111 | collect_var_if_unbind(acc, unbind_var) 112 | end 113 | 114 | {ast, acc} 115 | 116 | {op, _, [left | _]} = ast, acc when op in @scope_and_binding_ops -> 117 | bound = do_extract_vars(left) 118 | {ast, acc |> push_scope() |> collect_bound(bound)} 119 | 120 | {op, _, _} = ast, acc when op in @scope_ops -> 121 | {ast, push_scope(acc)} 122 | 123 | {op, _, [left, _]} = ast, acc when op in @binding_ops -> 124 | bound = do_extract_vars(left) 125 | {ast, collect_bound(acc, bound)} 126 | 127 | {:^, _, [{pinned, _, _}]} = ast, acc when is_atom(pinned) -> 128 | {ast, delete_unbound(acc, pinned)} 129 | 130 | ast, vars -> 131 | {ast, vars} 132 | end 133 | 134 | post = fn 135 | {op, _, _} = ast, acc 136 | when op in @scope_ops 137 | when op in @scope_and_binding_ops -> 138 | {ast, pop_scope(acc)} 139 | 140 | {var, _meta, context} = ast, acc 141 | when is_atom(var) and is_atom(context) -> 142 | if ignore?(var) or defined?(var, acc) do 143 | {ast, acc} 144 | else 145 | {ast, collect_var(acc, var)} 146 | end 147 | 148 | ast, vars -> 149 | {ast, vars} 150 | end 151 | 152 | {^ast, {_, collection}} = Macro.traverse(ast, initial_vars, pre, post) 153 | collection 154 | end 155 | 156 | defp push_scope({scopes, collection}), 157 | do: {[MapSet.new([]) | scopes], collection} 158 | 159 | defp pop_scope({scopes, collection}), 160 | do: {tl(scopes), collection} 161 | 162 | defp collect_var_if_unbind({scopes, collection}, var) do 163 | if Enum.all?(scopes, &(not ignore?(var) and var not in &1)) do 164 | {scopes, MapSet.put(collection, var)} 165 | else 166 | {scopes, collection} 167 | end 168 | end 169 | 170 | defp ignore?(var), 171 | do: to_string(var) |> String.starts_with?("_") 172 | 173 | defp collect_var({scopes, collection}, unbind_var), 174 | do: {scopes, MapSet.put(collection, unbind_var)} 175 | 176 | defp delete_unbound({[scope | tail], collection}, var), 177 | do: {[MapSet.delete(scope, var) | tail], collection} 178 | 179 | defp collect_bound({[scope | tail], collection}, bounds), 180 | do: {[MapSet.union(scope, bounds) | tail], collection} 181 | 182 | defp defined?(var, {scopes, _}), 183 | do: Enum.any?(scopes, &(var in &1)) 184 | 185 | defp set_hygiene(ast, hygiene_context) do 186 | Macro.postwalk(ast, fn 187 | {var, meta, context} when is_atom(var) and is_atom(context) -> 188 | {var, meta, hygiene_context} 189 | 190 | other -> 191 | other 192 | end) 193 | end 194 | 195 | defp def_used_variables(raw_ast) do 196 | vars = extract_vars(raw_ast) 197 | 198 | quote do 199 | def used_variables do 200 | [unquote_splicing(vars)] 201 | end 202 | end 203 | end 204 | end 205 | -------------------------------------------------------------------------------- /lib/formular/default_functions.ex: -------------------------------------------------------------------------------- 1 | defmodule Formular.DefaultFunctions do 2 | @moduledoc false 3 | 4 | @supported [ 5 | !: 1, 6 | !=: 2, 7 | !==: 2, 8 | &&: 2, 9 | *: 2, 10 | +: 1, 11 | +: 2, 12 | ++: 2, 13 | -: 1, 14 | -: 2, 15 | --: 2, 16 | ..: 2, 17 | "..//": 3, 18 | /: 2, 19 | <: 2, 20 | <=: 2, 21 | <>: 2, 22 | ==: 2, 23 | =~: 2, 24 | >: 2, 25 | >=: 2, 26 | abs: 1, 27 | and: 2, 28 | ceil: 1, 29 | div: 2, 30 | floor: 1, 31 | get_and_update_in: 2, 32 | get_and_update_in: 3, 33 | get_in: 2, 34 | hd: 1, 35 | if: 2, 36 | in: 2, 37 | inspect: 2, 38 | is_atom: 1, 39 | is_binary: 1, 40 | is_bitstring: 1, 41 | is_boolean: 1, 42 | is_exception: 1, 43 | is_exception: 2, 44 | is_float: 1, 45 | is_function: 1, 46 | is_integer: 1, 47 | is_list: 1, 48 | is_map: 1, 49 | is_map_key: 2, 50 | is_nil: 1, 51 | is_number: 1, 52 | is_pid: 1, 53 | is_port: 1, 54 | is_reference: 1, 55 | is_struct: 1, 56 | is_struct: 2, 57 | is_tuple: 1, 58 | length: 1, 59 | map_size: 1, 60 | match?: 2, 61 | max: 2, 62 | min: 2, 63 | not: 1, 64 | or: 2, 65 | pop_in: 1, 66 | pop_in: 2, 67 | put_elem: 3, 68 | put_in: 2, 69 | put_in: 3, 70 | rem: 2, 71 | round: 1, 72 | sigil_C: 2, 73 | sigil_D: 2, 74 | sigil_N: 2, 75 | sigil_R: 2, 76 | sigil_S: 2, 77 | sigil_T: 2, 78 | sigil_U: 2, 79 | sigil_W: 2, 80 | sigil_c: 2, 81 | sigil_r: 2, 82 | sigil_s: 2, 83 | sigil_w: 2, 84 | struct: 2, 85 | struct!: 2, 86 | tap: 2, 87 | then: 2, 88 | tl: 1, 89 | to_charlist: 1, 90 | to_string: 1, 91 | trunc: 1, 92 | tuple_size: 1, 93 | unless: 2, 94 | |>: 2, 95 | ||: 2 96 | ] 97 | 98 | def kernel_functions do 99 | @supported 100 | |> Enum.filter(fn {f, arity} -> 101 | function_exported?(Kernel, f, arity) 102 | end) 103 | |> Enum.sort() 104 | end 105 | 106 | def kernel_macros do 107 | @supported 108 | |> Enum.filter(fn {f, arity} -> 109 | macro_exported?(Kernel, f, arity) 110 | end) 111 | |> Enum.sort() 112 | end 113 | end 114 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Formular.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :formular, 7 | description: "A simple extendable DSL evaluator.", 8 | version: "0.4.2", 9 | elixir: ">= 1.10.0", 10 | start_permanent: Mix.env() == :prod, 11 | elixirc_paths: elixirc_paths(Mix.env()), 12 | deps: deps(), 13 | test_coverage: [tool: ExCoveralls], 14 | preferred_cli_env: [ 15 | coveralls: :test, 16 | "coveralls.detail": :test, 17 | "coveralls.post": :test, 18 | "coveralls.html": :test, 19 | "coveralls.json": :test 20 | ], 21 | docs: docs(), 22 | package: package() 23 | ] 24 | end 25 | 26 | # Run "mix help compile.app" to learn about applications. 27 | def application do 28 | [ 29 | extra_applications: [:logger], 30 | mod: {Formular.Application, []} 31 | ] 32 | end 33 | 34 | defp elixirc_paths(:test), do: ["lib", "test/support"] 35 | defp elixirc_paths(_), do: ["lib"] 36 | 37 | # Run "mix help deps" to learn about dependencies. 38 | defp deps do 39 | [ 40 | {:credo, "~> 1.5", only: [:dev, :test], runtime: false}, 41 | {:dialyxir, "~> 1.1", only: [:dev, :test], runtime: false}, 42 | {:git_hooks, "~> 0.5", only: [:dev, :test], runtime: false}, 43 | {:excoveralls, "~> 0.14", only: :test}, 44 | {:git_hub_actions, "~> 0.1", only: :dev}, 45 | {:ex_doc, ">= 0.0.0", only: :dev}, 46 | {:benchee, "~> 1.3", only: :dev} 47 | ] 48 | end 49 | 50 | defp docs do 51 | [ 52 | main: "Formular" 53 | ] 54 | end 55 | 56 | defp package do 57 | [ 58 | licenses: ["MIT"], 59 | maintainers: [ 60 | "qhwa " 61 | ], 62 | source_url: "https://github.com/qhwa/formular", 63 | links: %{ 64 | Github: "https://github.com/qhwa/formular" 65 | }, 66 | files: ~w[ 67 | lib mix.exs 68 | ] 69 | ] 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "benchee": {:hex, :benchee, "1.3.1", "c786e6a76321121a44229dde3988fc772bca73ea75170a73fd5f4ddf1af95ccf", [:mix], [{:deep_merge, "~> 1.0", [hex: :deep_merge, repo: "hexpm", optional: false]}, {:statistex, "~> 1.0", [hex: :statistex, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "76224c58ea1d0391c8309a8ecbfe27d71062878f59bd41a390266bf4ac1cc56d"}, 3 | "blankable": {:hex, :blankable, "1.0.0", "89ab564a63c55af117e115144e3b3b57eb53ad43ba0f15553357eb283e0ed425", [:mix], [], "hexpm", "7cf11aac0e44f4eedbee0c15c1d37d94c090cb72a8d9fddf9f7aec30f9278899"}, 4 | "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, 5 | "certifi": {:hex, :certifi, "2.8.0", "d4fb0a6bb20b7c9c3643e22507e42f356ac090a1dcea9ab99e27e0376d695eba", [:rebar3], [], "hexpm", "6ac7efc1c6f8600b08d625292d4bbf584e14847ce1b6b5c44d983d273e1097ea"}, 6 | "credo": {:hex, :credo, "1.7.11", "d3e805f7ddf6c9c854fd36f089649d7cf6ba74c42bc3795d587814e3c9847102", [: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", "56826b4306843253a66e47ae45e98e7d284ee1f95d53d1612bb483f88a8cf219"}, 7 | "deep_merge": {:hex, :deep_merge, "1.0.0", "b4aa1a0d1acac393bdf38b2291af38cb1d4a52806cf7a4906f718e1feb5ee961", [:mix], [], "hexpm", "ce708e5f094b9cd4e8f2be4f00d2f4250c4095be93f8cd6d018c753894885430"}, 8 | "dialyxir": {:hex, :dialyxir, "1.4.5", "ca1571ac18e0f88d4ab245f0b60fa31ff1b12cbae2b11bd25d207f865e8ae78a", [:mix], [{:erlex, ">= 0.2.7", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b0fb08bb8107c750db5c0b324fa2df5ceaa0f9307690ee3c1f6ba5b9eb5d35c3"}, 9 | "earmark_parser": {:hex, :earmark_parser, "1.4.42", "f23d856f41919f17cd06a493923a722d87a2d684f143a1e663c04a2b93100682", [:mix], [], "hexpm", "6915b6ca369b5f7346636a2f41c6a6d78b5af419d61a611079189233358b8b8b"}, 10 | "erlex": {:hex, :erlex, "0.2.7", "810e8725f96ab74d17aac676e748627a07bc87eb950d2b83acd29dc047a30595", [:mix], [], "hexpm", "3ed95f79d1a844c3f6bf0cea61e0d5612a42ce56da9c03f01df538685365efb0"}, 11 | "ex_doc": {:hex, :ex_doc, "0.36.1", "4197d034f93e0b89ec79fac56e226107824adcce8d2dd0a26f5ed3a95efc36b1", [:mix], [{:earmark_parser, "~> 1.4.42", [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", "d7d26a7cf965dacadcd48f9fa7b5953d7d0cfa3b44fa7a65514427da44eafd89"}, 12 | "excoveralls": {:hex, :excoveralls, "0.18.4", "70f70dc37b9bd90cf66868c12778d2f60b792b79e9e12aed000972a8046dc093", [:mix], [{:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "cda8bb587d9deaa0da6158cf0a18929189abbe53bf42b10fe70016d5f4f5d6a9"}, 13 | "file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"}, 14 | "git_hooks": {:hex, :git_hooks, "0.8.0", "3a5715213b48c5d2d5a059bce3707c2312f2ead81803ce936c9211da2afc969e", [:mix], [{:recase, "~> 0.8.0", [hex: :recase, repo: "hexpm", optional: false]}], "hexpm", "8c39fa263d903e7e929b1397cdb5551c06b3deae6d47549c5a96d208cab210b4"}, 15 | "git_hub_actions": {:hex, :git_hub_actions, "0.3.1", "f0438d14149da95ce896e43b63b519bd98ca7924a6ac199f416210cdcce95929", [:mix], [], "hexpm", "c51e0482875fb63f903944287f10bdf0da73b2761c8593e532728df43f6a4337"}, 16 | "hackney": {:hex, :hackney, "1.18.0", "c4443d960bb9fba6d01161d01cd81173089686717d9490e5d3606644c48d121f", [:rebar3], [{:certifi, "~>2.8.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~>6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~>1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~>1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "9afcda620704d720db8c6a3123e9848d09c87586dc1c10479c42627b905b5c5e"}, 17 | "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, 18 | "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, 19 | "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, 20 | "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"}, 21 | "makeup_erlang": {:hex, :makeup_erlang, "1.0.1", "c7f58c120b2b5aa5fd80d540a89fdf866ed42f1f3994e4fe189abebeab610839", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "8a89a1eeccc2d798d6ea15496a6e4870b75e014d1af514b1b71fa33134f57814"}, 22 | "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, 23 | "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, 24 | "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, 25 | "parse_trans": {:hex, :parse_trans, "3.3.1", "16328ab840cc09919bd10dab29e431da3af9e9e7e7e6f0089dd5a2d2820011d8", [:rebar3], [], "hexpm", "07cd9577885f56362d414e8c4c4e6bdf10d43a8767abb92d24cbe8b24c54888b"}, 26 | "recase": {:hex, :recase, "0.8.1", "ab98cd35857a86fa5ca99036f575241d71d77d9c2ab0c39aacf1c9b61f6f7d1d", [:mix], [], "hexpm", "9fd8d63e7e43bd9ea385b12364e305778b2bbd92537e95c4b2e26fc507d5e4c2"}, 27 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"}, 28 | "statistex": {:hex, :statistex, "1.0.0", "f3dc93f3c0c6c92e5f291704cf62b99b553253d7969e9a5fa713e5481cd858a5", [:mix], [], "hexpm", "ff9d8bee7035028ab4742ff52fc80a2aa35cece833cf5319009b52f1b5a86c27"}, 29 | "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, 30 | } 31 | -------------------------------------------------------------------------------- /test/compiler/expr_clause_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Formular.Compiler.ClauseTest do 2 | use ExUnit.Case, async: true 3 | import Formular.Compiler, only: [extract_vars: 1] 4 | 5 | describe "`case`" do 6 | test "it works" do 7 | ast = 8 | quote do 9 | case b do 10 | :ok -> 11 | :ok 12 | 13 | %{x: ^x} -> 14 | :ok 15 | 16 | other -> 17 | {:other, other} 18 | end 19 | end 20 | 21 | assert extract_vars(ast) |> Enum.sort() == [:b, :x] 22 | end 23 | end 24 | 25 | describe "function clause" do 26 | test "it works with anonymouse functions" do 27 | ast = 28 | quote do 29 | f = fn a, b -> 30 | c = a + b + x 31 | end 32 | end 33 | 34 | assert extract_vars(ast) == [:x] 35 | end 36 | 37 | test "it works named functions" do 38 | ast = 39 | quote do 40 | def my_f(a, b) do 41 | c = a + b + x 42 | end 43 | 44 | my_f(1, 2) 45 | end 46 | 47 | assert extract_vars(ast) == [:x] 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /test/compiler/expr_cond_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Formular.Compiler.CondTest do 2 | use ExUnit.Case, async: true 3 | import Formular.Compiler, only: [extract_vars: 1] 4 | 5 | describe "`with`" do 6 | test "it works" do 7 | ast = 8 | quote do 9 | cond do 10 | a == 1 -> 11 | :ok 12 | end 13 | end 14 | 15 | assert extract_vars(ast) == [:a] 16 | end 17 | 18 | test "it works with nested scope" do 19 | ast = 20 | quote do 21 | case target do 22 | %{a: a} -> 23 | cond do 24 | a == 1 -> 25 | :ok 26 | end 27 | end 28 | end 29 | 30 | assert extract_vars(ast) == [:target] 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /test/compiler/expr_for_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Formular.Compiler.ForTest do 2 | use ExUnit.Case, async: true 3 | import Formular.Compiler, only: [extract_vars: 1] 4 | 5 | describe "`for`" do 6 | test "it works" do 7 | ast = 8 | quote do 9 | for x <- 1..50, y <- 1..m, do: {x, y, z} 10 | end 11 | 12 | assert extract_vars(ast) == [:m, :z] 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /test/compiler/expr_with_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Formular.Compiler.WithTest do 2 | use ExUnit.Case, async: true 3 | import Formular.Compiler, only: [extract_vars: 1] 4 | 5 | describe "`with`" do 6 | test "it works" do 7 | ast = 8 | quote do 9 | with {:ok, a} <- test(b) do 10 | :ok 11 | end 12 | end 13 | 14 | assert extract_vars(ast) == [:b] 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /test/compiler/scope_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Formular.Compiler.ScopeTest do 2 | use ExUnit.Case, async: true 3 | 4 | import Formular.Compiler, only: [extract_vars: 1] 5 | 6 | describe "scopes" do 7 | test "it works" do 8 | ast = 9 | quote do 10 | case input do 11 | {:ok, x} -> 12 | x 13 | 14 | :error -> 15 | x 16 | end 17 | end 18 | 19 | assert extract_vars(ast) == [:input, :x] 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /test/compiler/top_level_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Formular.Compiler.TopLevelTest do 2 | use ExUnit.Case, async: true 3 | 4 | import Formular.Compiler, only: [extract_vars: 1] 5 | 6 | describe "top level scope variables" do 7 | test "it works" do 8 | ast = 9 | quote do 10 | a 11 | end 12 | 13 | assert extract_vars(ast) == [:a] 14 | end 15 | 16 | test "it works with multiple variables" do 17 | ast = 18 | quote do 19 | a 20 | b 21 | end 22 | 23 | assert Enum.sort(extract_vars(ast)) == [:a, :b] 24 | end 25 | 26 | test "it works with assignments" do 27 | ast = 28 | quote do 29 | a = 5 30 | b 31 | end 32 | 33 | assert extract_vars(ast) == [:b] 34 | end 35 | 36 | test "it works with tuples" do 37 | ast = 38 | quote do 39 | {a, b} = {1, 2} 40 | {x, y, z} = {:a, :b, c} 41 | end 42 | 43 | assert extract_vars(ast) == [:c] 44 | end 45 | 46 | test "it works with maps" do 47 | ast = 48 | quote do 49 | %{a: a, b: b} = %{a: 1, b: 2} 50 | %{x: x, y: y, z: z} = %{x: :a, y: :b, z: c} 51 | end 52 | 53 | assert extract_vars(ast) == [:c] 54 | end 55 | 56 | test "it works with keyword lists" do 57 | ast = 58 | quote do 59 | [a: a, b: b] = [a: 1, b: 2] 60 | [x: x, y: y, z: z] = [x: :a, y: :b, z: c] 61 | end 62 | 63 | assert extract_vars(ast) == [:c] 64 | end 65 | 66 | test "it works pin operator" do 67 | ast = 68 | quote do 69 | ^x = 1 70 | %{y: ^y} = %{y: 3} 71 | end 72 | 73 | assert extract_vars(ast) |> Enum.sort() == [:x, :y] 74 | end 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /test/compiler_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Formular.CompilerTest do 2 | use ExUnit.Case, async: true 3 | 4 | doctest Formular.Compiler 5 | 6 | describe "literal code support" do 7 | test "it supports atoms" do 8 | {:module, _} = Formular.compile_to_module!(":ok", :ok_module) 9 | assert Formular.eval({:module, :ok_module}, []) == {:ok, :ok} 10 | end 11 | 12 | test "it supports tuples" do 13 | {:module, _} = Formular.compile_to_module!("{:ok, :tuple}", :literal_tuple) 14 | assert Formular.eval({:module, :literal_tuple}, []) == {:ok, {:ok, :tuple}} 15 | end 16 | 17 | test "it supports numbers" do 18 | {:module, _} = Formular.compile_to_module!("3.14", :literal_number) 19 | assert Formular.eval({:module, :literal_number}, []) == {:ok, 3.14} 20 | end 21 | 22 | test "it supports binaries" do 23 | {:module, _} = Formular.compile_to_module!(~s("ABC"), :literal_binary) 24 | assert Formular.eval({:module, :literal_binary}, []) == {:ok, "ABC"} 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /test/features/access_get_test.exs: -------------------------------------------------------------------------------- 1 | defmodule AccessGetTest do 2 | use ExUnit.Case 3 | 4 | defdelegate eval(code, binding, opts \\ []), to: Formular 5 | 6 | test "with `Access.get/2`" do 7 | code = """ 8 | params[:foo] 9 | """ 10 | 11 | assert eval(code, params: %{}) == {:ok, nil} 12 | assert eval(code, params: %{foo: "bar"}) == {:ok, "bar"} 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /test/features/allowed_modules.exs: -------------------------------------------------------------------------------- 1 | defmodule AllowedModulesTest do 2 | use ExUnit.Case 3 | 4 | defdelegate eval(code, binding, opts \\ []), to: Formular 5 | 6 | describe "Directly calling module functions" do 7 | test "works" do 8 | code = """ 9 | Enum.sort([3, 2, 1]) 10 | """ 11 | 12 | assert eval(code, [], allow_modules: [Enum]) == {:ok, [1, 2, 3]} 13 | end 14 | 15 | test "works with Erlang modules" do 16 | code = """ 17 | min(0, :os.system_time()) 18 | """ 19 | 20 | assert eval(code, [], allow_modules: [:os]) == {:ok, 0} 21 | end 22 | 23 | test "works with expression" do 24 | code = """ 25 | m = Enum 26 | m.count([]) 27 | """ 28 | 29 | assert eval(code, [], allow_modules: [Enum]) == {:ok, 0} 30 | end 31 | end 32 | 33 | describe "Importing allowed modules" do 34 | test "works" do 35 | code = """ 36 | import Enum 37 | sort([3, 2, 1]) 38 | """ 39 | 40 | assert eval(code, [], allow_modules: [Enum]) == {:ok, [1, 2, 3]} 41 | end 42 | 43 | test "works with `except` opts" do 44 | code = """ 45 | import Enum, except: [sort: 2] 46 | sort([3, 2, 1]) 47 | """ 48 | 49 | assert eval(code, [], allow_modules: [Enum]) == {:ok, [1, 2, 3]} 50 | end 51 | 52 | test "works with `except` option and returns error" do 53 | code = """ 54 | import Enum, except: [sort: 1] 55 | sort([3, 2, 1]) 56 | """ 57 | 58 | assert {:error, %CompileError{}} = eval(code, [], allow_modules: [Enum]) 59 | end 60 | end 61 | 62 | describe "Allowed modules for requiring" do 63 | test "works" do 64 | code = """ 65 | require Logger 66 | Logger.debug("hi") 67 | """ 68 | 69 | assert eval(code, [], allow_modules: [Logger]) == {:ok, :ok} 70 | end 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /test/features/case_test.exs: -------------------------------------------------------------------------------- 1 | defmodule CaseTest do 2 | use ExUnit.Case 3 | 4 | defdelegate eval(code, binding, opts \\ []), to: Formular 5 | 6 | test "with `case`" do 7 | code = """ 8 | case params do 9 | %{user_name: "Alex"} -> 10 | "Hello, Alex!" 11 | 12 | _ -> 13 | "Hello!" 14 | end 15 | """ 16 | 17 | assert eval(code, params: nil) == {:ok, "Hello!"} 18 | assert eval(code, params: %{user_name: "Alex"}) == {:ok, "Hello, Alex!"} 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /test/features/compile_to_module_test.exs: -------------------------------------------------------------------------------- 1 | defmodule CompileToModuleTest do 2 | use ExUnit.Case, async: true 3 | 4 | defmodule CompileToModuleTest.ContextModule do 5 | def foo, do: 42 6 | end 7 | 8 | describe "compile_to_module!/2" do 9 | test "it works" do 10 | code = """ 11 | 1 + 5 12 | """ 13 | 14 | mod = FormularModule 15 | assert {:module, ^mod} = Formular.compile_to_module!(code, mod) 16 | assert mod.run([]) == 6 17 | end 18 | 19 | test "it works with bindings" do 20 | code = """ 21 | 1 + foo 22 | """ 23 | 24 | mod = FormularModule2 25 | assert {:module, ^mod} = Formular.compile_to_module!(code, mod) 26 | assert mod.run(foo: 42) == 43 27 | end 28 | 29 | test "it works with context module" do 30 | code = """ 31 | 1 + foo() 32 | """ 33 | 34 | mod = FormularModule3 35 | 36 | assert {:module, ^mod} = 37 | Formular.compile_to_module!(code, mod, CompileToModuleTest.ContextModule) 38 | 39 | assert mod.run([]) == 43 40 | end 41 | end 42 | 43 | describe "kernel functions" do 44 | test "it works with allowed functions" do 45 | code = """ 46 | max(1, 2) 47 | """ 48 | 49 | mod = FormularModule4 50 | assert {:module, ^mod} = Formular.compile_to_module!(code, mod) 51 | assert mod.run([]) == 2 52 | end 53 | 54 | test "it fails with disallowed functions" do 55 | code = """ 56 | exit() 57 | """ 58 | 59 | mod = FormularModule5 60 | assert_raise CompileError, fn -> Formular.compile_to_module!(code, mod) end 61 | end 62 | end 63 | 64 | describe "exmaple code" do 65 | test "it works" do 66 | code = """ 67 | for n <- args do 68 | case n do 69 | _ when is_integer(n) -> 70 | n * n 71 | 72 | _ -> 73 | n 74 | end 75 | end 76 | """ 77 | 78 | mod = Formular.compile_to_module!(code, :test_module) 79 | assert {:ok, [9]} = Formular.eval(mod, args: [3]) 80 | end 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /test/features/custom_macro_test.exs: -------------------------------------------------------------------------------- 1 | defmodule CustomMacroTest do 2 | use ExUnit.Case 3 | 4 | defdelegate eval(code, binding, opts \\ []), to: Formular 5 | 6 | test "with custom macro" do 7 | defmodule Context do 8 | defmacro hello do 9 | quote do 10 | unquote("hi") 11 | end 12 | end 13 | end 14 | 15 | code = """ 16 | hello() 17 | """ 18 | 19 | assert eval(code, [], context: Context) == {:ok, "hi"} 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /test/features/error_handling_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ErrorHandlingTest do 2 | use ExUnit.Case 3 | 4 | defdelegate eval(code, binding, opts \\ []), to: Formular 5 | 6 | test "it works with runtime error" do 7 | code = "1 / 0" 8 | 9 | assert eval(code, params: nil) == 10 | {:error, %ArithmeticError{message: "bad argument in arithmetic expression"}} 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /test/features/exception_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExceptionTest do 2 | use ExUnit.Case 3 | 4 | defdelegate eval(code, binding, opts \\ []), to: Formular 5 | 6 | test "disallowing `raise`" do 7 | code = """ 8 | raise "test" 9 | """ 10 | 11 | assert_compile_error(code) 12 | end 13 | 14 | test "disallowing `throw`" do 15 | code = """ 16 | throw "test" 17 | """ 18 | 19 | assert_compile_error(code) 20 | end 21 | 22 | defp assert_compile_error(code) do 23 | assert {:error, %CompileError{file: "nofile"}} = eval(code, []) 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /test/features/if_test.exs: -------------------------------------------------------------------------------- 1 | defmodule IfTest do 2 | use ExUnit.Case 3 | 4 | defdelegate eval(code, binding, opts \\ []), to: Formular 5 | 6 | test "with `if`" do 7 | code = """ 8 | if n > 0 do 9 | 1 10 | else 11 | -1 12 | end 13 | """ 14 | 15 | assert eval(code, n: 100) == {:ok, 1} 16 | assert eval(code, n: -10) == {:ok, -1} 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /test/features/in_test.exs: -------------------------------------------------------------------------------- 1 | defmodule InTest do 2 | use ExUnit.Case 3 | 4 | defdelegate eval(code, binding, opts \\ []), to: Formular 5 | 6 | test "with `in`" do 7 | code = """ 8 | 1 in [1, 2] 9 | """ 10 | 11 | assert eval(code, []) == {:ok, true} 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /test/features/kernel_function_list_test.exs: -------------------------------------------------------------------------------- 1 | defmodule KernelFunctionListTest do 2 | use ExUnit.Case, async: true 3 | 4 | import Formular.DefaultFunctions 5 | 6 | test "the full list" do 7 | functions = [ 8 | !: 1, 9 | !=: 2, 10 | !==: 2, 11 | &&: 2, 12 | *: 2, 13 | +: 1, 14 | +: 2, 15 | ++: 2, 16 | ++: 2, 17 | -: 1, 18 | -: 2, 19 | --: 2, 20 | --: 2, 21 | ..: 2, 22 | "..//": 3, 23 | /: 2, 24 | <: 2, 25 | <=: 2, 26 | <>: 2, 27 | ==: 2, 28 | =~: 2, 29 | >: 2, 30 | >=: 2, 31 | abs: 1, 32 | and: 2, 33 | ceil: 1, 34 | div: 2, 35 | floor: 1, 36 | get_and_update_in: 2, 37 | get_and_update_in: 3, 38 | get_in: 2, 39 | hd: 1, 40 | if: 2, 41 | in: 2, 42 | inspect: 2, 43 | is_atom: 1, 44 | is_binary: 1, 45 | is_bitstring: 1, 46 | is_boolean: 1, 47 | is_exception: 1, 48 | is_exception: 2, 49 | is_float: 1, 50 | is_function: 1, 51 | is_integer: 1, 52 | is_list: 1, 53 | is_map: 1, 54 | is_map_key: 2, 55 | is_nil: 1, 56 | is_number: 1, 57 | is_pid: 1, 58 | is_port: 1, 59 | is_reference: 1, 60 | is_struct: 1, 61 | is_struct: 2, 62 | is_tuple: 1, 63 | length: 1, 64 | map_size: 1, 65 | max: 2, 66 | min: 2, 67 | not: 1, 68 | or: 2, 69 | pop_in: 1, 70 | pop_in: 2, 71 | put_elem: 3, 72 | put_in: 2, 73 | put_in: 3, 74 | rem: 2, 75 | round: 1, 76 | sigil_C: 2, 77 | sigil_D: 2, 78 | sigil_N: 2, 79 | sigil_R: 2, 80 | sigil_S: 2, 81 | sigil_T: 2, 82 | sigil_U: 2, 83 | sigil_W: 2, 84 | sigil_c: 2, 85 | sigil_r: 2, 86 | sigil_s: 2, 87 | sigil_w: 2, 88 | struct: 2, 89 | struct!: 2, 90 | tap: 2, 91 | then: 2, 92 | tl: 1, 93 | to_charlist: 1, 94 | to_string: 1, 95 | trunc: 1, 96 | tuple_size: 1, 97 | unless: 2, 98 | |>: 2, 99 | ||: 2 100 | ] 101 | 102 | for f = {name, arity} <- functions do 103 | if function_exported?(Kernel, name, arity) do 104 | assert f in kernel_functions(), "#{inspect(f)} is expected to be supported but not" 105 | end 106 | 107 | if macro_exported?(Kernel, name, arity) do 108 | assert f in kernel_macros(), "#{inspect(f)} is expected to be supported but not" 109 | end 110 | end 111 | end 112 | end 113 | -------------------------------------------------------------------------------- /test/features/list_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ListTest do 2 | use ExUnit.Case 3 | 4 | defdelegate eval(code, binding, opts \\ []), to: Formular 5 | 6 | test "list" do 7 | code = """ 8 | [1, 2, 3] 9 | """ 10 | 11 | assert eval(code, []) == {:ok, [1, 2, 3]} 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /test/features/pipe_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PipeTest do 2 | use ExUnit.Case 3 | 4 | alias Formular.TestContext 5 | 6 | defdelegate eval(code, binding, opts \\ []), to: Formular 7 | 8 | test "with pipe" do 9 | assert eval("100 |> no_more_than(10)", [], context: TestContext) == {:ok, 10} 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /test/features/sigil_test.exs: -------------------------------------------------------------------------------- 1 | defmodule SigilTest do 2 | use ExUnit.Case 3 | 4 | defdelegate eval(code, binding, opts \\ []), to: Formular 5 | 6 | test "with `~w`" do 7 | code = """ 8 | ~w[a b c] 9 | """ 10 | 11 | assert eval(code, []) == {:ok, ~w[a b c]} 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /test/features/tap_test.exs: -------------------------------------------------------------------------------- 1 | defmodule TapTest do 2 | use ExUnit.Case 3 | 4 | defdelegate eval(code, binding, opts \\ []), to: Formular 5 | 6 | test "tap" do 7 | run = fn -> 8 | eval("~s'100' |> tap(&puts/1)", [], context: IO) 9 | end 10 | 11 | assert run.() in [ 12 | {:ok, "100"}, 13 | {:error, 14 | %CompileError{description: "undefined function tap/2", file: "nofile", line: 1}} 15 | ] 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /test/features/then_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ThenTest do 2 | use ExUnit.Case 3 | 4 | defdelegate eval(code, binding, opts \\ []), to: Formular 5 | 6 | test "then" do 7 | assert eval(":a |> then(&to_string/1)", []) in [ 8 | {:ok, "a"}, 9 | {:error, 10 | %CompileError{description: "undefined function then/2", file: "nofile", line: 1}} 11 | ] 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /test/features/timeout_test.exs: -------------------------------------------------------------------------------- 1 | defmodule TimeoutTest do 2 | use ExUnit.Case 3 | 4 | defdelegate eval(code, binding, opts \\ []), to: Formular 5 | 6 | test "it kills the evaluating process after a given time" do 7 | sleep = fn -> 8 | :timer.sleep(:infinity) 9 | end 10 | 11 | assert eval("sleep.()", [sleep: sleep], timeout: 10) == 12 | {:error, :timeout} 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /test/formular_test.exs: -------------------------------------------------------------------------------- 1 | defmodule FormularTest do 2 | use ExUnit.Case 3 | doctest Formular 4 | 5 | alias Formular.TestContext 6 | 7 | defdelegate eval(code, binding, opts \\ []), to: Formular 8 | 9 | test "evaluating" do 10 | assert eval("a + b", a: 1, b: -1) == {:ok, 0} 11 | end 12 | 13 | test "argument error" do 14 | assert {:error, %CompileError{}} = eval("x(1, 2)", x: 42) 15 | end 16 | 17 | test "with custom context" do 18 | assert eval("1 + foo()", [], context: TestContext) == {:ok, 43} 19 | assert eval("1 + my_div(foo(), 2)", [], context: TestContext) == {:ok, 22} 20 | end 21 | 22 | test "multiple lines" do 23 | f = """ 24 | 100 25 | |> no_more_than(10) 26 | """ 27 | 28 | assert eval(f, [], context: TestContext) == {:ok, 10} 29 | end 30 | 31 | test "with `if`" do 32 | f = """ 33 | a = 10 34 | if a >= 5 do 35 | "GTE" 36 | else 37 | "LT" 38 | end 39 | """ 40 | 41 | assert eval(f, []) == {:ok, "GTE"} 42 | end 43 | 44 | test "defining a function" do 45 | f = """ 46 | a = fn -> 47 | :foo 48 | end 49 | 50 | a.() 51 | """ 52 | 53 | assert eval(f, []) == {:ok, :foo} 54 | end 55 | 56 | test "calling a module function in a newly defined function will not work" do 57 | f = """ 58 | a = fn -> 59 | :os.system_time() 60 | end 61 | 62 | a.() 63 | """ 64 | 65 | assert eval(f, []) == {:error, :no_calling_module_function} 66 | end 67 | 68 | describe "used_vars/1" do 69 | test "returns the used variables" do 70 | f = 71 | "for ret when not is_nil(ret) <- [\n if(match?({_type, _name, _}, plan),\n do: 1\n)], do: ret\n" 72 | 73 | assert [:plan] == Formular.used_vars(f) 74 | end 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /test/security/exit_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExitTest do 2 | use ExUnit.Case 3 | 4 | defdelegate eval(code, binding, opts \\ []), to: Formular 5 | 6 | test "disallowing `exit`" do 7 | code = """ 8 | exit("test") 9 | """ 10 | 11 | assert {:error, %CompileError{description: _, file: "nofile"}} = 12 | eval(code, []) 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /test/security/heap_size_limit_test.exs: -------------------------------------------------------------------------------- 1 | defmodule HeapSizeLimitTest do 2 | use ExUnit.Case 3 | 4 | defdelegate eval(code, binding, opts \\ []), to: Formular 5 | 6 | test "it works atom flooding" do 7 | code = ~S|for a <- struct(Range, %{first: 0, last: 999_999_999_999, step: 1}), do: :"#{a}"| 8 | 9 | assert eval(code, [], timeout: :infinity, max_heap_size: 1_000_000) == {:error, :killed} 10 | end 11 | 12 | test "it works binary flooding" do 13 | code = ~S|for a <- 0..999_999_999_999, do: "#{a}"| 14 | 15 | assert eval(code, [], timeout: :infinity, max_heap_size: 1_000_000) == {:error, :killed} 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /test/security/no_calling_module_function.exs: -------------------------------------------------------------------------------- 1 | defmodule NoCallingModuleFunctionTest do 2 | use ExUnit.Case 3 | 4 | defdelegate eval(code, binding, opts \\ []), to: Formular 5 | 6 | test "it returns error when calling module function" do 7 | assert eval("Kernel.exit(:normal)", []) == {:error, :no_calling_module_function} 8 | end 9 | 10 | test "it returns error when trying to extract module function" do 11 | assert eval("&Kernel.exit/1", []) == {:error, :no_calling_module_function} 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /test/security/no_importing.exs: -------------------------------------------------------------------------------- 1 | defmodule NoImportingTest do 2 | use ExUnit.Case 3 | 4 | defdelegate eval(code, binding, opts \\ []), to: Formular 5 | 6 | test "it returns error when importing" do 7 | assert eval("import Kernel", []) == {:error, :no_import_or_require} 8 | end 9 | 10 | test "it returns error when requiring" do 11 | assert eval("require Logger", []) == {:error, :no_import_or_require} 12 | end 13 | 14 | test "it is ok to require when is specifically set as allowed" do 15 | assert eval("require Logger\n:ok", [], allow_modules: [Logger]) == {:ok, :ok} 16 | end 17 | 18 | test "it is ok to import when is specifically set as allowed" do 19 | assert eval("import Logger\n:ok", [], allow_modules: [Logger]) == {:ok, :ok} 20 | end 21 | 22 | test "requiring returns error when not specifically set as allowed" do 23 | assert eval("require Logger\n:ok", [], allow_modules: [:logger]) == 24 | {:error, :no_import_or_require} 25 | end 26 | 27 | test "importing returns error when not specifically set as allowed" do 28 | assert eval("import Logger\n:ok", [], allow_modules: [:logger]) == 29 | {:error, :no_import_or_require} 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /test/support/test_context.ex: -------------------------------------------------------------------------------- 1 | defmodule Formular.TestContext do 2 | @moduledoc false 3 | 4 | def foo do 5 | 42 6 | end 7 | 8 | def my_div(a, b) do 9 | div(a, b) 10 | end 11 | 12 | def no_more_than(a, b) do 13 | min(a, b) 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | --------------------------------------------------------------------------------