Feel.Runtime.Env (feel v0.1.0)

Copy Markdown View Source

The environment of one run: its mode, its dialect, its extensions, its clock, its limits, and the diagnostics it has produced so far.

The runtime is made of pure functions on values, and the environment is the one thing that is threaded through a run. Everything that depends on the run and not on the expression lives here:

  • :mode - :lenient (the default) or :strict, as in Feel.run/3.
  • :dialect - the dialect the expression was prepared in (Feel.Dialect, D-49). It is a property of the prepared expression, not of a run: the backend puts it here. What is defined for an error in FEEL is null; B-FEEL will read this to give the value of the type instead. S-FEEL has the semantics of FEEL, so nothing reads it for that.
  • :extensions - the Feel.Extensions the expression was prepared with (D-81). Like the dialect, it is a property of the prepared expression: the backend puts it here, and a call of a function finds the function through it (Feel.Builtin.call/3).
  • :clock - the moment now() and today() see. It is fixed when the environment is built, so repeated calls in one run agree. A DateTime that was given is kept as it is. Without one (D-148), the instant is taken as a number of microseconds ({:os_time, microseconds}), and made into a DateTime only when clock/1 is asked, since most expressions never ask and a DateTime costs about as much as everything else a run does. Read it with clock/1. It is nil in the template of an executable (template/1), which has no moment yet.
  • :limits - the Feel.Limits.t/0 of the run.
  • :entropy - where random bytes come from, for the functions of an extension that are random (D-81): a function from a number of bytes to that many bytes, :crypto.strong_rand_bytes/1 by default. A run can be given its own to be repeatable. Nothing in DMN 1.6 is random.
  • :diagnostics - what has been reported, newest first. Use diagnostics/1.
  • :diagnostic_count - how many there are in :diagnostics, kept so that the ones that an operation reported can be told from the ones before it without counting the list (D-103). Whoever puts another list in :diagnostics puts its length here.
  • :declared - the errors that the checks of the bound values reported when the run began (declared/2, D-103), newest first. They are at the span of the first read of each name, and so inside the operations that read it.
  • :call_depth - how many calls of a function value the run is in (D-82), which max_call_depth of the limits caps.
  • :calls - how many calls of a function of FEEL the run has made, nested or not (D-154), which max_calls of the limits caps.
  • :iterations - how many elements the iterators of for, some and every have taken in the run (D-86), which max_iterations of the limits caps.
  • :links - the closures that Feel.Recursion has made in this run (D-200), by the link and the member they are for. A function that calls itself needs its closure at each call, and makes it at the first: the closure is a value of the run like the diagnostics, and a function value that outlives the run finds this empty in the next one and makes it again.
  • :origin - the origin of the text that is being run (D-201): the :origin option of the run (nil unless the host gave one) for the expression of the run, and for the body of a function the origin that function has, which a function written in an expression takes from the run it is made in. A diagnostic is reported with it (Feel.Diagnostic, :origin), so that its span can be found in the text it is of.

Outcomes

A runtime operation does not touch the environment. It returns an outcome/0, and settle/3 turns that into a value and a new environment:

  • {:ok, value} - the result. The value may be null: some results are defined to be.
  • {:ok, value, env} - the result, from a function that called a function value on the way, as list replace calls its match (D-82). The environment it gives back has what those calls reported, and the diagnostics in it that have no span are given the one of the operation.
  • {:ok, value, [diagnostic]} - the result, with what the operation did on a value's behalf and says (the implicit conversion of a date in a subtraction, D-56): the diagnostics are reported, with the span of the operation if they have none, and then the value is the result.
  • :missing - the result is null because an operand is null: missing data, not a mistake. Nothing is reported.
  • {:error, diagnostic} - the result is null because of a semantic error, such as a type mismatch or a division by zero (DMN 1.6, section 7.3.8). It is reported.

Which null results count as errors is D-103 (was D-103), after the TCK's flags: a null operand of an arithmetic operator, and of <= and >=, is {:error, _} (Feel.Runtime.NullOperand), and one of <, >, and, or and not is :missing; settle/3 is the one place that applies the split. In lenient mode a reported diagnostic is collected and evaluation goes on. In strict mode the first error ends the run, and warnings and infos do not. run/2 is where that ends up as {:error, error}.

Limits

settle/3 also checks the size of the value it is given against :limits, since every backend passes its results through it. A string or list that is too long ends the run with an error of kind :limit, in either mode. A function whose result can be as long as the product of the sizes of its arguments asks the same question before it makes the result, with check_string_length/2 and check_list_length/2 (D-154 does it for a loop), so that the error is the same and nothing is built that the limit refuses (Feel.Builtin.Size).

report/2 counts the diagnostics against max_diagnostics.

Summary

Types

What a runtime operation returns; see the module documentation.

t()

Functions

Ends the run with an error, in either mode.

Ends the run, in either mode, with an error of kind :limit if a list of length elements is longer than max_list_length, and gives the environment back if it is not (D-154).

Ends the run, in either mode, with an error of kind :limit if a string of size bytes is longer than max_string_length, and gives the environment back if it is not.

The moment now() and today() see: the DateTime that was given as the :clock, or, without one, the instant at which the environment was built, as a DateTime in UTC with the precision of a microsecond (what DateTime.utc_now/0 gives). Every call on one environment gives the same moment (D-148).

Counts one element taken by an iterator of for, some or every (D-86). A run in which they have taken more than max_iterations ends, in either mode, with an error of kind :limit.

Keeps the errors that were reported since since as the ones of the declarations of the names (D-103).

Returns the diagnostics reported so far, oldest first.

Enters a call of a function of FEEL (D-82): the depth of the run goes up by one, and so does the number of calls it has made (D-154). A run that passes max_call_depth or max_calls ends in either mode, as one that passes any other limit does.

Leaves a call: the depth is the one that the run was at before it (depth). The number of calls is not: they have been made.

Builds the environment of a run.

Runs fun on the environment and keeps what it reports out of it (D-100).

count random bytes, from the :entropy of the environment. A source that gives anything but that many bytes is a mistake of its caller and raises ArgumentError.

Reports a diagnostic.

Runs fun on the environment and returns the result in the form Feel.run/3 does.

Turns the outcome of an operation into a value and an environment.

The environment of one run, from the template of its executable (D-148) and the run options :mode, :clock, :limits :entropy and :origin, which mean what they mean for new/1, with the same errors. It is what new/1 gives for the options of the template and these, and does the work of checking only the options that are given: a run with none takes the clock and nothing else.

The environment that the runs of one executable start from (D-148): new/1 with the options of the executable (its :dialect and :extensions) and no clock, which start/2 takes at each run. An executable makes it once, when it is compiled, and so a run does not check and build the same options again. It has no clock so that two executables of the same expression are equal.

Runs fun with origin as the origin of the text, and gives the environment back with the origin it had (D-201).

Types

mode()

@type mode() :: :lenient | :strict

outcome()

@type outcome() ::
  {:ok, Feel.Value.t()}
  | {:ok, Feel.Value.t(), t()}
  | {:ok, Feel.Value.t(), [Feel.Diagnostic.t(), ...]}
  | :missing
  | {:error, Feel.Diagnostic.t()}

What a runtime operation returns; see the module documentation.

t()

@type t() :: %Feel.Runtime.Env{
  budget: Feel.Runtime.Budget.t() | nil,
  call_depth: non_neg_integer(),
  calls: non_neg_integer(),
  clock: DateTime.t() | {:os_time, integer()} | nil,
  declared: [Feel.Diagnostic.t()],
  diagnostic_count: non_neg_integer(),
  diagnostics: [Feel.Diagnostic.t()],
  dialect: Feel.Dialect.t(),
  entropy: (non_neg_integer() -> binary()) | nil,
  extensions: Feel.Extensions.t(),
  iterations: non_neg_integer(),
  limits: Feel.Limits.t(),
  links: %{optional({reference(), term()}) => Feel.Value.t()},
  mode: mode(),
  monitor: (Feel.IR.t(), Feel.Value.t() -> term()) | nil,
  origin: term()
}

Functions

abort(env, kind, diagnostic)

@spec abort(t(), Feel.Error.kind(), Feel.Diagnostic.t()) :: no_return()

Ends the run with an error, in either mode.

A strict run ends at its first error (report/2), and a run that passes a limit ends in either mode; this is for a mistake that is not the expression's and that makes the run worthless, as a value that is not allowed by the declaration of the scope. run/2 returns {:error, error}. The error has the diagnostic and, after it, those reported so far.

iex> env = Feel.Runtime.Env.new()
iex> diagnostic = Feel.Diagnostic.new(:null_not_allowed, "x")
iex> {:error, error} = Feel.Runtime.Env.run(env, fn env -> Feel.Runtime.Env.abort(env, :input, diagnostic) end)
iex> {error.kind, error.code}
{:input, :null_not_allowed}

check_list_length(env, length)

@spec check_list_length(t(), non_neg_integer()) :: t()

Ends the run, in either mode, with an error of kind :limit if a list of length elements is longer than max_list_length, and gives the environment back if it is not (D-154).

A list is checked when it is finished (settle/3), and a loop that makes one checks it as it makes it, with the number of results so far, so that a loop that passes the limit ends at the first result over it and not when it is done. A built-in function that makes a list out of lists, as flatten and concatenate do, counts the items as it walks them. The diagnostics reported until then are in the error, as for any limit.

iex> env = Feel.Runtime.Env.new(limits: Feel.Limits.new(max_list_length: 2))
iex> ^env = Feel.Runtime.Env.check_list_length(env, 2)
iex> {:error, error} = Feel.Runtime.Env.run(env, fn env -> {Feel.Runtime.Env.check_list_length(env, 3), env} end)
iex> {error.kind, error.code}
{:limit, :limit_exceeded}

check_string_length(env, size)

@spec check_string_length(t(), non_neg_integer()) :: t()

Ends the run, in either mode, with an error of kind :limit if a string of size bytes is longer than max_string_length, and gives the environment back if it is not.

settle/3 checks a string when it is finished, and a built-in function whose result is longer than its arguments checks the size it is going to make before it makes anything, so that a result that the limit refuses is not built. It is the same function, so the error is the same (D-154 does it for a list). size need not be the exact size of the string to come: a count that has passed the limit already (the bytes added up until then) is enough.

iex> env = Feel.Runtime.Env.new(limits: Feel.Limits.new(max_string_length: 2))
iex> ^env = Feel.Runtime.Env.check_string_length(env, 2)
iex> {:error, error} = Feel.Runtime.Env.run(env, fn env -> {Feel.Runtime.Env.check_string_length(env, 3), env} end)
iex> {error.kind, hd(error.diagnostics).details}
{:limit, %{limit: :max_string_length}}

clock(env)

@spec clock(t()) :: DateTime.t()

The moment now() and today() see: the DateTime that was given as the :clock, or, without one, the instant at which the environment was built, as a DateTime in UTC with the precision of a microsecond (what DateTime.utc_now/0 gives). Every call on one environment gives the same moment (D-148).

iex> env = Feel.Runtime.Env.new(clock: ~U[2026-10-13 12:00:00Z])
iex> Feel.Runtime.Env.clock(env)
~U[2026-10-13 12:00:00Z]

iex> env = Feel.Runtime.Env.new()
iex> Feel.Runtime.Env.clock(env) == Feel.Runtime.Env.clock(env)
true

count_iteration(env)

@spec count_iteration(t()) :: t()

Counts one element taken by an iterator of for, some or every (D-86). A run in which they have taken more than max_iterations ends, in either mode, with an error of kind :limit.

iex> env = Feel.Runtime.Env.new(limits: Feel.Limits.new(max_iterations: 2))
iex> env = env |> Feel.Runtime.Env.count_iteration() |> Feel.Runtime.Env.count_iteration()
iex> env.iterations
2
iex> {:error, error} = Feel.Runtime.Env.run(env, fn env -> {Feel.Runtime.Env.count_iteration(env), env} end)
iex> {error.kind, error.code}
{:limit, :limit_exceeded}

declared(env, since)

@spec declared(t(), t()) :: t()

Keeps the errors that were reported since since as the ones of the declarations of the names (D-103).

The checks of the bound values (Feel.Runtime.BindingTypes, Feel.Runtime.AllowedValues) run before the expression, and report at the span of the first read of each name. An operation that reads the name has that span inside its own without its operands having reported anything, and the error explains its null operand all the same (settle/4), in whatever turn of a loop or call of a function it is. There is one for a name at most, so looking through them costs the names and not the diagnostics of the run.

iex> env = Feel.Runtime.Env.new()
iex> {nil, checked} = Feel.Runtime.Env.settle(env, {:error, Feel.Diagnostic.new(:value_not_allowed, "x")}, {0, 3})
iex> operand = Feel.Runtime.NullOperand.error("+")
iex> {nil, kept} = Feel.Runtime.Env.settle(checked, operand, {0, 7}, checked)
iex> Enum.map(Feel.Runtime.Env.diagnostics(kept), & &1.code)
[:value_not_allowed, :null_operand]
iex> declared = Feel.Runtime.Env.declared(checked, env)
iex> {nil, explained} = Feel.Runtime.Env.settle(declared, operand, {0, 7}, declared)
iex> Enum.map(Feel.Runtime.Env.diagnostics(explained), & &1.code)
[:value_not_allowed]

diagnostics(env)

@spec diagnostics(t()) :: [Feel.Diagnostic.t()]

Returns the diagnostics reported so far, oldest first.

iex> env = Feel.Runtime.Env.new()
iex> {nil, env} = Feel.Runtime.Env.settle(env, {:error, Feel.Diagnostic.new(:division_by_zero, "x")})
iex> Enum.map(Feel.Runtime.Env.diagnostics(env), & &1.code)
[:division_by_zero]

enter_call(env)

@spec enter_call(t()) :: t()

Enters a call of a function of FEEL (D-82): the depth of the run goes up by one, and so does the number of calls it has made (D-154). A run that passes max_call_depth or max_calls ends in either mode, as one that passes any other limit does.

iex> env = Feel.Runtime.Env.new(limits: Feel.Limits.new(max_call_depth: 1))
iex> env = Feel.Runtime.Env.enter_call(env)
iex> {env.call_depth, env.calls}
{1, 1}
iex> {:error, error} = Feel.Runtime.Env.run(env, fn env -> {Feel.Runtime.Env.enter_call(env), env} end)
iex> {error.kind, error.code}
{:limit, :limit_exceeded}

iex> env = Feel.Runtime.Env.new(limits: Feel.Limits.new(max_calls: 1))
iex> env = env |> Feel.Runtime.Env.enter_call() |> Feel.Runtime.Env.leave_call(0)
iex> {env.call_depth, env.calls}
{0, 1}
iex> {:error, error} = Feel.Runtime.Env.run(env, fn env -> {Feel.Runtime.Env.enter_call(env), env} end)
iex> {error.kind, hd(error.diagnostics).details}
{:limit, %{limit: :max_calls}}

leave_call(env, depth)

@spec leave_call(t(), non_neg_integer()) :: t()

Leaves a call: the depth is the one that the run was at before it (depth). The number of calls is not: they have been made.

new(options \\ [])

@spec new(keyword()) :: t()

Builds the environment of a run.

Options

  • :mode - :lenient (default) or :strict.
  • :dialect - :feel (default), :s_feel, or :b_feel.
  • :extensions - a list of extensions or a Feel.Extensions.t/0, none by default.
  • :clock - a DateTime, or a function of no arguments that returns one. It defaults to the current time, and a function is called once, here.
  • :limits - a Feel.Limits.t/0. It defaults to Feel.Limits.default/0.
  • :entropy - a function from a number of bytes to that many bytes, for the random functions of an extension. It defaults to :crypto.strong_rand_bytes/1, and is called each time a function wants bytes (the clock is read once).
  • :origin - which text the run is of, for the host to find the span of a diagnostic in (D-201). It is nil by default. A function written in the expression keeps it, wherever the function is called.

Raises ArgumentError for an unknown option or a value of the wrong kind.

iex> env = Feel.Runtime.Env.new(mode: :strict, clock: ~U[2026-10-13 12:00:00Z])
iex> {env.mode, env.dialect, env.clock}
{:strict, :feel, ~U[2026-10-13 12:00:00Z]}

quietly(env, fun)

@spec quietly(t(), (t() -> {Feel.Value.t(), t()})) :: {Feel.Value.t(), t()}

Runs fun on the environment and keeps what it reports out of it (D-100).

fun takes the environment and returns {value, environment}, as the code of an expression does. In fun the run is lenient, so an error does not end it, and what it reports is dropped: the diagnostics and the mode are the ones of env. What it counted (the iterations and the depth of the calls) is kept, and so is the end of a run by a limit or by abort/3, which is not a report.

iex> env = Feel.Runtime.Env.new(mode: :strict)
iex> {nil, quiet} = Feel.Runtime.Env.quietly(env, fn env ->
...>   {nil, Feel.Runtime.Env.report(env, Feel.Diagnostic.new(:invalid_property, "x"))}
...> end)
iex> {quiet.mode, Feel.Runtime.Env.diagnostics(quiet)}
{:strict, []}

random_bytes(env, count)

@spec random_bytes(t(), non_neg_integer()) :: binary()

count random bytes, from the :entropy of the environment. A source that gives anything but that many bytes is a mistake of its caller and raises ArgumentError.

iex> env = Feel.Runtime.Env.new(entropy: fn count -> :binary.copy(<<7>>, count) end)
iex> Feel.Runtime.Env.random_bytes(env, 3)
<<7, 7, 7>>
iex> byte_size(Feel.Runtime.Env.random_bytes(Feel.Runtime.Env.new(), 16))
16

report(env, diagnostic)

@spec report(t(), Feel.Diagnostic.t()) :: t()

Reports a diagnostic.

In lenient mode it is collected, and so is a warning or an info (D-46) in either mode. In strict mode an error ends the run: this function does not return, and the run's run/2 returns {:error, error} of kind :strict, whose diagnostics start with this one and go on with the ones reported before.

A run that has reported max_diagnostics already ends, in either mode, with an error of kind :limit and the diagnostics it has (D-154).

iex> env = Feel.Runtime.Env.new(limits: Feel.Limits.new(max_diagnostics: 1))
iex> env = Feel.Runtime.Env.report(env, Feel.Diagnostic.new(:invalid_property, "x"))
iex> env.diagnostic_count
1
iex> {:error, error} = Feel.Runtime.Env.run(env, fn env ->
...>   {nil, Feel.Runtime.Env.report(env, Feel.Diagnostic.new(:invalid_property, "y"))}
...> end)
iex> {error.kind, hd(error.diagnostics).details, length(error.diagnostics)}
{:limit, %{limit: :max_diagnostics}, 2}

run(env, fun)

@spec run(t(), (t() -> {Feel.Value.t(), t()})) :: Feel.result(Feel.Value.t())

Runs fun on the environment and returns the result in the form Feel.run/3 does.

fun takes the environment and returns {value, environment}. The result is {:ok, value, diagnostics}, or {:error, error} if the run was ended by a strict mode error or by a limit.

iex> env = Feel.Runtime.Env.new()
iex> Feel.Runtime.Env.run(env, fn env -> {1, env} end)
{:ok, 1, []}

iex> env = Feel.Runtime.Env.new(mode: :strict)
iex> {:error, error} = Feel.Runtime.Env.run(env, fn env ->
...>   Feel.Runtime.Env.settle(env, {:error, Feel.Diagnostic.new(:type_mismatch, "x")})
...> end)
iex> error.kind
:strict

settle(env, outcome, span \\ nil, since \\ nil)

@spec settle(t(), outcome(), Feel.Diagnostic.span() | nil, t() | nil) ::
  {Feel.Value.t(), t()}

Turns the outcome of an operation into a value and an environment.

span is the byte range of the source the operation came from; it is put on a diagnostic that has none. A value that is too long for the limits ends the run, as described in the module documentation. Construction checks apply to each string and list.

A null operand (Feel.Runtime.NullOperand) is not reported when an error has been reported inside the operation already. since is the environment from before the operands of the operation were evaluated, and what was reported after it is what they can have reported (D-103):

iex> env = Feel.Runtime.Env.new()
iex> since = env
iex> {nil, env} = Feel.Runtime.Env.settle(env, {:error, Feel.Diagnostic.new(:division_by_zero, "x")}, {1, 6})
iex> {nil, env} = Feel.Runtime.Env.settle(env, Feel.Runtime.NullOperand.error("+"), {0, 11}, since)
iex> Enum.map(Feel.Runtime.Env.diagnostics(env), & &1.code)
[:division_by_zero]

An error that was reported before the operands were evaluated, in an earlier turn of a loop or by an earlier call of the same function, has the span of this operation around it too, and is not the reason for this null. Without since, nothing is:

iex> env = Feel.Runtime.Env.new()
iex> {nil, env} = Feel.Runtime.Env.settle(env, {:error, Feel.Diagnostic.new(:division_by_zero, "x")}, {1, 6})
iex> since = env
iex> {nil, env} = Feel.Runtime.Env.settle(env, Feel.Runtime.NullOperand.error("+"), {0, 11}, since)
iex> Enum.map(Feel.Runtime.Env.diagnostics(env), & &1.code)
[:division_by_zero, :null_operand]

start(template, options)

@spec start(t(), keyword()) :: t()

The environment of one run, from the template of its executable (D-148) and the run options :mode, :clock, :limits :entropy and :origin, which mean what they mean for new/1, with the same errors. It is what new/1 gives for the options of the template and these, and does the work of checking only the options that are given: a run with none takes the clock and nothing else.

Options of any other kind (a :dialect, an unknown one, one given twice) are left to new/1, which raises for the ones that are wrong and lets the options of the executable win.

iex> template = Feel.Runtime.Env.template(dialect: :s_feel)
iex> env = Feel.Runtime.Env.start(template, mode: :strict, clock: ~U[2026-10-13 12:00:00Z])
iex> {env.mode, env.dialect, env.clock}
{:strict, :s_feel, ~U[2026-10-13 12:00:00Z]}
iex> Feel.Runtime.Env.start(template, []).mode
:lenient

template(options \\ [])

@spec template(keyword()) :: t()

The environment that the runs of one executable start from (D-148): new/1 with the options of the executable (its :dialect and :extensions) and no clock, which start/2 takes at each run. An executable makes it once, when it is compiled, and so a run does not check and build the same options again. It has no clock so that two executables of the same expression are equal.

iex> template = Feel.Runtime.Env.template(dialect: :s_feel)
iex> {template.dialect, template.mode, template.clock}
{:s_feel, :lenient, nil}

within(env, origin, fun)

@spec within(t(), term(), (t() -> {Feel.Value.t(), t()})) :: {Feel.Value.t(), t()}

Runs fun with origin as the origin of the text, and gives the environment back with the origin it had (D-201).

What fun reports has origin (Feel.Diagnostic, :origin) unless a function it calls has one of its own. It is how the body of a function that another expression made (Feel.Value.Function.with_origin/2) says whose text its spans are in.

iex> env = Feel.Runtime.Env.new()
iex> {nil, inner} = Feel.Runtime.Env.within(env, :member, fn env ->
...>   {nil, Feel.Runtime.Env.report(env, Feel.Diagnostic.new(:invalid_property, "x", span: {0, 1}))}
...> end)
iex> {inner.origin, Enum.map(Feel.Runtime.Env.diagnostics(inner), & &1.origin)}
{nil, [:member]}