Predicator.ContextLocation (predicator v3.8.0)

Copy Markdown View Source

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 variable user
  • ["user", "name"] - property access user.name
  • ["items", 0] - array access items[0]
  • ["user", "profile", "settings", "theme"] - nested access user.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 :undefined is 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_container error, 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

location_path()

@type location_path() :: [binary() | integer()]

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.

location_result()

@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.

put_result()

@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

put(context, path, value)

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 into
  • path - A location path as returned by resolve/2
  • value - 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

resolve(ast_node, context)

@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 resolve
  • context - 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"}}

resolve_expression(expression, context)

@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.