Elvelt

EMPL IR

Access nodes — `MemberAccessNode`, `IndexAccessNode`

Access nodes — MemberAccessNode, IndexAccessNode

The IR has two property/index access node kinds covering all forms of "look something up":

MemberAccessNode   — obj.member
IndexAccessNode    — obj[index]

Both are used in read position (e.g. console.log(obj.field)) and in write position (e.g. obj.field = 42). The read/write context is determined by the parent node, not the access node itself.

MemberAccessNode

// MPL_Compiler/core/ir/EMPLNode.h:363
class MemberAccessNode : public EMPLNode {
public:
    EMPLNodePtr object;
    std::string memberName;

    MemberAccessNode() : EMPLNode(NodeKind::MemberAccess) {}
    void accept(EMPLVisitor& v) override { v.visit(*this); }
};
Field Type Source Description
kind NodeKind::MemberAccess EMPLNode.h:368 Discriminator.
object EMPLNodePtr EMPLNode.h:365 The receiver expression (obj in obj.field). Required.
memberName std::string EMPLNode.h:366 The member identifier (the part after the dot). Required.

Source mapping

Source Field config
obj.x object=Identifier("obj"), memberName="x"
obj.x.y object=MemberAccess(obj, "x"), memberName="y"
arr.length object=Identifier("arr"), memberName="length"
obj["x"] object=Identifier("obj"), memberName="x" (canonicalized — see "Bracketed member access" below)
obj?.x (optional chaining, JS / TS) object=Identifier("obj"), memberName="x", metadata["optional"]="true"
self.field (Python) object=Identifier("self"), memberName="field"
@x (Rust / PHP / Ruby instance var) object=Identifier("this"), memberName="x" (with metadata["instance_var"]="true")
obj->field (C / C++ pointer) object=Identifier("obj"), memberName="field" (with metadata["pointer_deref"]="true" for C backend)
obj::ns (C++ namespace) object=Identifier("obj"), memberName="ns" (with metadata["namespace"]="true")

Validation rules

Rule Constraint
V1 object MUST be non-null.
V1 memberName MUST be non-empty.

Backend lowering (C++)

// obj.x               → obj.x
// obj?.x              → (obj) ? obj->x : mpl_undefined()    // optional chaining
// obj->field          → obj->field
// obj::ns             → obj::ns
// @x (Rust instance)  → obj.x   // no special lowering

IndexAccessNode

// MPL_Compiler/core/ir/EMPLNode.h:373
class IndexAccessNode : public EMPLNode {
public:
    EMPLNodePtr object;
    EMPLNodePtr index;

    IndexAccessNode() : EMPLNode(NodeKind::IndexAccess) {}
    void accept(EMPLVisitor& v) override { v.visit(*this); }
};
Field Type Source Description
kind NodeKind::IndexAccess EMPLNode.h:378 Discriminator.
object EMPLNodePtr EMPLNode.h:375 The receiver expression (obj in obj[index]). Required.
index EMPLNodePtr EMPLNode.h:376 The index expression. Required.

Source mapping

Source Field config
arr[0] object=Identifier("arr"), index=Literal(0)
arr[i] object=Identifier("arr"), index=Identifier("i")
arr[i][j] object=IndexAccess(arr, i), index=Identifier("j")
obj["x"] (computed key) object=Identifier("obj"), index=Literal("x")
obj[k] (dynamic key) object=Identifier("obj"), index=Identifier("k")
obj?.[k] (optional chaining) object=Identifier("obj"), index=Identifier("k"), metadata["optional"]="true"
arr.at(i) (JS / Python) This is a method call, NOT an IndexAccessNode. Emitted as MethodCallNode("at", arr, i).
arr[:3] (Python slice) object=Identifier("arr"), index=SliceNode(0, 3)SliceNode is currently BinaryOpNode(":", …) with metadata["slice"]="true"; planned: dedicated SliceNode

Validation rules

Rule Constraint
V1 object MUST be non-null.
V1 index MUST be non-null.

Backend lowering (C++)

// arr[0]              → arr[0]
// obj[k]              → obj[k]
// obj?.[k]            → (obj) ? mpl_get(obj, k) : mpl_undefined()
// obj["x"]            → obj["x"]

For arrays of unknown type, the backend emits mpl_at(obj, index) (defined in empl.h) which dispatches to the appropriate operator[] for mpl_value, std::vector, or string.


Bracketed member access vs. IndexAccessNode

In JavaScript and Python, obj["x"] and obj.x are semantically equivalent when x is a static identifier. The IR canonicalizes both to MemberAccessNode (if the key is a string literal). For dynamic keys, the IR uses IndexAccessNode.

Source IR node
obj.x MemberAccessNode(obj, "x")
obj["x"] MemberAccessNode(obj, "x") (canonicalized — same as obj.x)
obj[x] IndexAccessNode(obj, x)
obj[42] IndexAccessNode(obj, Literal(42))

Canonicalizer rule (implicit, in frontend): emit MemberAccessNode for static string keys, IndexAccessNode otherwise.


Read vs. write context

MemberAccessNode and IndexAccessNode are inherently directionless — they don't know whether they are on the left or right side of an assignment. The parent node determines context:

Parent Interpretation
AssignmentNode::target Write context — assigning to the accessed property/element.
BinaryOpNode (right side) Read context — value of the property/element.
FunctionCallNode::arguments[i] Read context — passing the property/element as an argument.
MethodCallNode::object Read context — using as a method-call receiver.
ReturnNode::value Read context — returning the property/element.

For C++ backend lowering, the context matters: obj.x = 5 lowers to obj.x = 5, but arr[i] = 5 lowers to arr[i] = 5 AND may require arr to be mutable (the backend emits an error if the access path goes through a const binding).


Special forms (metadata keys)

The IR uses metadata to carry language-specific hints that backends consume:

Key Value Meaning
optional "true" Optional chaining (?., ?.[, ?.()).
instance_var "true" Source-language instance-variable syntax (Rust @x, PHP $this->x, Ruby @x).
pointer_deref "true" C / C++ pointer dereference (->).
namespace "true" C++ namespace (::).
deref "true" C++ unary * dereference.
slice "true" Indicates a slice expression (the operator field of the surrounding BinaryOpNode carries ":").

These keys are NOT standardized across all backends — each backend documents which keys it consumes. The C++ backend consumes all of the above; the Python backend (planned) ignores most.


See also

Detailed examples by source language

JavaScript — property access patterns

const obj = { x: 10, y: 20, nested: { z: 30 } };

// Read
console.log(obj.x);                  // → MemberAccess(obj, "x")
console.log(obj["x"]);                // → MemberAccess(obj, "x") (canonicalized)
console.log(obj.nested.z);            // → MemberAccess(MemberAccess(obj, "nested"), "z")
console.log(obj?.x);                  // → MemberAccess(obj, "x") with metadata["optional"]="true"
console.log(obj?.nested?.z);         // → chained optional MemberAccess
console.log(obj[dynamicKey]);         // → IndexAccess(obj, dynamicKey)
console.log(obj?.[dynamicKey]);       // → IndexAccess(obj, dynamicKey) with optional

// Write
obj.x = 100;                          // → Assignment(MemberAccess(obj, "x"), 100)
obj["x"] = 100;                       // → Assignment(MemberAccess(obj, "x"), 100) (canonicalized)
obj[dynamicKey] = 100;                // → Assignment(IndexAccess(obj, dynamicKey), 100)

// Computed property names
const key = "x";
console.log(obj[key]);                // → IndexAccess(obj, Identifier("key"))

// Delete
delete obj.x;                         // → UnaryOp("delete", MemberAccess(obj, "x"))
delete obj[dynamicKey];               // → UnaryOp("delete", IndexAccess(obj, dynamicKey))

Lowered IR for obj.nested.z:

auto innerAccess = std::make_shared<mpl::ir::MemberAccessNode>();
innerAccess->object = std::make_shared<mpl::ir::IdentifierNode>("obj");
innerAccess->memberName = "nested";

auto outerAccess = std::make_shared<mpl::ir::MemberAccessNode>();
outerAccess->object = innerAccess;
outerAccess->memberName = "z";

Python — attribute access

class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y

p = Point(1, 2)
print(p.x)        # → MemberAccess(Identifier("p"), "x")
print(p.distance)  # → MemberAccess(Identifier("p"), "distance")

The Python frontend translates p.x to MemberAccessNode. Python's __dict__ access (rare in source) is emitted as MemberAccessNode with metadata["dunder_access"]="true".

C++ pointer / reference semantics

struct Point { int x, y; };
Point* pp = new Point{1, 2};

int a = pp->x;       // → MemberAccess(Identifier("pp"), "x") with metadata["pointer_deref"]="true"
int b = (*pp).x;     // → MemberAccess(Dereference(Identifier("pp")), "x")
int c = pp[0].x;     // → MemberAccess(IndexAccess(Identifier("pp"), 0), "x")

The C++ backend lowers:

  • pp->xpp->x
  • (*pp).x(*pp).x
  • pp[0].xpp.operator[](0).x (if operator[] is overloaded) or pp[0].x for arrays

Ruby — instance variables

class Counter
  def initialize
    @count = 0  # instance variable
  end

  def increment
    @count += 1
  end
end

c = Counter.new
puts c.instance_variable_get(:@count)  # → MemberAccess with metadata["instance_var"]="true"

The Ruby frontend translates @count to MemberAccessNode { object = Identifier("this"), memberName = "count", metadata["instance_var"] = "true" }.

Optional chaining patterns

JS optional chaining has four syntactic forms, all lowered to MemberAccessNode / IndexAccessNode / MethodCallNode with metadata["optional"] = "true":

Source Lowered to
obj?.x MemberAccessNode { object, "x", metadata["optional"]="true" }
obj?.() FunctionCallNode { "", [obj], metadata["optional"]="true" } (planned; currently emits MethodCallNode with empty name)
obj?.[k] IndexAccessNode { object, k, metadata["optional"]="true" }
obj?.m() MethodCallNode { object, "m", [], metadata["optional"]="true" }

The C++ backend emits:

// obj?.x  →  (obj) ? obj->x : mpl_undefined()
auto _tmp = obj;
auto _result = _tmp ? _tmp->x : mpl_value(__mpl_undefined__);

For chained optional access (obj?.x?.y), the backend short-circuits at the first undefined.

Read vs. write context — extended examples

Assignment targets

obj.x = 5;          // → Assignment(target=MemberAccess(obj, "x"), value=5)
arr[i] = 5;         // → Assignment(target=IndexAccess(arr, i), value=5)
obj.x.y = 5;        // → Assignment(target=MemberAccess(MemberAccess(obj, "x"), "y"), value=5)
obj?.[k] = 5;       // → Assignment(target=IndexAccess(obj, k, optional=true), value=5)

The C++ backend emits the access as the LHS of an assignment. For obj.x = 5, the LHS is obj.x and the RHS is 5.

Compound assignment

obj.count += 1;     // → Assignment(target=MemberAccess(obj, "count"), op="+=", value=1)
arr[i] *= 2;        // → Assignment(target=IndexAccess(arr, i), op="*=", value=2)

The C++ backend emits obj.count += 1; and arr[i] *= 2; directly.

Destructuring assignment

({ x, y } = obj);   // → Assignment(target=DestructuringPattern({x, y}), value=Identifier("obj"))

For destructuring assignments, the target is a DestructuringPatternNode (see variable-decl.md).

Member access on smart pointers

The C++ backend distinguishes between:

Source pattern IR C++ lowered form
ptr.x (raw pointer) MemberAccessNode { metadata["pointer_deref"]="true" } ptr->x
ptr.x (smart pointer) MemberAccessNode ptr->x (smart pointers have operator->)
ptr.x (value / reference) MemberAccessNode ptr.x
obj.x (boxed value) MemberAccessNode mpl_get(obj, "x")

The C++ backend reads metadata["receiver_kind"] (one of "raw", "smart", "value", "boxed") when present to disambiguate. If absent, the backend uses std::is_pointer / std::is_class_with_arrow_operator heuristics.

Index access on different container types

Container IR IndexAccessNode C++ lowered form
std::vector<T> object = std::vector, index = int vec[i]
std::array<T, N> same arr[i]
std::map<K, V> object = std::map, index = K m[k]
std::unordered_map<K, V> same m[k]
JS-style array (boxed) object = mpl_object, index = any mpl_at(obj, idx)
String object = std::string, index = int s[i]
Tuple object = std::tuple, index = int std::get<i>(t)
Optional object = std::optional, index = any (*obj).second or similar (rare)

For tuples with a constant integer index, the C++ backend may substitute std::get<i> at compile time. For dynamic indices, it emits std::get<i> with a runtime check.

Slice notation

Python-style slicing (arr[1:4], arr[1:], arr[:4], arr[::2]) is currently represented as a BinaryOpNode with op=":" and metadata["slice"]="true" on the surrounding node. The C++ backend emits calls to mpl_slice(arr, start, stop, step). A dedicated SliceNode is planned for v4 to clean this up.

// arr[1:4]
auto slice = std::make_shared<mpl::ir::BinaryOpNode>();
slice->op = ":";
slice->left = mpl::ir::LiteralNode::makeInt(1);
slice->right = mpl::ir::LiteralNode::makeInt(4);
slice->metadata["slice"] = "true";

auto indexAccess = std::make_shared<mpl::ir::IndexAccessNode>();
indexAccess->object = std::make_shared<mpl::ir::IdentifierNode>("arr");
indexAccess->index = slice;

The C++ backend emits mpl_slice(arr, 1, 4, 1).

See also (post-summary)