Resolves location paths for assignment operations in SCXML datamodel expressions.
This module takes parsed AST nodes and extracts location paths that can be used for assignment operations. It validates that expressions represent assignable locations (l-values) rather than computed values.
Location Path Format
Location paths are returned as lists of keys/indices that represent the path to a specific location in the context data structure:
["user"]- top-level variableuser["user", "name"]- property accessuser.name["items", 0]- array accessitems[0]["user", "profile", "settings", "theme"]- nested accessuser.profile.settings.theme
Assignable vs Non-Assignable
Assignable (valid locations):
- Simple identifiers:
user - Property access:
user.name,obj.prop - Bracket access:
items[0],obj["key"] - Mixed notation:
user.items[0].name,data["users"][0]["profile"]
Non-Assignable (invalid locations):
- Literals:
42,"string",true - Function calls:
len(items),upper(name) - Arithmetic expressions:
user.age + 1,items[i + 1] - Any computed values that can't be used as assignment targets
Assignment
put/3 writes a value at a resolved location path, creating any missing
intermediate containers along the way (auto-vivification):
- A segment whose current value is missing,
nil, or:undefinedis created: a%{}when the next segment is a string key, a[]when it is an integer index. - Existing data is never destroyed to make room. A path that traverses a
scalar returns a
:not_a_containererror, as does a string segment against an existing list. - Integer indices past the end of a list pad the gap with
:undefined; negative indices return:invalid_index. - The leaf is always overwritten, whatever it currently holds.
Examples
iex> alias Predicator.{ContextLocation, Lexer, Parser}
iex> {:ok, tokens} = Lexer.tokenize("user.name")
iex> {:ok, ast} = Parser.parse(tokens)
iex> ContextLocation.resolve(ast, %{"user" => %{"name" => "John"}})
{:ok, ["user", "name"]}
iex> alias Predicator.{ContextLocation, Lexer, Parser}
iex> {:ok, tokens} = Lexer.tokenize("items[0]")
iex> {:ok, ast} = Parser.parse(tokens)
iex> ContextLocation.resolve(ast, %{"items" => [1, 2, 3]})
{:ok, ["items", 0]}
Summary
Types
A location path representing the sequence of keys/indices to reach a location in the context.
Result of resolving a location expression.
Result of writing a value at a location path.
Functions
Writes value into context at path, creating missing intermediate containers.
Resolves an AST node to a location path for assignment operations.
Tokenizes, parses, and resolves a location expression in one step.
Types
A location path representing the sequence of keys/indices to reach a location in the context.
String keys represent object properties, integer keys represent array indices.
@type location_result() :: {:ok, location_path()} | {:error, Predicator.Errors.LocationError.t()}
Result of resolving a location expression.
Returns either a successful path or a structured error explaining why the location is invalid.
@type put_result() :: {:ok, Predicator.Types.context()} | {:error, Predicator.Errors.LocationError.t()}
Result of writing a value at a location path.
Returns either the updated context or a structured error explaining why the write could not be performed.
Functions
@spec put(Predicator.Types.context(), location_path(), term()) :: put_result()
Writes value into context at path, creating missing intermediate containers.
Auto-vivification is ECMAScript-like: a missing (or nil/:undefined) segment
is created as a map when the next segment is a string key, and as a list when
the next segment is an integer index. Existing data is never destroyed - a path
that traverses a scalar returns a :not_a_container error. The leaf is always
overwritten.
Integer indices past the end of an existing list pad the gap with :undefined.
Negative indices are rejected with :invalid_index. An integer segment against
an existing map is allowed and writes with the integer key, because refusing
would destroy data the caller put there.
String and integer keys only
put/3 consults string and integer keys, never atom keys. Writing
["user", "name"] into a context holding %{user: %{}} vivifies a new
"user" map beside the atom key rather than descending into it. A caller
reaching put/3 via Predicator.Context.assign/3 never has atom keys to
worry about in the first place, since Context.new/2/bind/3 already
normalize them away deeply and eagerly. This note matters only for a
caller invoking put/3 directly on a hand-built map.
Parameters
context- The context map to write intopath- A location path as returned byresolve/2value- The value to write at the leaf
Returns
{:ok, context}- The updated context{:error, %LocationError{}}- An error explaining why the write failed
Examples
iex> Predicator.ContextLocation.put(%{}, ["user", "profile", "name"], "Ada")
{:ok, %{"user" => %{"profile" => %{"name" => "Ada"}}}}
iex> Predicator.ContextLocation.put(%{"items" => [1]}, ["items", 2], "x")
{:ok, %{"items" => [1, :undefined, "x"]}}
iex> {:error, error} = Predicator.ContextLocation.put(%{"user" => 5}, ["user", "name"], "Ada")
iex> error.type
:not_a_container
@spec resolve(term(), Predicator.Types.context()) :: location_result()
Resolves an AST node to a location path for assignment operations.
Takes a parsed AST node and attempts to extract a valid location path. Validates that the expression represents an assignable location.
Parameters
ast_node- The parsed AST node to resolvecontext- The evaluation context (used for validating array bounds, etc.)
Returns
{:ok, path}- A valid location path{:error, %LocationError{}}- An error explaining why the location is invalid
Examples
# Simple identifier
resolve({:identifier, "user"}, %{})
#=> {:ok, ["user"]}
# Property access
resolve({:property_access, {:identifier, "user"}, "name"}, %{})
#=> {:ok, ["user", "name"]}
# Bracket access
resolve({:bracket_access, {:identifier, "items"}, {:literal, 0}}, %{})
#=> {:ok, ["items", 0]}
# Invalid: literal value
resolve({:literal, 42}, %{})
#=> {:error, %LocationError{type: :not_assignable, message: "Cannot assign to literal value"}}
@spec resolve_expression(binary(), Predicator.Types.context()) :: location_result() | {:error, Predicator.Errors.ParseError.t()}
Tokenizes, parses, and resolves a location expression in one step.
Equivalent to parsing expression and calling resolve/2 on the result,
except parse and tokenize errors are wrapped as Predicator.Errors.ParseError
the same way Predicator.context_location/3 does - this is that
function's implementation, extracted so Predicator.Context.assign/3 can
share it without depending on the Predicator module.