FEEL, the Friendly Enough Expression Language of DMN 1.6.

Work in progress

parse/2 reads the whole grammar (see Feel.Syntax.Parser). prepare/2, run/3, and eval/3 handle most of it: literals, names, + - * / **, the comparisons, and, or and if, lists and contexts, paths, filters, in, between, ranges, instance of, dates, times and durations, unary tests, function definitions and the call of a function value (D-82), and all 86 built-in functions (D-56, D-73, D-79, D-87), and for, some and every (D-86). A function that is defined outside the expression is an :analysis error with the code :not_implemented, found when the expression is prepared. The README says where it stands.

Two ways to run an expression

eval/3 takes source text and runs it once. For repeated use, prepare/2 does the work that does not depend on the data (parsing, name resolution, checks), and run/3 or test/4 then runs the prepared expression:

{:ok, expression} = Feel.prepare("price * quantity")
{:ok, total, diagnostics} = Feel.run(expression, %{"price" => ..., "quantity" => ...})

Results and errors

Evaluation follows the DMN error model. A semantic error, such as a type mismatch, makes the affected result null and produces a Feel.Diagnostic.t/0. In :lenient mode (the default) evaluation continues and the diagnostics are returned with the value. In :strict mode evaluation halts at the first diagnostic, and the result is {:error, error}. A null operand of an arithmetic operator and of <= and >= is such a diagnostic (:null_operand, D-103), so a strict run ends at one; <, >, and, or and not give null and report nothing.

{:error, error} is also returned when the source cannot be parsed or analyzed, and when a resource limit is hit. The ! variants raise the returned Feel.Error.t/0. Argument errors raised inside a worker become input errors. Invalid API argument shapes can still raise before a worker starts.

Containment

Public preparation, parsing and evaluation calls use Feel.Worker by default. guard: configures deadlines, process heap policy and input/result transfer caps. sandbox: false skips this boundary for trusted input, while keeping Feel.Limits. This flag applies to each call and cannot escape an enclosing worker. BEAM preparation and execution require the explicit trusted opt-out. Host callables remain trusted capabilities; see the security model.

Entry points and dialects

prepare/2 accepts three independent options. :entry selects the grammar entry point: a full expression, or a list of unary tests (as used by decision table input entries). :dialect selects FEEL, S-FEEL, or B-FEEL (Feel.Dialect). :scope supplies the names the expression may refer to. A type such as list<number> is not an entry point; Feel.Type.parse/2 reads those.

S-FEEL is a restriction of the syntax: an expression that is outside it is a syntax error (Feel.Syntax.SFeel), and one that is inside it runs as FEEL. B-FEEL has the grammar of FEEL and will have other semantics; it is not written yet and runs as FEEL. The dialect goes with the prepared expression into every run of it; a run cannot choose one (D-49).

Extensions

:extensions switches on additions to FEEL that DMN 1.6 does not have, such as the functions of an engine (Feel.Extension, D-81). There are none by default, and an extension only adds: what is valid FEEL runs as it does without. Like the dialect, they go with the prepared expression into every run.

Errors are chosen per run through :mode; they are not a property of the prepared expression.

Summary

Types

Values for the names an expression refers to, keyed by name.

Options that prepare/2 understands.

The result of running an expression.

Options that run/3, test/4, and eval/3 understand at run time.

FEEL source text.

Functions

Parses and runs source once.

Like eval/3, but returns the value alone and raises Feel.Error.t/0 on {:error, error}. Diagnostics are discarded.

Returns the names that a prepared expression reads from its bindings.

Parses source and returns its syntax tree without analyzing or running it.

Prepares source for repeated use.

Prepares a syntax tree that was not parsed from source, for a caller that builds one.

Tests input against a prepared list of unary tests.

Returns the inferred result type of a prepared expression, or :any when it cannot be determined without running it.

Returns the diagnostics that prepare/2 produced without failing.

Types

bindings()

@type bindings() :: %{optional(String.t()) => Feel.Value.t()}

Values for the names an expression refers to, keyed by name.

prepare_option()

@type prepare_option() ::
  {:scope, Feel.Scope.t()}
  | {:scope_index, Feel.Syntax.ScopeIndex.t() | nil}
  | {:entry, :expression | :unary_tests}
  | {:dialect, Feel.Dialect.t()}
  | {:extensions, [atom()] | Feel.Extensions.t()}
  | {:s_feel_grammar, :practical | :literal}
  | {:unknown_names, :warn | :null | :error}
  | {:result_allowed_values, String.t()}
  | {:result_type, Feel.Type.t()}
  | {:input, Feel.Type.t()}
  | {:declarations, Feel.Analysis.Declarations.stance()}
  | {:analysis, Feel.Analysis.Refinement.tier()}
  | {:limits, Feel.Limits.t()}
  | {:backend, :interpreter | :beam}
  | {:optimize, boolean()}
  | {:sandbox, boolean()}
  | {:guard, keyword()}

Options that prepare/2 understands.

result(value)

@type result(value) :: {:ok, value, [Feel.Diagnostic.t()]} | {:error, Feel.Error.t()}

The result of running an expression.

run_option()

@type run_option() ::
  {:mode, :lenient | :strict}
  | {:clock, DateTime.t() | (-> DateTime.t())}
  | {:entropy, (non_neg_integer() -> binary())}
  | {:limits, Feel.Limits.t()}
  | {:origin, term()}
  | {:contexts, :map | :ordered}
  | {:budget, Feel.Runtime.Budget.t()}
  | {:sandbox, boolean()}
  | {:guard, keyword()}

Options that run/3, test/4, and eval/3 understand at run time.

source()

@type source() :: String.t()

FEEL source text.

Functions

eval(source, bindings \\ %{}, options \\ [])

@spec eval(source(), bindings(), [prepare_option() | run_option()]) ::
  result(Feel.Value.t())

Parses and runs source once.

Takes the options of prepare/2 and of run/3; :limits applies to both. When no :scope is given, the scope is made from bindings: each name with the type of its value, so a name that is not bound is null, with a warning, and an error with unknown_names: :error (D-37). A non-string binding key is an input error. With sandbox: false, building the implicit scope raises ArgumentError for it.

The diagnostics are those of the run and those of prepare/2 (warnings/1). Where the run reports what prepare/2 had warned of, at the same span and with the same code, it is reported once, as the run has it, with the hint of the warning if the run has none.

iex> {:ok, three, []} = Feel.eval("1 + 2")
iex> Decimal.equal?(three, 3)
true
iex> Feel.eval("age >= 18", %{"age" => Decimal.new(21)})
{:ok, true, []}
iex> {:ok, nil, [warning, null_operand]} = Feel.eval("agee >= 18", %{"age" => Decimal.new(21)})
iex> {warning.code, warning.severity, warning.hint}
{:unknown_name, :warning, ~s(did you mean "age"?)}
iex> {null_operand.code, null_operand.severity}
{:null_operand, :error}
iex> {:error, error} =
...>   Feel.eval("agee >= 18", %{"age" => Decimal.new(21)}, unknown_names: :error)
iex> {error.code, hd(error.diagnostics).hint}
{:unknown_name, ~s(did you mean "age"?)}
iex> {:ok, nil, [diagnostic]} = Feel.eval("1 / 0")
iex> {diagnostic.code, diagnostic.span}
{:division_by_zero, {0, 5}}
iex> {:error, error} = Feel.eval("1 / 0", %{}, mode: :strict)
iex> error.kind
:strict

eval!(source, bindings \\ %{}, options \\ [])

@spec eval!(source(), bindings(), [prepare_option() | run_option()]) :: Feel.Value.t()

Like eval/3, but returns the value alone and raises Feel.Error.t/0 on {:error, error}. Diagnostics are discarded.

free_names(expression)

@spec free_names(Feel.Expression.t()) :: MapSet.t(String.t())

Returns the names that a prepared expression reads from its bindings.

parse(source, options \\ [])

@spec parse(source(), [prepare_option()]) ::
  {:ok, Feel.AST.t()} | {:error, Feel.Error.t()}

Parses source and returns its syntax tree without analyzing or running it.

Takes the :entry and :dialect options of prepare/2, plus :scope, because names that contain spaces or operators can only be recognized with the scope in hand. A caller that parses many texts against one scope, and adds names to it as it goes, makes a Feel.Syntax.ScopeIndex once and gives it as :scope_index: the names are then read from it, and the cost of the scope is not paid for each text.

Syntax errors have kind :syntax; source and nesting limits have kind :limit. The shared sandbox can also return deadline/transfer limits or worker failures. Argument errors in parsing become :input errors with code :invalid_options by default; direct calls with sandbox: false raise ArgumentError instead.

entry: :unary_tests reads a list of unary tests, with - for any input and not(...) for a negated list; see Feel.Syntax.Parser.parse_unary_tests/2. Any unsupported :entry is an argument error (:expression is the default). The parser reads FEEL. With dialect: :s_feel the tree is then checked against S-FEEL (Feel.Syntax.SFeel), and anything outside it is a :syntax error with the code :outside_s_feel; :s_feel_grammar is explained at prepare/2. A dialect outside :feel, :s_feel and :b_feel is an argument error.

iex> {:ok, {:binary, _meta, :add, _left, _right}} = Feel.parse("1 + 2")
iex> {:error, error} = Feel.parse("1 +")
iex> {error.kind, error.span}
{:syntax, {3, 3}}
iex> {:ok, {:unary_tests, _meta, :positive, [_, _]}} = Feel.parse("< 5, 10", entry: :unary_tests)

prepare(source, options \\ [])

@spec prepare(source(), [prepare_option()]) ::
  {:ok, Feel.Expression.t()} | {:error, Feel.Error.t()}

Prepares source for repeated use.

Parses the source, resolves names against the :scope, checks calls and types where it can, and builds the form that run/3 executes.

Argument errors raised during preparation become :input errors with code :invalid_options under the default sandbox. The ArgumentError descriptions below apply to direct calls with sandbox: false.

Options

  • :scope - a Feel.Scope.t/0 with the names and types the expression may refer to, and the allowed values of some of them: a bound value that is not allowed is null with an error (D-138). Without it, any name is accepted and resolved at run time.

  • :entry - :expression (default) or :unary_tests.

  • :dialect - :feel (default), :s_feel, or :b_feel. Raises ArgumentError for any other. With :s_feel, an expression that is not in S-FEEL is a :syntax error with the code :outside_s_feel; one that is runs as FEEL (D-49).

  • :extensions - a list of the extensions to switch on: names (Feel.Extension.registry/0) or modules that implement Feel.Extension. [] by default. They add functions, more signatures for functions, and the like, and change nothing that is valid FEEL (D-81). Raises ArgumentError for one that is neither. They go with the prepared expression into every run, and run/3 raises for the option.

  • :s_feel_grammar - :practical (default) or :literal. Read only with dialect: :s_feel: the printed grammar of S-FEEL has no parentheses for grouping and no null, which :practical accepts and :literal reports (D-49).

  • :unknown_names - what a name that is not in the :scope is: :warn (the default), a variable that is looked up in the bindings when the expression runs, with a warning, since a scope need not have every name; :null, which makes it null with a warning; or :error.

  • :result_type - the type of the result, as a Feel.Type.t/0. The result is converted to it as a decision's is to its variable's, and is null with an error when it cannot be (D-138). The result_type of the scope, by default.

  • :result_allowed_values - the allowed values of the result: the source text of a list of unary tests, such as "[0..100]". A result that none of them accepts is null, with a :value_not_allowed error; a result that is null is exempt (D-138). The result_allowed_values of the scope, by default.

  • :input - the type of the input ? of unary tests (entry: :unary_tests), as a Feel.Type.t/0: Any by default. It is the type of a name declared in a scope: the input of test/4 is converted to it, or is null with an error, as the value of a name is (D-138), and a ! after the type makes a null input end the run with an :input error (D-48). What :declarations says of the names it says of the input. The tests are analyzed for an input of this type, so ? > 5 is a comparison of a number, and ? instance of number is true for a number!.

  • :declarations - the stance on the declarations of the :scope: what a run does with a bound value that does not fit the declared type or the allowed values of its name (D-138), and whether the allowed values narrow the types, so that age with [0..150] is number in [0..150] and age + 1 is number in [1..151] (D-138). One of Feel.Analysis.Declarations.stance/0, :enforce by default:

    StanceA bound value that does not fitThe allowed values
    :enforce (default)is converted, or is null with an errornarrow the types
    :enforce_widethe samedo not narrow the types
    :trustis not looked at: the host guarantees the valuesnarrow the types
    :auditstays, and is reported as an errornarrow the types
    :reportstays, and is reported: an error for a declared type, a warning for allowed valuesdo not narrow
    :ignoreis not looked atdo not narrow

    Narrowing lets the analysis prove things and the optimizer take out checks. It is sound when the run makes the values what the types say (:enforce), and the host's word when it does not (:trust, :audit): if a value is not allowed after all, the types are wrong, and so is what the optimizer removed because of them. :report and :ignore rely on no allowed value. Feel.Analysis.Declarations has the rest. Raises ArgumentError for any other value, and for :trust and :audit with analysis: :core, which has no sets of values to narrow by. The options :verify and :narrow, which this one replaces, raise ArgumentError that names the stance to use.

  • :analysis - the precision of inferred bounds (Feel.Analysis.Refinement, D-133). :full (the default) refines possible values and condition facts within representation and work budgets. :core keeps kinds and nullability, such as number instead of number in [6..max], with no condition facts. Under the same declaration assumptions, both must preserve runtime values, ordered diagnostics and strict failures. Preparation warnings, optimizer proofs and preparation cost can differ. declarations: :enforce still verifies allowed values in core analysis without using them to narrow; :trust and :audit require full analysis. Literal function signatures come from kind-only analysis in their written environment and stay the same in both tiers (D-219). Full analysis makes an additional bounded core pass when the syntax contains functions; call refinements can retain a more precise body result. Runtime built-in argument diagnostics describe actual values; preparation warnings may describe different inferred bounds in each tier.

  • :limits - a Feel.Limits.t/0 capping the size of results, and the work of preparing the expression: an error of the kind :limit for a source that is too large, a name that is too long, nesting that is too deep, conditions that say too much about one subject (:max_narrowing_facts, D-154), or, with backend: :beam, an expression that would make more than :max_generated_statements statements of code (code :too_large_to_compile, D-154; the interpreter makes none).

  • :backend - :interpreter (default) or :beam; the latter requires sandbox: false. See Feel.Backend.

  • :sandbox - true (default) contains the complete operation; false runs trusted input directly.

  • :guard - options of Feel.Worker.run/2, used when containment is enabled.

  • :optimize - whether Feel.Optimizer removes from the IR what the analysis has settled before the backend compiles it. true by default; the result of a run is the same either way (D-45). Feel.Expression.unoptimized_ir/1 is the IR as the analyzer made it.

prepare_ast(ast, source, options \\ [])

@spec prepare_ast(Feel.AST.t(), source(), [prepare_option()]) ::
  {:ok, Feel.Expression.t()} | {:error, Feel.Error.t()}

Prepares a syntax tree that was not parsed from source, for a caller that builds one.

A layer above FEEL that has its own notation, as DMN has boxed expressions, can map it to a Feel.AST.t/0 and have it analyzed, typed, optimized and compiled as an expression is. The tree is made of the nodes of Feel.AST (the table there is the whole contract) and is trusted to be well formed: a node that is not one, or a name that is not a list of tokens, raises. What it is not trusted for is meaning, which the analyzer checks as it does for a parsed tree.

source is the text that the :spans of the nodes are byte ranges of. It is not read; it is kept in the expression, and a diagnostic points into it. A caller that builds a tree from several texts gives it their concatenation and moves the spans of each to where it is (Feel.AST.shift/2).

Takes the options of prepare/2, except :entry and :dialect, which are those of a tree (an expression, and :feel). Argument errors inside the default worker become input errors; sandbox: false exposes them directly as exceptions.

iex> {:ok, tree} = Feel.parse("a + 1")
iex> {:ok, expression} = Feel.prepare_ast(tree, "a + 1", scope: Feel.Scope.new(%{"a" => :number}))
iex> Feel.run(expression, %{"a" => Decimal.new(1)})
{:ok, Decimal.new(2), []}

run(expression, bindings \\ %{}, options \\ [])

Runs a prepared expression.

Options

  • :mode - :lenient (default) or :strict.
  • :clock - a DateTime, or a zero-arity function returning one, used by now() and today(). The clock is read once per run, so repeated calls within one run agree.
  • :entropy - a function from a number of bytes to that many bytes, where the random functions of an extension get them (D-81): :crypto.strong_rand_bytes/1 by default.
  • :limits - a Feel.Limits.t/0.
  • :budget - a process-owned Feel.Runtime.Budget handle for cumulative iteration, call and diagnostic limits. Share it with sandbox: false, or create and use it inside one enclosing worker.
  • :sandbox - true (default) contains the complete run; false executes trusted input in the caller.
  • :guard - options of Feel.Worker.run/2, used when containment is enabled.
  • :origin - which text this run is of, a term of the host's (D-201). The diagnostics of the run have it as their :origin, and so do those of a function written in the expression, wherever that function is called later, so that a host that runs several texts can find the span of a diagnostic in the one it is a range of. nil by default.
  • :contexts - what a context in the result is (D-135). :map (default) is a plain map with string keys, as the specification and the TCK have a context: the entries by name, with no order, so two contexts are equal as = says when their maps are. :ordered is a Feel.Value.Context.t/0, which keeps the order of the entries, and is what get entries, context put and string(...) of the same run saw.

A context in bindings is either: a plain map is a context in the order of its keys, and Feel.Value.Context.new/1 gives one in another order.

The dialect and the extensions are those of the prepared expression, and a :dialect or :extensions option is an input error, or raises ArgumentError with sandbox: false (D-49, D-81).

test(expression, input, bindings \\ %{}, options \\ [])

Tests input against a prepared list of unary tests.

The expression must have been prepared with entry: :unary_tests, and any other raises ArgumentError. input is the value of ? while the tests run. The result is true when the tests are satisfied, and false otherwise; it is never null.

The tests are alternatives, so the input is satisfied by any one of them. - is satisfied by every input, null included, and not(...) by an input that none of its tests matches. A test that gives null is not satisfied, not(...) included: a null input is not < 5, nor is it not(< 5). A test is read as follows:

  • < 5, >= x, = 3: the input compared with the value.
  • [1..5], (1..5]: the input within the interval.
  • 1, "a", null: the input equal to the value.
  • An expression of ?, such as ? > 5: satisfied when it is true. One that is not a boolean, such as ? + 1, is equal to the input when it has a value, and is null, so not satisfied, when it has none (D-209): a null input is not ? + 1, whatever type the input is declared to be.

The other options and the bindings are those of run/3.

iex> {:ok, tests} = Feel.prepare("< 5", entry: :unary_tests)
iex> Feel.test(tests, Decimal.new(2))
{:ok, true, []}
iex> Feel.test(tests, Decimal.new(7))
{:ok, false, []}
iex> Feel.test(tests, nil)
{:ok, false, []}
iex> {:ok, tests} = Feel.prepare("not(1, [5..9])", entry: :unary_tests)
iex> Feel.test(tests, Decimal.new(3))
{:ok, true, []}

type(expression)

@spec type(Feel.Expression.t()) :: Feel.Type.t()

Returns the inferred result type of a prepared expression, or :any when it cannot be determined without running it.

It is a type of the specification, where every type includes null, so Feel.type/1 of 1 < 2 is :boolean whether or not the result can be null. The type with the null explicit is the type of the node of expression.ir, a Feel.Type.Union (D-39).

iex> {:ok, expression} = Feel.prepare("1 + 2")
iex> Feel.type(expression)
:number
iex> Feel.Type.Union.format(Feel.IR.type(expression.ir))
"number = 3"

warnings(expression)

@spec warnings(Feel.Expression.t()) :: [Feel.Diagnostic.t()]

Returns the diagnostics that prepare/2 produced without failing.