Source code for simvx.editor.scene_diff

"""Reconcile a parsed scene class with a live runtime ``Node`` tree.

The editor mutates the runtime tree directly while the user works (drag a
node, change a Property, add a child); on save we need to push those
mutations *back* into the on-disk source without losing comments, blank
lines, hand-written helper functions, or import ordering.

:func:`apply_runtime_diff` is the integration point: given a
:class:`~simvx.core.scene_io.SceneClass` (a parso-backed view of the class
in the user's ``.py`` file) and the live runtime root, it issues the
minimum set of structural edits via the SceneClass API so the next
:meth:`SceneFile.save` writes a file whose ``__init__`` matches the
runtime tree exactly.

Identity matching uses the same name-sanitisation rule the greenfield
emitter applies (:func:`structural_type_name` + ``_canonical_var_names``
below): each runtime child gets its var name computed; if the parsed
source already has a child with that name, the two are treated as the
same node and reconciled in place. Otherwise it's an add (for runtime-
only nodes) or a remove (for source-only nodes).

v1 limitations (locked down by tests):
  * Renaming a runtime child mid-session manifests as a remove + add at
    save time. Proper rename detection requires a stable identity beyond
    the user-visible ``.name``, which is a v2 concern.
  * Procedural ``__init__`` bodies (children built inside loops or
    conditionals) are not safe to diff-reconcile; callers should detect
    that case via :func:`~simvx.core.scene_io.has_procedural_construction`
    and fall back to greenfield save.
"""

from __future__ import annotations

from typing import TYPE_CHECKING

from simvx.core import Node
from simvx.core.scene_io import iter_runtime_kwargs, structural_type_name

if TYPE_CHECKING:
    from simvx.core.scene_io import SceneClass

__all__ = ["apply_runtime_diff"]


# ---------------------------------------------------------------------------
# Var-name canonicalisation (mirrors emitter._EmitterContext._unique_var)
# ---------------------------------------------------------------------------


def _resolve_module(node: Node) -> str:
    """Return the import-source module for ``type(node)``.

    Built-in classes (anywhere under ``simvx.core``) collapse to ``simvx.core``
    so the existing umbrella re-export line is reused. User classes return
    their actual ``__module__`` so the emitted ``from`` statement points at
    the file the class lives in (e.g., ``player.attack`` for
    ``src/player/attack.py``).
    """
    module = type(node).__module__ or "simvx.core"
    if module.startswith("simvx.core"):
        return "simvx.core"
    return module


def _canonical_var_names(children: list[Node]) -> list[str]:
    """Compute the var names the greenfield emitter would assign to ``children``.

    Mirrors :class:`simvx.core.scene_io.emitter._EmitterContext._unique_var`
    exactly: lowercase, replace spaces/hyphens with ``_``, drop non-alnum,
    prefix ``node_`` when the cleaned name is empty or starts with a digit,
    and dedup duplicates with a ``_2``/``_3`` suffix in the order the
    emitter visits them. The result is the canonical "what would the
    source look like if we re-emitted from scratch" name list, used by
    :func:`apply_runtime_diff` for source ↔ runtime identity matching.
    """
    seen: dict[str, int] = {}
    out: list[str] = []
    for child in children:
        base = child.name.lower().replace(" ", "_").replace("-", "_")
        base = "".join(c for c in base if c.isalnum() or c == "_")
        if not base or base[0].isdigit():
            base = f"node_{base}"
        if base == "self":
            base = "node_self"
        if base in seen:
            seen[base] += 1
            out.append(f"{base}_{seen[base]}")
        else:
            seen[base] = 0
            out.append(base)
    return out


# ---------------------------------------------------------------------------
# Public surface
# ---------------------------------------------------------------------------


[docs] def apply_runtime_diff( scene_class: SceneClass, runtime_root: Node, *, identity_hints: dict[Node, str] | None = None, ) -> None: """Reconcile ``scene_class`` so its ``__init__`` matches ``runtime_root``. Mutation strategy: 1. Update root ``super().__init__`` kwargs to reflect non-default Property values on the runtime root (insert/update/remove). 2. For each top-level runtime child whose canonical var name already exists in the source, update its constructor kwargs in place. 3. Append runtime children whose canonical name has no source counterpart via :meth:`SceneClass.add_child` (auto-importing the type via the file's :class:`ImportSet`). 4. Remove source-only children via :meth:`SceneClass.remove_child`, then drop now-unused imports for types no longer referenced by any remaining child constructor. 5. If the surviving children are out of order relative to the runtime tree, call :meth:`SceneClass.reorder_children`. Only top-level children of the root are reconciled in v1 (matching the emitter's depth: nested ``add_child`` calls aren't expressed in the current scene-class shape). Deeper sub-tree reconciliation is a v2 concern. ``identity_hints`` is an optional ``{runtime_node: source_var_name}`` mapping captured at scene load. When a runtime node has a hint and its current canonical var name differs from the hint, the diff treats this as a *rename* — the source var is renamed in place via :meth:`SceneClass.rename_child` and kwargs/type-swap are reconciled against the renamed source — rather than emitting a remove + add seam (which would lose the original source position and any kwargs the source had that the runtime no longer carries explicitly). """ _reconcile_root_kwargs(scene_class, runtime_root) _reconcile_children(scene_class, runtime_root, identity_hints=identity_hints)
# --------------------------------------------------------------------------- # Root kwargs # --------------------------------------------------------------------------- def _reconcile_root_kwargs(scene_class: SceneClass, root: Node) -> None: """Update the root's ``super().__init__(...)`` kwargs to match ``root``. The ``name=...`` kwarg is suppressed when it would equal the class name itself (matching the emitter's canonical form); spatial and Property kwargs are emitted whenever the runtime value deviates from the default the emitter would consider canonical. """ used_types: set[str] = set() desired = iter_runtime_kwargs(root, used_types=used_types) desired_dict = dict(desired) # The emitter suppresses ``name=<ClassName>`` when the class name and # node.name match — mirror that here so we don't reintroduce it. if "name" in desired_dict and desired_dict["name"].strip("'\"") == scene_class.name: del desired_dict["name"] # Auto-import any helper types the new kwargs need (Vec2/Vec3/Quat). for type_name in used_types: if not scene_class._file.imports.has_any_alias(type_name): scene_class._file.imports.ensure(type_name, from_="simvx.core") # Diff against existing kwargs on super().__init__. existing = _existing_super_init_kwargs(scene_class) for name, value_expr in desired_dict.items(): if existing.get(name) != value_expr: scene_class.set_root_kwarg(name, value_expr) # Remove kwargs that the runtime no longer carries. Preserve # ``**kwargs`` (existing[name] is None for that case — never appears # in our parsed kwargs map because get_root_kwarg returns None for # absent names, but the parsed-kwargs map below only includes named # arguments). for name in list(existing): if name not in desired_dict: scene_class.remove_root_kwarg(name) def _existing_super_init_kwargs(scene_class: SceneClass) -> dict[str, str]: """Return ``{kwarg_name: source_expr}`` for the existing ``super().__init__(...)`` call, excluding ``*args``/``**kwargs``.""" from simvx.core.scene_io.scene_file import _arglist_arguments, _argument_name trailer = scene_class._super_init_trailer() if trailer is None: return {} out: dict[str, str] = {} for arg in _arglist_arguments(trailer): name = _argument_name(arg) if name is None: continue out[name] = arg.children[2].get_code().strip() return out # --------------------------------------------------------------------------- # Child reconciliation # --------------------------------------------------------------------------- def _reconcile_children( scene_class: SceneClass, root: Node, *, identity_hints: dict[Node, str] | None = None, ) -> None: """Add/update/remove top-level children of the root to match the source.""" runtime_children = list(root.children) runtime_var_names = _canonical_var_names(runtime_children) source_var_names = scene_class.child_var_names() source_set = set(source_var_names) # Rename pass: when the editor passed identity hints, walk each # runtime child whose hinted source-var name differs from its current # canonical name. Rename the source var in place so subsequent steps # match by canonical name. Only rename when the hinted source var # actually exists AND the new name doesn't already exist (avoiding # collisions with sibling renames). if identity_hints: for i, child in enumerate(runtime_children): hint = identity_hints.get(child) if hint is None: continue current_canonical = runtime_var_names[i] if hint == current_canonical: continue if hint not in source_set: continue if current_canonical in source_set: # Sibling collision — leave it for the remove+add path. continue scene_class.rename_child(hint, current_canonical) source_set.discard(hint) source_set.add(current_canonical) # Update source_var_names in place so reorder check below # uses the renamed sequence. try: idx = source_var_names.index(hint) source_var_names[idx] = current_canonical except ValueError: pass runtime_set = set(runtime_var_names) # Types removed via class-swap (step 1 below) — their imports may no # longer be referenced; pass them through the prune pass at step 4. swap_removed_types: list[str] = [] # 1. For each child present in both: if the source's child constructor # is the wrong type (e.g. Make Custom Class swapped Sprite2D for # Player on this instance), remove the source child and let step 2 # re-add it with the new type. Otherwise update kwargs in place. for child, var_name in zip(runtime_children, runtime_var_names, strict=True): if var_name not in source_set: continue source_type = _child_type_name(scene_class, var_name) runtime_type = structural_type_name(child) if source_type is not None and source_type != runtime_type: scene_class.remove_child(var_name) source_set.discard(var_name) swap_removed_types.append(source_type) continue _update_child_kwargs(scene_class, var_name, child) # 2. Add runtime-only children. Insert in runtime order, anchored # after the last existing source child (the SceneClass.add_child # default), so the resulting order tends toward the runtime order # without needing a follow-up reorder for the common "appended at # the end" case. for child, var_name in zip(runtime_children, runtime_var_names, strict=True): if var_name in source_set: continue type_name = structural_type_name(child) used_types: set[str] = set() kwargs_pairs = iter_runtime_kwargs(child, used_types=used_types) # Auto-import helper types the new construction needs (always simvx.core). for helper in used_types: if helper == type_name: continue if not scene_class._file.imports.has_any_alias(helper): scene_class._file.imports.ensure(helper, from_="simvx.core") kwargs = dict(kwargs_pairs) scene_class.add_child(var_name, type_name, from_module=_resolve_module(child), **kwargs) # 3. Remove source-only children. Capture their type before remove # so we can clean up unused imports afterwards. removed_types: list[str] = [] for var_name in list(source_var_names): if var_name in runtime_set: continue type_name = _child_type_name(scene_class, var_name) scene_class.remove_child(var_name) if type_name is not None: removed_types.append(type_name) # 4. Drop imports that are no longer referenced by any surviving # child constructor or the root's own type / super().__init__ # kwargs. Best-effort: ImportSet.remove ignores absent names. all_removed = removed_types + swap_removed_types if all_removed: _prune_unused_imports(scene_class, all_removed) # 5. Reorder if the surviving children don't already match runtime # order. ``add_child`` appends new entries at the end, so this # handles mid-list inserts and runtime-side reorderings. current_order = scene_class.child_var_names() if current_order != runtime_var_names: scene_class.reorder_children(runtime_var_names) def _update_child_kwargs(scene_class: SceneClass, var_name: str, child: Node) -> None: """Diff the constructor kwargs of one source child against the runtime node. Insert/update/remove kwargs as needed; leave ``var = Type(...)`` structure untouched.""" used_types: set[str] = set() desired = dict(iter_runtime_kwargs(child, used_types=used_types)) # Auto-import helper types if the new value introduces one. for helper in used_types: if helper == structural_type_name(child): continue if not scene_class._file.imports.has_any_alias(helper): scene_class._file.imports.ensure(helper, from_="simvx.core") existing = _existing_child_kwargs(scene_class, var_name) for name, value_expr in desired.items(): if existing.get(name) != value_expr: scene_class.set_child_kwarg(var_name, name, value_expr) for name in list(existing): if name not in desired: scene_class.remove_child_kwarg(var_name, name) def _existing_child_kwargs(scene_class: SceneClass, var_name: str) -> dict[str, str]: """Return ``{kwarg: expr}`` for the parsed child constructor.""" from simvx.core.scene_io.scene_file import _arglist_arguments, _argument_name trailer = scene_class._child_ctor_trailer(var_name) if trailer is None: return {} out: dict[str, str] = {} for arg in _arglist_arguments(trailer): name = _argument_name(arg) if name is None: continue out[name] = arg.children[2].get_code().strip() return out def _child_type_name(scene_class: SceneClass, var_name: str) -> str | None: """Type name from ``<var> = <Type>(...)`` in the source, or ``None`` when the parsed shape isn't a simple call.""" from parso.tree import Leaf assignment = scene_class._find_child_assignment(var_name) if assignment is None: return None expr_stmt = assignment.children[0] if len(expr_stmt.children) < 3: return None rhs = expr_stmt.children[2] if rhs.type != "atom_expr" or not rhs.children: return None head = rhs.children[0] if not isinstance(head, Leaf) or head.type != "name": return None return head.value def _prune_unused_imports(scene_class: SceneClass, removed_types: list[str]) -> None: """Drop imports for ``removed_types`` no longer referenced by any remaining child constructor or by the root's class header. The root class's base type is always referenced (via the ``class Foo(Bar):`` header) and never removed. Helper types in kwarg values (Vec2/Vec3/Quat) survive because :func:`iter_runtime_kwargs` re-adds them via ``imports.ensure`` whenever they're still in use; the prune pass below runs *after* the update pass that re-ensures them. """ # Collect every type name still referenced by a child constructor. in_use: set[str] = set() for var_name in scene_class.child_var_names(): type_name = _child_type_name(scene_class, var_name) if type_name is not None: in_use.add(type_name) # Inspect kwarg values for helper types like Vec2(...). from simvx.core.scene_io.scene_file import _arglist_arguments trailer = scene_class._child_ctor_trailer(var_name) if trailer is not None: for arg in _arglist_arguments(trailer): in_use.update(_collect_call_heads(arg)) # Spatial helper types may live in the root's super().__init__ args. super_trailer = scene_class._super_init_trailer() if super_trailer is not None: from simvx.core.scene_io.scene_file import _arglist_arguments for arg in _arglist_arguments(super_trailer): in_use.update(_collect_call_heads(arg)) # The root's base class header is always referenced. base_name = _root_base_name(scene_class) if base_name is not None: in_use.add(base_name) # Now remove each removed_type that is no longer referenced. for type_name in removed_types: if type_name in in_use: continue # The ImportSet API needs the source module. Walk the existing # imports to find which ``from <mod> import <type_name>`` line # carries this name and remove it from there. for from_module, imported_name in scene_class._file.imports.names(): if imported_name == type_name: scene_class._file.imports.remove(type_name, from_=from_module) break def _collect_call_heads(node) -> set[str]: """Collect bare names that appear as the head of a call inside ``node``. Used by import pruning to find ``Vec2``/``Quat``/etc. references hiding inside kwarg values like ``position=Vec2(1, 2)``. """ from parso.tree import Leaf out: set[str] = set() def walk(n) -> None: if isinstance(n, Leaf): return # An ``atom_expr`` is a sequence of ``name``, ``trailer*``. If the # second child is a call trailer ``(...)``, the head name is being # invoked. if n.type == "atom_expr" and len(n.children) >= 2: head = n.children[0] second = n.children[1] if ( isinstance(head, Leaf) and head.type == "name" and second.type == "trailer" and second.children and second.children[0].value == "(" ): out.add(head.value) for c in getattr(n, "children", ()): walk(c) walk(node) return out def _root_base_name(scene_class: SceneClass) -> str | None: """Return the immediate base class name of ``scene_class`` from its ``class Foo(Bar):`` header, or ``None`` if no base.""" from parso.tree import Leaf cls = scene_class.node # children: ['class', name, '(', arglist|name, ')', ':', suite] for # subclasses; ['class', name, ':', suite] for bare classes. saw_lparen = False for c in cls.children: if isinstance(c, Leaf) and c.type == "operator" and c.value == "(": saw_lparen = True continue if saw_lparen: if isinstance(c, Leaf): if c.type == "name": return c.value if c.type == "operator" and c.value == ")": return None elif c.type == "arglist": # Multiple bases — return the first name. for sub in c.children: if isinstance(sub, Leaf) and sub.type == "name": return sub.value return None return None