Predicator.Analysis (predicator v9.5.0)

Copy Markdown View Source

What an expression or a program reads, calls and stores, listed for tooling.

A checker that compares every path a condition reads against a declared shape, or an editor that lists the functions a program calls, needs those facts without evaluating anything. This module answers three questions:

  • references/1 - each variable read, as a path.
  • calls/1 - each function call, with its name and arity.
  • assignments/1 - each location a program stores to, as a path.

It is a reading surface, not a parsing one, in the Predicator.Vocabulary mould. Nothing here participates in lexing, parsing, compiling or evaluating, and no answer depends on a context: a path is read off the program text, never resolved against data.

Inputs

Each function accepts any of five forms of one program:

The AST forms and the instruction forms give the same paths, calls and stores for the same source (the instruction form rebuilds paths by tracking what the stack holds). Positions come from the AST's own slots, or from the envelope's position table; a bare instruction list carries no positions, so every position read from one is nil.

Answers

Every answer lists every occurrence, in source order, as maps. A caller that wants a set dedupes.

  • A reference is %{path: [segment], position: position}.
  • A call is %{name: name, arity: arity, position: position}. A qualified name (ns.fn(x)) is reported as written, dots included; calls nested in another call's arguments are listed too, the outer call first.
  • An assignment is %{path: [segment], position: position}.

A segment is a string (a variable name or a property name, or a string literal key), an integer (a literal integer index), or :dynamic (any other bracket key: a computed key, or a literal that is neither a string nor an integer). The path continues past a :dynamic segment, and a computed key's own reads are listed as references of their own.

A reference is a chain rooted at a variable. A chain rooted anywhere else (a call's result, a literal) reads no variable, so f(x).y lists x and the call, and no reference for .y. The location an assignment writes is not a read; the reads inside its bracket keys are.

A position is the node's trailing slot (Predicator.Parser.position/0). For a path - a reference or an assignment - it is the span of the whole chain when the program was parsed or compiled with spans, and otherwise the point position of the chain's root variable: either way it starts where the path starts. A call's position is the call node's own.

Examples

iex> Predicator.Analysis.references("score > limit")
{:ok, [%{path: ["score"], position: {1, 1}}, %{path: ["limit"], position: {1, 9}}]}

iex> {:ok, program} = Predicator.parse_program("total = price * 2")
iex> Predicator.Analysis.assignments(program)
{:ok, [%{path: ["total"], position: {1, 1}}]}

Errors

A source string that does not parse answers the %Predicator.Errors.ParseError{} Predicator.compile_program/1 returns for it. An instruction the analysis cannot read - an opcode outside Predicator.Instructions.opcodes/0, or an operand of the wrong shape - answers an %Predicator.Errors.EvaluationError{} with the evaluator's "unknown_instruction" reason. An instruction list that pops more values than it pushed is read as far as it goes: the missing values read nothing.

test/predicator/analysis_clause_coverage_test.exs keeps the AST clauses in step with Predicator.Parser.visitable/0 and the instruction clauses in step with Predicator.Instructions.opcodes/0, the retired opcodes included.

Summary

Types

One location a program stores to.

One function call.

The error arm: a parse failure for a source string, or an instruction the analysis cannot read.

Anything the three functions accept.

A source annotation: a point position, a span, or nil when none is known.

One variable read.

One step of a path: a name, a literal integer index, or :dynamic for any other bracket key.

Functions

Lists every location a program stores to, in source order.

Lists every function call in input, in source order, nested calls included.

Lists every variable read in input, in source order.

Types

assignment()

@type assignment() :: %{path: [segment()], position: position()}

One location a program stores to.

call()

@type call() :: %{name: String.t(), arity: non_neg_integer(), position: position()}

One function call.

error()

The error arm: a parse failure for a source string, or an instruction the analysis cannot read.

input()

Anything the three functions accept.

position()

@type position() :: Predicator.Parser.position()

A source annotation: a point position, a span, or nil when none is known.

read()

@type read() :: %{path: [segment()], position: position()}

One variable read.

segment()

@type segment() :: String.t() | integer() | :dynamic

One step of a path: a name, a literal integer index, or :dynamic for any other bracket key.

Functions

assignments(input)

@spec assignments(input()) :: {:ok, [assignment()]} | {:error, error()}

Lists every location a program stores to, in source order.

The path is read off the assignment's location chain: the root variable, then one segment per property or bracket access. A bracket key that is a string or integer literal is that segment; any other key is :dynamic. An expression stores nothing.

Examples

iex> {:ok, program} = Predicator.parse_program("a.b = 1; if ok { list[i] = 2 }")
iex> Predicator.Analysis.assignments(program)
{:ok, [%{path: ["a", "b"], position: {1, 1}}, %{path: ["list", :dynamic], position: {1, 18}}]}

iex> {:ok, compiled} = Predicator.compile_program_with_positions("a['b'][0] = 1")
iex> Predicator.Analysis.assignments(compiled)
{:ok, [%{path: ["a", "b", 0], position: {1, 1}}]}

calls(input)

@spec calls(input()) :: {:ok, [call()]} | {:error, error()}

Lists every function call in input, in source order, nested calls included.

Examples

iex> Predicator.Analysis.calls("max(len(name), 3) > ns.limit()")
{:ok,
 [
   %{name: "max", arity: 2, position: {1, 1}},
   %{name: "len", arity: 1, position: {1, 5}},
   %{name: "ns.limit", arity: 0, position: {1, 21}}
 ]}

iex> {:ok, instructions} = Predicator.compile("max(len(name), 3) > 1")
iex> {:ok, calls} = Predicator.Analysis.calls(instructions)
iex> Enum.map(calls, &{&1.name, &1.arity})
[{"max", 2}, {"len", 1}]

references(input)

@spec references(input()) :: {:ok, [read()]} | {:error, error()}

Lists every variable read in input, in source order.

Examples

iex> Predicator.Analysis.references("user.tags[0] == 'x' or items[k].id > 1")
{:ok,
 [
   %{path: ["user", "tags", 0], position: {1, 1}},
   %{path: ["items", :dynamic, "id"], position: {1, 24}},
   %{path: ["k"], position: {1, 30}}
 ]}

The instruction form gives the same paths, with positions only when the envelope carries a table:

iex> {:ok, instructions} = Predicator.compile("user.tags[0] == 'x'")
iex> Predicator.Analysis.references(instructions)
{:ok, [%{path: ["user", "tags", 0], position: nil}]}

iex> {:ok, compiled} = Predicator.compile_with_spans("user.tags[0] == 'x'")
iex> Predicator.Analysis.references(compiled)
{:ok, [%{path: ["user", "tags", 0], position: {{1, 1}, {1, 13}}}]}

A program's reads include its bracket keys, never the location it writes:

iex> {:ok, program} = Predicator.parse_program("totals[day] = totals[day] + amount")
iex> {:ok, refs} = Predicator.Analysis.references(program)
iex> Enum.map(refs, & &1.path)
[["day"], ["totals", :dynamic], ["day"], ["amount"]]