pure_fun

A Mix task that tells you which functions have side effects.

$ mix pure_fun --all
Payments.Core
fee/2 pure
settle/2 pure if the fun given as argument 2 is pure
charge/2 impure: performs I/O (IO.puts/1) via Payments.Core.log/1
1 pure, 1 conditional, 1 impure, 0 unknown (3 functions)

It reads the abstract code out of compiled .beam files, so it works on both Elixir and Erlang, sees your code after macro expansion, and needs no annotations to say something useful. Calls into your dependencies are followed too, so an effect three libraries deep still comes back with the function that has it.

Install

def deps do
[{:pure_fun, "~> 0.1", only: [:dev, :test], runtime: false}]
end

Use

mix pure_fun # every module in the project
mix pure_fun Payments.Core # one module
mix pure_fun Payments.Core.fee/2 # one function
mix pure_fun --check # fail the build if a @pure_fun function is not pure
Option Effect
--check Exit non-zero when an annotation is not kept. The CI mode.
--all List pure functions too.
--no-deps Do not follow calls into dependencies. Faster, at the cost of reporting every call into a library as unknown.
--unknown List functions whose purity could not be determined.
--private Include private functions.

Verdicts

pure — computes a return value and nothing else. Raising is still pure: an exception is a result, not an effect.

conditional — pure as long as the funs passed at the given argument positions are pure. def each(list, fun) is not impure, it is someone else's purity. Call sites are resolved individually, so Enum.map(list, &String.upcase/1) is pure while Enum.map(list, &IO.puts/1) is not.

impure — reaches an effect, reported with the function that has it and the callee it came in through:

charge/2 impure: performs I/O (IO.puts/1) via Payments.Core.log/1

unknown — the trail was lost: an apply/3 with a computed module, a fun the analyser could not resolve, or a module it has no knowledge of and could not read.

That fourth verdict is the point. Folding "cannot tell" into either "pure" or "impure" would make every other answer untrustworthy.

Annotating

defmodule Payments.Core do
use PureFun
@pure_fun true
def fee(amount, rate), do: round(amount * rate)
end

mix pure_fun --check now fails if fee/2 ever grows an effect — a functional core that stays a functional core.

A function that owns up to one kind of effect says which:

@pure_fun except: [:time]
def quote(amount), do: {DateTime.utc_now(), fee(amount, 0.03)}

The waiver belongs to quote/1 and to nothing else. A caller annotated plain @pure_fun still fails on the clock its callee reads, which is what stops a waiver from laundering effects through the rest of the call graph. Telling the analyser about code it cannot see is a different job, and :known below is where that lives.

A whole module can make the claim at once, which is the useful form for a functional core: a function added to it tomorrow is covered the day it lands.

defmodule Payments.Core do
use PureFun
@pure_fun_module except: [:time]
def fee(amount, rate), do: round(amount * rate)
end

A module-wide claim covers every public function and no private one. A function inside it may narrow what its module waives, never widen it. To exempt one function entirely, use Credo's own # credo:disable-for-next-line PureFun.Check.Purity.

The classes an annotation may name are the ones the analyser reports: :io, :file, :network, :system, :time, :random, :process, :process_dictionary, :message, :message_receive, :ets, :persistent_term, :mutable_state, :code_loading, :port, :tracing, and the three that mean the trail was lost rather than an effect found: :dynamic_call, :higher_order and :unknown. A misspelt one is an error rather than a waiver of nothing, and fails the compile.

Two verdicts keep a claim rather than breaking it. A higher-order function is pure in itself — whoever hands it &IO.puts/1 fails on their own annotation — so conditional passes. unknown does not: an annotation nobody can check is the case this tool exists to report, and waiving it takes saying except: [:unknown].

In Erlang:

-pure_fun_annotated([{fee, 2}, {quote, 1, [time]}]).
-pure_fun_module([{except, [time]}]).

As a Credo check

If the project already runs Credo, the same answers arrive as ordinary Credo issues, on the line the annotation sits on:

# .credo.exs
%{
configs: [
%{
name: "default",
checks: %{extra: [{PureFun.Check.Purity, []}]}
}
]
}
┃ [W] ↗ charge/2 is annotated @pure_fun but is impure: performs I/O
┃ (IO.puts/1) via Payments.Core.log/1
┃ lib/payments/core.ex:24:7 #(Payments.Core.charge)

Credo has to be a dependency of your project for the check to exist: this library declares it as optional, so without it the check is not compiled at all and pure_fun still brings nothing with it.

{:credo, "~> 1.7", only: [:dev, :test], runtime: false}
Param Effect
known The same %{mfa => answer} map as mix.exs, merged over it.
follow_deps Follow calls into dependencies. On by default; without it, a call into a library is unknown and an annotation that reaches one cannot be kept.

The check reads annotations from the source and answers from the compiled code, which has three consequences worth knowing:

A waiver that has outlived the effect it was written for is reported too, at low priority and with no exit status of its own: an annotation that is merely out of date says something untrue about the code, but it cannot make the check miss anything, so it does not fail a build.

Teaching it about a library

Unknown functions are reported, never guessed. When you know better than the analyser, say so in mix.exs:

def project do
[
pure_fun: [
known: %{
{MyLib.Cache, :get, 1} => {:impure, :ets},
{MyLib.Money, :add, 2} => :pure,
{MyLib.Fold, :run, 2} => {:hof, [2]}
}
]
]
end

Dispatch

A call that picks its target at runtime is only as pure as the implementations it can reach, so all of them have to be pure for the dispatch to be pure. A protocol is just a behaviour whose implementations live in their own modules, so both go through one rule: a call to a module that declares the callback joins over the implementations.

defimpl String.Chars, for: Loud do
def to_string(loud) do
IO.puts("converting") # one impure implementation is enough
loud.name
end
end
"hello #{term}" # impure: it can reach that implementation

When the call site says which implementation it will reach, only that one counts:

for x <- xs, into: %{} # pure — only Collectable.Map can be reached
for x <- xs, into: stream # impure if a reachable Collectable writes

The same holds without a module name at all, because the function name is often enough — calendar.date_to_string(y, m, d) names no module, but date_to_string/3 is a Calendar callback, so the implementations are known.

Implementations are found without being asked for: analysing a module pulls in the protocols it dispatches to along with their implementations.

How it works

  1. Scan. Every function body is walked for the things that can have an effect: calls, fun references, !, receive. Each call argument keeps a shape — a resolvable fun, a parameter of the enclosing function, a literal of a known type, or opaque.
  2. Resolve. Each call becomes a dependency on another analysed function, a set of dependencies on the implementations it dispatches to, a known effect, or an unknown. Applying a parameter makes the function higher-order at that position rather than impure.
  3. Fixpoint. Effects propagate backwards along the call graph until nothing changes. Recursion needs no special case: the least fixpoint starts at "no effects" and only grows.

Leaves of the call graph are BIFs and NIFs whose abstract code shows nothing — :ets.insert/2 compiles to erlang:nif_error(undef), and believing that would report it as pure. PureFun.Knowledge is the hand-maintained table that stops the analysis from bottoming out in a lie, and it always wins over what the code appears to do.

What it cannot see

Development

mix test
mix test --cover
mix pure_fun --all # it analyses itself

Running it on itself reports every function of the analysis core as pure, and every function that touches beam files or Mix as impure.