Skip to main content

formualizer_eval/engine/graph/editor/
vertex_editor.rs

1use crate::SheetId;
2use crate::engine::addr::GridAddr;
3use crate::engine::graph::DependencyGraph;
4use crate::engine::graph::editor::change_log::{FormulaRunAdjusted, MutationCapture};
5use crate::engine::graph::editor::reference_adjuster::{
6    MoveReferenceAdjuster, ReferenceAdjuster, ReferenceContext, RelativeReferenceAdjuster,
7    ShiftOperation,
8};
9use crate::engine::named_range::{NameScope, NamedDefinition};
10use crate::engine::{ChangeEvent, ChangeLogger, VertexId, VertexKind};
11use crate::reference::{CellRef, Coord};
12use formualizer_common::{ExcelError, ExcelErrorKind, LiteralValue};
13use formualizer_parse::parser::ASTNode;
14use rustc_hash::FxHashMap;
15use std::sync::atomic::{AtomicU64, Ordering};
16
17/// Metadata for creating a new vertex
18#[derive(Debug, Clone)]
19pub struct VertexMeta {
20    pub coord: GridAddr,
21    pub sheet_id: SheetId,
22    pub kind: VertexKind,
23    pub flags: u8,
24}
25
26impl VertexMeta {
27    pub fn new(row: u32, col: u32, sheet_id: SheetId, kind: VertexKind) -> Self {
28        Self {
29            coord: GridAddr::new(row, col),
30            sheet_id,
31            kind,
32            flags: 0,
33        }
34    }
35
36    pub fn with_flags(mut self, flags: u8) -> Self {
37        self.flags = flags;
38        self
39    }
40
41    pub fn dirty(mut self) -> Self {
42        self.flags |= 0x01;
43        self
44    }
45
46    pub fn volatile(mut self) -> Self {
47        self.flags |= 0x02;
48        self
49    }
50}
51
52/// Patch for updating vertex metadata
53#[derive(Debug, Clone)]
54pub struct VertexMetaPatch {
55    pub kind: Option<VertexKind>,
56    pub coord: Option<GridAddr>,
57    pub dirty: Option<bool>,
58    pub volatile: Option<bool>,
59}
60
61/// Patch for updating vertex data
62#[derive(Debug, Clone)]
63pub struct VertexDataPatch {
64    pub value: Option<LiteralValue>,
65    pub formula: Option<ASTNode>,
66}
67
68/// Summary of metadata update
69#[derive(Debug, Clone, Default)]
70pub struct MetaUpdateSummary {
71    pub coord_changed: bool,
72    pub kind_changed: bool,
73    pub flags_changed: bool,
74}
75
76/// Summary of data update
77#[derive(Debug, Clone, Default)]
78pub struct DataUpdateSummary {
79    pub value_changed: bool,
80    pub formula_changed: bool,
81    pub dependents_marked_dirty: Vec<VertexId>,
82}
83
84/// Summary of shift operations (row/column insert/delete)
85#[derive(Debug, Clone, Default)]
86pub struct ShiftSummary {
87    pub vertices_moved: Vec<VertexId>,
88    pub vertices_deleted: Vec<VertexId>,
89    pub references_adjusted: usize,
90    pub formulas_updated: usize,
91    #[cfg(test)]
92    pub(crate) structural_dependents_dirtied: Vec<VertexId>,
93}
94
95/// Summary of range operations
96#[derive(Debug, Clone, Default)]
97pub struct RangeSummary {
98    pub cells_affected: usize,
99    pub vertices_created: Vec<VertexId>,
100    pub vertices_updated: Vec<VertexId>,
101    pub cells_moved: usize,
102}
103
104/// Transaction ID for tracking active transactions
105#[derive(Debug, Clone, Copy, PartialEq, Eq)]
106pub struct TransactionId(u64);
107
108impl TransactionId {
109    fn new() -> Self {
110        static COUNTER: AtomicU64 = AtomicU64::new(0);
111        TransactionId(COUNTER.fetch_add(1, Ordering::Relaxed))
112    }
113}
114
115/// Represents an active transaction
116#[derive(Debug)]
117struct Transaction {
118    id: TransactionId,
119    start_index: usize, // Index in change_log where transaction started
120}
121
122/// Custom error type for vertex editor operations
123#[derive(Debug, Clone)]
124pub enum EditorError {
125    TargetOccupied { cell: CellRef },
126    OutOfBounds { row: u32, col: u32 },
127    InvalidName { name: String, reason: String },
128    TransactionFailed { reason: String },
129    TransactionUnsupported { reason: String },
130    NoActiveTransaction,
131    VertexNotFound { id: VertexId },
132    Excel(ExcelError),
133}
134
135impl From<ExcelError> for EditorError {
136    fn from(e: ExcelError) -> Self {
137        EditorError::Excel(e)
138    }
139}
140
141impl std::fmt::Display for EditorError {
142    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
143        match self {
144            EditorError::TargetOccupied { cell } => {
145                write!(
146                    f,
147                    "Target cell occupied at row {}, col {}",
148                    cell.coord.row(),
149                    cell.coord.col()
150                )
151            }
152            EditorError::OutOfBounds { row, col } => {
153                write!(f, "Cell position out of bounds: row {row}, col {col}")
154            }
155            EditorError::InvalidName { name, reason } => {
156                write!(f, "Invalid name '{name}': {reason}")
157            }
158            EditorError::TransactionFailed { reason } => {
159                write!(f, "Transaction failed: {reason}")
160            }
161            EditorError::TransactionUnsupported { reason } => {
162                write!(f, "Transaction unsupported: {reason}")
163            }
164            EditorError::NoActiveTransaction => {
165                write!(f, "No active transaction")
166            }
167            EditorError::VertexNotFound { id } => {
168                write!(f, "Vertex not found: {id:?}")
169            }
170            EditorError::Excel(e) => write!(f, "Excel error: {e:?}"),
171        }
172    }
173}
174
175impl std::error::Error for EditorError {}
176
177/// Builder/controller object that provides exclusive access to the dependency graph
178/// for all mutation operations. This ensures consistency and proper change tracking.
179/// # Example Usage
180///
181/// ```rust
182/// use formualizer_eval::engine::{DependencyGraph, VertexEditor, VertexMeta, VertexKind};
183/// use formualizer_common::LiteralValue;
184/// use formualizer_eval::reference::{CellRef, Coord};
185///
186/// let mut graph = DependencyGraph::new();
187/// let mut editor = VertexEditor::new(&mut graph);
188///
189/// // Batch operations for better performance
190/// editor.begin_batch();
191///
192/// // Create a new cell vertex
193/// let meta = VertexMeta::new(1, 1, 0, VertexKind::Cell).dirty();
194/// let vertex_id = editor.add_vertex(meta);
195///
196/// // Set cell values
197/// let cell_ref = CellRef {
198///     sheet_id: 0,
199///     coord: Coord::new(2, 3, true, true)
200/// };
201/// editor.set_cell_value(cell_ref, LiteralValue::Number(42.0));
202///
203/// // Commit batch operations
204/// editor.commit_batch();
205///
206/// ```
207/// Optional hook for reading Arrow-truth spill values for ChangeLog snapshots.
208///
209/// VertexEditor is structure-only; in canonical mode, callers should provide this
210/// reader so spill undo/redo uses Arrow overlays rather than graph value caches.
211pub trait SpillValueReader {
212    fn read_cell_value(&self, sheet: &str, row: u32, col: u32) -> Option<LiteralValue>;
213}
214
215/// Whether a run the block shift moved holds vertex `v`.
216fn moved_run_holds(runs: &[crate::engine::graph::ShiftedRun], v: VertexId) -> bool {
217    runs.iter()
218        .any(|r| r.moved && v.0 >= r.first.0 && v.0 - r.first.0 < r.len)
219}
220
221/// The editor's change sink: a caller's logger, or the engine's mutation
222/// capture, which also keeps run records (Program 2) unexpanded.
223enum EditorLogger<'g> {
224    Dyn(&'g mut dyn ChangeLogger),
225    Capture(&'g mut MutationCapture),
226}
227
228impl EditorLogger<'_> {
229    #[inline]
230    fn record(&mut self, event: ChangeEvent) {
231        match self {
232            EditorLogger::Dyn(l) => l.record(event),
233            EditorLogger::Capture(c) => c.record(event),
234        }
235    }
236
237    #[inline]
238    fn begin_compound(&mut self, description: String) {
239        match self {
240            EditorLogger::Dyn(l) => l.begin_compound(description),
241            EditorLogger::Capture(c) => c.begin_compound(description),
242        }
243    }
244
245    #[inline]
246    fn end_compound(&mut self) {
247        match self {
248            EditorLogger::Dyn(l) => l.end_compound(),
249            EditorLogger::Capture(c) => c.end_compound(),
250        }
251    }
252
253    /// A run's per-member `FormulaAdjusted` events: kept as one record by
254    /// the engine's capture, expanded now for any other logger.
255    fn record_formula_run(&mut self, run: FormulaRunAdjusted) {
256        match self {
257            EditorLogger::Dyn(l) => {
258                for event in run.events() {
259                    l.record(event);
260                }
261            }
262            EditorLogger::Capture(c) => c.record_lazy(run),
263        }
264    }
265}
266
267pub struct VertexEditor<'g> {
268    graph: &'g mut DependencyGraph,
269    change_logger: Option<EditorLogger<'g>>,
270    spill_value_reader: Option<&'g dyn SpillValueReader>,
271    structural_occupancy: Option<crate::engine::graph::StructuralOccupancy>,
272    batch_mode: bool,
273}
274
275impl<'g> VertexEditor<'g> {
276    /// Create a new vertex editor without change logging
277    pub fn new(graph: &'g mut DependencyGraph) -> Self {
278        Self {
279            graph,
280            change_logger: None,
281            spill_value_reader: None,
282            structural_occupancy: None,
283            batch_mode: false,
284        }
285    }
286
287    /// Supply the conservative union of graph and Arrow occupancy for structural edits.
288    pub(crate) fn with_structural_occupancy(
289        mut self,
290        occupancy: crate::engine::graph::StructuralOccupancy,
291    ) -> Self {
292        self.structural_occupancy = Some(occupancy);
293        self
294    }
295
296    pub(crate) fn set_structural_occupancy(
297        &mut self,
298        occupancy: crate::engine::graph::StructuralOccupancy,
299    ) {
300        self.structural_occupancy = Some(occupancy);
301    }
302
303    /// Create a new vertex editor with change logging
304    pub fn with_logger<L: ChangeLogger + 'g>(
305        graph: &'g mut DependencyGraph,
306        logger: &'g mut L,
307    ) -> Self {
308        Self {
309            graph,
310            change_logger: Some(EditorLogger::Dyn(logger as &'g mut dyn ChangeLogger)),
311            spill_value_reader: None,
312            structural_occupancy: None,
313            batch_mode: false,
314        }
315    }
316
317    /// Create a new vertex editor with change logging and an Arrow-truth spill reader
318    pub fn with_logger_and_spill_reader<L: ChangeLogger + 'g>(
319        graph: &'g mut DependencyGraph,
320        logger: &'g mut L,
321        spill_value_reader: &'g dyn SpillValueReader,
322    ) -> Self {
323        Self {
324            graph,
325            change_logger: Some(EditorLogger::Dyn(logger as &'g mut dyn ChangeLogger)),
326            spill_value_reader: Some(spill_value_reader),
327            structural_occupancy: None,
328            batch_mode: false,
329        }
330    }
331
332    /// An editor logging into the engine's mutation capture (run records
333    /// stay unexpanded), with an Arrow-truth spill reader.
334    pub(crate) fn with_capture_and_spill_reader(
335        graph: &'g mut DependencyGraph,
336        capture: &'g mut MutationCapture,
337        spill_value_reader: &'g dyn SpillValueReader,
338    ) -> Self {
339        Self {
340            graph,
341            change_logger: Some(EditorLogger::Capture(capture)),
342            spill_value_reader: Some(spill_value_reader),
343            structural_occupancy: None,
344            batch_mode: false,
345        }
346    }
347
348    /// Start batch mode to defer expensive operations until commit
349    pub fn begin_batch(&mut self) {
350        if !self.batch_mode {
351            self.graph.begin_batch();
352            self.batch_mode = true;
353        }
354    }
355
356    /// End batch mode and commit all deferred operations
357    pub fn commit_batch(&mut self) {
358        if self.batch_mode {
359            self.graph.end_batch();
360            self.batch_mode = false;
361        }
362    }
363
364    /// Helper method to log a change event
365    fn log_change(&mut self, event: ChangeEvent) {
366        if let Some(logger) = &mut self.change_logger {
367            logger.record(event);
368        }
369    }
370
371    fn snapshot_spill_for_anchor(
372        &self,
373        anchor: VertexId,
374    ) -> Option<crate::engine::graph::editor::change_log::SpillSnapshot> {
375        let cells = self.graph.spill_cells_for_anchor(anchor)?.to_vec();
376        if cells.is_empty() {
377            return None;
378        }
379
380        // Defensive bound for log payloads.
381        let max = self.graph.get_config().spill.max_spill_cells as usize;
382        let mut cells = cells;
383        if cells.len() > max {
384            cells.truncate(max);
385        }
386
387        let first = *cells.first().expect("non-empty spill cells");
388        let sheet_name = self.graph.sheet_name(first.sheet_id).to_string();
389        let row0 = first.coord.row();
390        let col0 = first.coord.col();
391
392        let mut max_row = row0;
393        let mut max_col = col0;
394        let mut by_coord: FxHashMap<(u32, u32), LiteralValue> = FxHashMap::default();
395        for cell in &cells {
396            max_row = max_row.max(cell.coord.row());
397            max_col = max_col.max(cell.coord.col());
398            let v = if let Some(reader) = self.spill_value_reader {
399                reader
400                    .read_cell_value(&sheet_name, cell.coord.row() + 1, cell.coord.col() + 1)
401                    .unwrap_or(LiteralValue::Empty)
402            } else {
403                self.graph
404                    .get_cell_value(&sheet_name, cell.coord.row() + 1, cell.coord.col() + 1)
405                    .unwrap_or(LiteralValue::Empty)
406            };
407            by_coord.insert((cell.coord.row(), cell.coord.col()), v);
408        }
409
410        let rows = (max_row - row0 + 1) as usize;
411        let cols = (max_col - col0 + 1) as usize;
412        let mut values: Vec<Vec<LiteralValue>> = Vec::with_capacity(rows);
413        for r in 0..rows {
414            let mut row: Vec<LiteralValue> = Vec::with_capacity(cols);
415            for c in 0..cols {
416                row.push(
417                    by_coord
418                        .get(&(row0 + r as u32, col0 + c as u32))
419                        .cloned()
420                        .unwrap_or(LiteralValue::Empty),
421                );
422            }
423            values.push(row);
424        }
425
426        // The rectangle can contain cells relinquished to user edits. Keep
427        // its dimensions, but snapshot ownership only for generated cells.
428        if self.graph.spill_may_be_intruded(anchor) {
429            cells.retain(|cell| {
430                self.graph.spill_registry_anchor_for_cell(*cell) == Some(anchor)
431                    && !self.graph.is_foreign_formula_cell(cell, anchor)
432            });
433        }
434        Some(crate::engine::graph::editor::change_log::SpillSnapshot {
435            target_cells: cells,
436            values,
437        })
438    }
439
440    /// Commit a spill region and log it for replay/undo.
441    pub fn commit_spill_region(
442        &mut self,
443        anchor: VertexId,
444        target_cells: Vec<CellRef>,
445        values: Vec<Vec<LiteralValue>>,
446    ) -> Result<(), EditorError> {
447        let old = self.snapshot_spill_for_anchor(anchor);
448        self.graph
449            .commit_spill_region_atomic_with_fault(
450                anchor,
451                target_cells.clone(),
452                values.clone(),
453                None,
454            )
455            .map_err(EditorError::Excel)?;
456        self.log_change(ChangeEvent::SpillCommitted {
457            anchor,
458            old,
459            new: crate::engine::graph::editor::change_log::SpillSnapshot {
460                target_cells,
461                values,
462            },
463        });
464        Ok(())
465    }
466
467    /// Clear a spill region (if any) and log it for replay/undo.
468    pub fn clear_spill_region(&mut self, anchor: VertexId) {
469        let Some(old) = self.snapshot_spill_for_anchor(anchor) else {
470            return;
471        };
472        self.graph.clear_spill_region(anchor);
473        self.log_change(ChangeEvent::SpillCleared { anchor, old });
474    }
475
476    /// Check if change logging is enabled
477    pub fn has_logger(&self) -> bool {
478        self.change_logger.is_some()
479    }
480
481    fn get_formula_ast(&self, id: VertexId) -> Option<ASTNode> {
482        self.graph.get_formula(id)
483    }
484
485    fn snapshot_named_definitions(&self) -> FxHashMap<(NameScope, String), NamedDefinition> {
486        let mut out: FxHashMap<(NameScope, String), NamedDefinition> = FxHashMap::default();
487        for (name, nr) in self.graph.named_ranges_iter() {
488            out.insert((NameScope::Workbook, name.clone()), nr.definition.clone());
489        }
490        for ((sheet_id, name), nr) in self.graph.sheet_named_ranges_iter() {
491            out.insert(
492                (NameScope::Sheet(*sheet_id), name.clone()),
493                nr.definition.clone(),
494            );
495        }
496        out
497    }
498
499    // Transaction support
500
501    /// Resize native table metadata and record a reversible change.
502    pub fn resize_table(
503        &mut self,
504        name: &str,
505        new_range: crate::reference::RangeRef,
506    ) -> Result<(), EditorError> {
507        self.resize_table_body(name, new_range, false)
508    }
509
510    pub fn resize_table_body(
511        &mut self,
512        name: &str,
513        new_range: crate::reference::RangeRef,
514        new_empty_body: bool,
515    ) -> Result<(), EditorError> {
516        let old_empty_body = self
517            .graph
518            .resolve_table_entry(name)
519            .ok_or_else(|| ExcelError::new(ExcelErrorKind::Name))?
520            .empty_body;
521        let old_range = self
522            .graph
523            .resize_table_body(name, new_range, new_empty_body)?;
524        if old_range != new_range || old_empty_body != new_empty_body {
525            self.log_change(ChangeEvent::TableResized {
526                name: name.to_string(),
527                old_range,
528                new_range,
529                old_empty_body,
530                new_empty_body,
531            });
532        }
533        Ok(())
534    }
535
536    fn materialize_empty_tables(&mut self, cell: CellRef) {
537        for table in self.graph.empty_tables_at(cell) {
538            self.resize_table_body(&table.name, table.range, false)
539                .expect("existing table extent is valid");
540        }
541    }
542
543    // Transaction support has been moved to TransactionContext
544    // which coordinates ChangeLog, TransactionManager, and VertexEditor
545
546    /// Backward replay reached a compound's end marker; `description` is
547    /// its start marker's. A structural edit's extent record goes back to
548    /// the pre-edit frame here, before the replay of its events
549    /// (`DependencyGraph::undo_structural_extent`).
550    pub(crate) fn inverse_compound_end(&mut self, description: &str) {
551        self.graph.undo_structural_extent(description);
552    }
553
554    /// Apply the inverse of a change event (used by TransactionContext for rollback)
555    pub fn apply_inverse(&mut self, change: ChangeEvent) -> Result<(), EditorError> {
556        match change {
557            ChangeEvent::SetValue {
558                addr,
559                old_value,
560                old_formula,
561                new: _,
562            } => {
563                // Restore previous state. Setting a value can overwrite a formula.
564                if let Some(old_formula) = old_formula {
565                    self.set_cell_formula(addr, old_formula);
566                } else if let Some(old_value) = old_value {
567                    self.set_cell_value(addr, old_value);
568                } else {
569                    // No prior state the graph holds: the cell is a value or
570                    // empty cell again (Arrow restores its value). Its formula
571                    // id retires at the cell (decision 27), so replaying the
572                    // formula back revives it.
573                    self.graph.retire_cell_for_replay(addr);
574                }
575            }
576            ChangeEvent::SetFormula {
577                addr,
578                old_value,
579                old_formula,
580                new: _,
581            } => {
582                // Restore previous state. Setting a formula can overwrite a value.
583                if let Some(old_formula) = old_formula {
584                    self.set_cell_formula(addr, old_formula);
585                } else if let Some(old_value) = old_value {
586                    self.set_cell_value(addr, old_value);
587                } else {
588                    // No prior state the graph holds: the cell is a value or
589                    // empty cell again (Arrow restores its value). Its formula
590                    // id retires at the cell (decision 27), so replaying the
591                    // formula back revives it.
592                    self.graph.retire_cell_for_replay(addr);
593                }
594            }
595            ChangeEvent::TableResized {
596                name,
597                old_range,
598                old_empty_body,
599                ..
600            } => {
601                self.graph
602                    .resize_table_body(&name, old_range, old_empty_body)?;
603            }
604            ChangeEvent::SetRowVisibility { .. } => {
605                // Engine-level sidecar metadata; handled by Engine replay/rollback paths.
606            }
607            ChangeEvent::AddVertex { id, .. } => {
608                // Inverse of AddVertex is removal
609                let _ = self.remove_vertex(id); // ignore errors for now
610            }
611            ChangeEvent::RemoveVertex {
612                id,
613                old_value,
614                old_formula,
615                old_dependencies,
616                old_dependents,
617                coord,
618                sheet_id,
619                kind,
620                ..
621            } => {
622                if let (Some(c), Some(sid)) = (coord, sheet_id) {
623                    let meta =
624                        VertexMeta::new(c.row(), c.col(), sid, kind.unwrap_or(VertexKind::Cell));
625                    // Decision 9 (as amended in Program 2): the removed
626                    // vertex itself comes back, so the cell's formula keeps
627                    // its id. Legacy re-created the cell on a new vertex;
628                    // that stays the fallback when the cell is occupied.
629                    let new_id = if self.graph.revive_vertex(id, sid, c) {
630                        if self.has_logger() {
631                            self.log_change(ChangeEvent::AddVertex {
632                                id,
633                                coord: meta.coord,
634                                sheet_id: meta.sheet_id,
635                                value: Some(LiteralValue::Empty),
636                                formula: None,
637                                kind: Some(meta.kind),
638                                flags: Some(meta.flags),
639                            });
640                        }
641                        id
642                    } else {
643                        self.try_add_vertex(meta)?
644                    };
645                    if let Some(v) = old_value {
646                        let cell_ref = self.graph.make_cell_ref_internal(sid, c.row(), c.col());
647                        self.set_cell_value(cell_ref, v);
648                    }
649                    if let Some(f) = old_formula {
650                        let cell_ref = self.graph.make_cell_ref_internal(sid, c.row(), c.col());
651                        self.set_cell_formula(cell_ref, f);
652                    }
653                    // Legacy's edges are rebuilt from the restored formula
654                    // (the authority reads formulas, not recorded edges).
655                    #[cfg(any(test, feature = "legacy_oracle"))]
656                    {
657                        for dep in old_dependencies {
658                            self.graph.add_dependency_edge(new_id, dep)?;
659                        }
660                        for parent in old_dependents {
661                            self.graph.add_dependency_edge(parent, new_id)?;
662                        }
663                    }
664                    #[cfg(not(any(test, feature = "legacy_oracle")))]
665                    let _ = (old_dependencies, old_dependents, new_id);
666                }
667            }
668            ChangeEvent::DefineName { name, scope, .. } => {
669                // Inverse is delete name
670                self.graph.delete_name(&name, scope)?;
671            }
672            ChangeEvent::UpdateName {
673                name,
674                scope,
675                old_definition,
676                ..
677            } => {
678                // Restore old definition
679                self.graph.update_name(&name, old_definition, scope)?;
680            }
681            ChangeEvent::DeleteName {
682                name,
683                scope,
684                old_definition,
685            } => {
686                if let Some(def) = old_definition {
687                    self.graph.define_name(&name, def, scope)?;
688                } else {
689                    return Err(EditorError::TransactionFailed {
690                        reason: "Missing old definition for name deletion rollback".to_string(),
691                    });
692                }
693            }
694            ChangeEvent::SpillCommitted { anchor, old, .. } => {
695                // Restore previous spill region.
696                if let Some(old) = old {
697                    self.graph
698                        .restore_spill_snapshot(anchor, &old)
699                        .map_err(EditorError::Excel)?;
700                } else {
701                    self.graph.clear_spill_region(anchor);
702                }
703            }
704            ChangeEvent::SpillCleared { anchor, old } => {
705                // Re-commit the previous spill region.
706                self.graph
707                    .restore_spill_snapshot(anchor, &old)
708                    .map_err(EditorError::Excel)?;
709            }
710            ChangeEvent::StagedFormulaCellChanged { .. } => {
711                // Workbook-level deferred state is replayed by Engine undo/redo wrappers.
712            }
713            // Granular events for compound operations
714            ChangeEvent::CompoundStart { description, .. } => {
715                // A marker; a structural edit's marker also shifts the
716                // retired-id side table back (decision 27).
717                self.graph.replay_structural_marker(&description, false);
718            }
719            ChangeEvent::CompoundEnd { .. } => {
720                // A marker: backward replay loops call
721                // `inverse_compound_end` with its compound's description.
722            }
723            ChangeEvent::VertexMoved {
724                id,
725                sheet_id: _,
726                old_coord,
727                ..
728            } => {
729                // Move back to old position
730                self.move_vertex(id, old_coord)?;
731            }
732            ChangeEvent::FormulaAdjusted { id, old_ast, .. } => {
733                // Restore old formula directly by vertex id.
734                self.graph
735                    .update_vertex_formula(id, old_ast)
736                    .map_err(EditorError::Excel)?;
737                self.graph.mark_vertex_dirty(id);
738            }
739            ChangeEvent::NamedRangeAdjusted {
740                name,
741                scope,
742                old_definition,
743                ..
744            } => {
745                // Restore old definition
746                self.graph.update_name(&name, old_definition, scope)?;
747            }
748            ChangeEvent::EdgeAdded { from, to } => {
749                // Remove the edge
750                // TODO: Need specific edge removal method
751                return Err(EditorError::TransactionFailed {
752                    reason: "Cannot rollback edge addition yet".to_string(),
753                });
754            }
755            ChangeEvent::EdgeRemoved { from, to } => {
756                // Re-add the edge
757                // TODO: Need specific edge addition method
758                return Err(EditorError::TransactionFailed {
759                    reason: "Cannot rollback edge removal yet".to_string(),
760                });
761            }
762        }
763        Ok(())
764    }
765
766    /// Add a vertex to the graph.
767    ///
768    /// This compatibility API preserves the historical sentinel return on failure. New
769    /// transactional callers should use [`Self::try_add_vertex`] to retain typed admission errors.
770    pub fn add_vertex(&mut self, meta: VertexMeta) -> VertexId {
771        self.try_add_vertex(meta)
772            .unwrap_or_else(|_| VertexId::new(0))
773    }
774
775    pub fn try_add_vertex(&mut self, meta: VertexMeta) -> Result<VertexId, EditorError> {
776        // An explicitly requested vertex: an empty one at the cell (value
777        // cells otherwise have none, decision 27; a value set later removes
778        // it again). VertexMeta uses internal 0-based coordinates.
779        let id = self
780            .graph
781            .add_empty_vertex(meta.sheet_id, meta.coord.row(), meta.coord.col())
782            .map_err(EditorError::Excel)?;
783
784        if self.has_logger() && id.0 != 0 {
785            self.log_change(ChangeEvent::AddVertex {
786                id,
787                coord: meta.coord,
788                sheet_id: meta.sheet_id,
789                value: Some(LiteralValue::Empty),
790                formula: None,
791                kind: Some(meta.kind),
792                flags: Some(meta.flags),
793            });
794        }
795        Ok(id)
796    }
797
798    /// Remove a vertex from the graph with proper cleanup
799    pub fn remove_vertex(&mut self, id: VertexId) -> Result<(), EditorError> {
800        // Check if vertex exists
801        if !self.graph.vertex_exists(id) {
802            return Err(EditorError::Excel(
803                ExcelError::new(ExcelErrorKind::Ref).with_message("Vertex does not exist"),
804            ));
805        }
806        self.graph.materialize_vertex(id);
807
808        // If this vertex anchors a spill, clear ownership + spilled children first.
809        // This keeps the spill registry consistent even if the anchor is removed.
810        let spill_snapshot = self.snapshot_spill_for_anchor(id);
811        let did_spill_clear = spill_snapshot.is_some();
812        if let Some(old_spill) = spill_snapshot {
813            if let Some(logger) = &mut self.change_logger {
814                logger.begin_compound(format!("RemoveVertexWithSpillClear id={}", id.0));
815            }
816            self.graph.clear_spill_region(id);
817            self.log_change(ChangeEvent::SpillCleared {
818                anchor: id,
819                old: old_spill,
820            });
821        }
822
823        // Its direct readers (legacy's in-edges) before anything changes.
824        let dependents = self.graph.authority_in_edge_readers(id);
825
826        // Capture old state (dependencies & dependents) BEFORE edge removal
827        let (
828            old_value,
829            old_formula,
830            old_dependencies,
831            old_dependents,
832            coord,
833            sheet_id_opt,
834            kind,
835            flags,
836        ) = if self.has_logger() {
837            let coord = self.graph.get_grid_addr(id);
838            let sheet_id = self.graph.get_sheet_id(id);
839            let kind = self.graph.get_vertex_kind(id);
840            // flags not publicly exposed; set to 0 for now (future: expose getter)
841            let flags = 0u8;
842            (
843                self.graph.get_value(id),
844                self.get_formula_ast(id),
845                // Outgoing edges: legacy's, in oracle builds only.
846                {
847                    #[cfg(any(test, feature = "legacy_oracle"))]
848                    let deps = self.graph.get_dependencies(id);
849                    #[cfg(not(any(test, feature = "legacy_oracle")))]
850                    let deps = Vec::new();
851                    deps
852                },
853                dependents.clone(), // captured earlier
854                coord,
855                Some(sheet_id),
856                Some(kind),
857                Some(flags),
858            )
859        } else {
860            (None, None, vec![], vec![], None, None, None, None)
861        };
862
863        // A formula leaving its cell is journaled (replay that brings it
864        // back revives this vertex).
865        self.graph.journal_formula_left(id);
866        // Remove from cell mapping if it exists
867        if let Some(cell_ref) = self.graph.get_cell_ref_for_vertex(id) {
868            self.graph.remove_cell_mapping(&cell_ref);
869            // Legacy's cell leaves the used extent with its vertex.
870            let (r, c) = (cell_ref.coord.row(), cell_ref.coord.col());
871            self.graph
872                .forget_extent_cells(cell_ref.sheet_id, (r, r), (c, c));
873        }
874
875        // Remove all formula/value payloads owned by this vertex.  Tombstoned vertices remain in
876        // the SoA store for stable IDs/debugging, but they must not continue to participate in
877        // formula evaluation through `vertex_formulas`.
878        self.graph.forget_declared_dynamic_anchor(id);
879        self.graph.vertex_formulas.remove(&id);
880        self.graph.vertex_values.remove(&id);
881        self.graph.clear_formula_vertex_dirty(id);
882        self.graph.mark_volatile(id, false);
883        self.graph.store.set_kind(id, VertexKind::Empty);
884        self.graph.store.set_dynamic(id, false);
885
886        // Remove all edges
887        self.graph.remove_all_edges(id);
888
889        // Mark all dependents as having #REF! error
890        for dep_id in &dependents {
891            self.graph.mark_as_ref_error(*dep_id);
892        }
893
894        // Mark as deleted in store (tombstone)
895        self.graph.mark_deleted(id, true);
896
897        // Log change event
898        self.log_change(ChangeEvent::RemoveVertex {
899            id,
900            old_value,
901            old_formula,
902            old_dependencies,
903            old_dependents,
904            coord,
905            sheet_id: sheet_id_opt,
906            kind,
907            flags,
908        });
909
910        if did_spill_clear && let Some(logger) = &mut self.change_logger {
911            logger.end_compound();
912        }
913
914        Ok(())
915    }
916
917    /// Convenience: remove vertex at a given cell ref if exists
918    pub fn remove_vertex_at(&mut self, cell: CellRef) -> Result<(), EditorError> {
919        if let Some(id) = self.graph.get_vertex_for_cell(&cell) {
920            self.remove_vertex(id)
921        } else {
922            // A value or referenced cell (legacy's vertex) leaves the used
923            // extent.
924            let (r, c) = (cell.coord.row(), cell.coord.col());
925            self.graph
926                .forget_extent_cells(cell.sheet_id, (r, r), (c, c));
927            Ok(())
928        }
929    }
930
931    /// Move a vertex to a new position
932    ///
933    /// The `GridAddr` argument says where the vertex is going, but the `VertexId` says
934    /// nothing about whether it is somewhere to begin with. A symbol has no position, so
935    /// moving one is meaningless: it is what turned a default-sheet insert into a
936    /// name-hijacked cell (#304). Every in-tree caller iterates `grid_vertices_in_sheet`
937    /// and so cannot reach this, but the method is public, so refuse explicitly.
938    pub fn move_vertex(&mut self, id: VertexId, new_coord: GridAddr) -> Result<(), EditorError> {
939        self.move_vertex_inner(id, new_coord, true)
940    }
941
942    /// [`Self::move_vertex`]; `map_cell` false leaves the cell map alone
943    /// (the destination keeps the vertex a block shift moved there).
944    fn move_vertex_inner(
945        &mut self,
946        id: VertexId,
947        new_coord: GridAddr,
948        map_cell: bool,
949    ) -> Result<(), EditorError> {
950        self.graph.authority_note_structural(false);
951        // A compressed member's formula is relative to its cell: it keeps
952        // its own AST across the move (Program 2).
953        self.graph.own_formula_id(id);
954        // Check if vertex exists
955        if !self.graph.vertex_exists(id) {
956            return Err(EditorError::Excel(
957                ExcelError::new(ExcelErrorKind::Ref).with_message("Vertex does not exist"),
958            ));
959        }
960        if self.graph.get_grid_addr(id).is_none() {
961            return Err(EditorError::Excel(
962                ExcelError::new(ExcelErrorKind::Ref).with_message(
963                    "Symbol vertices have no position and cannot be moved onto the grid",
964                ),
965            ));
966        }
967
968        // Get old cell reference
969        let old_cell_ref = self.graph.get_cell_ref_for_vertex(id);
970
971        // Create new cell reference
972        let sheet_id = self.graph.get_sheet_id(id);
973        let new_cell_ref = CellRef::new(
974            sheet_id,
975            Coord::new(new_coord.row(), new_coord.col(), true, true),
976        );
977
978        // Update coordinate in store
979        self.graph.set_grid_addr(id, new_coord);
980
981        // Update edge cache coordinate if needed
982        self.graph.update_edge_grid_addr(id, new_coord);
983
984        // Update cell mapping
985        if map_cell {
986            self.graph
987                .update_cell_mapping(id, old_cell_ref, new_cell_ref);
988        }
989
990        // Mark dependents as dirty
991        self.graph.mark_dependents_dirty(id);
992
993        Ok(())
994    }
995
996    /// Update vertex metadata
997    pub fn patch_vertex_meta(
998        &mut self,
999        id: VertexId,
1000        patch: VertexMetaPatch,
1001    ) -> Result<MetaUpdateSummary, EditorError> {
1002        if !self.graph.vertex_exists(id) {
1003            return Err(EditorError::Excel(
1004                ExcelError::new(ExcelErrorKind::Ref).with_message("Vertex does not exist"),
1005            ));
1006        }
1007
1008        let mut summary = MetaUpdateSummary::default();
1009
1010        if let Some(coord) = patch.coord {
1011            // Same reasoning as `move_vertex`: a symbol has no position to patch.
1012            if self.graph.get_grid_addr(id).is_none() {
1013                return Err(EditorError::Excel(
1014                    ExcelError::new(ExcelErrorKind::Ref).with_message(
1015                        "Symbol vertices have no position and cannot be moved onto the grid",
1016                    ),
1017                ));
1018            }
1019            // A compressed member's formula is relative to its cell.
1020            let _ = self.graph.own_formula_id(id);
1021            self.graph.set_grid_addr(id, coord);
1022            self.graph.update_edge_grid_addr(id, coord);
1023            summary.coord_changed = true;
1024        }
1025
1026        if let Some(kind) = patch.kind {
1027            self.graph.set_kind(id, kind);
1028            summary.kind_changed = true;
1029        }
1030
1031        if let Some(dirty) = patch.dirty {
1032            self.graph.set_dirty(id, dirty);
1033            summary.flags_changed = true;
1034        }
1035
1036        if let Some(volatile) = patch.volatile {
1037            self.graph.mark_volatile(id, volatile);
1038            summary.flags_changed = true;
1039        }
1040
1041        Ok(summary)
1042    }
1043
1044    /// Update vertex data (value or formula)
1045    pub fn patch_vertex_data(
1046        &mut self,
1047        id: VertexId,
1048        patch: VertexDataPatch,
1049    ) -> Result<DataUpdateSummary, EditorError> {
1050        if !self.graph.vertex_exists(id) {
1051            return Err(EditorError::Excel(
1052                ExcelError::new(ExcelErrorKind::Ref).with_message("Vertex does not exist"),
1053            ));
1054        }
1055
1056        let mut summary = DataUpdateSummary::default();
1057
1058        if let Some(value) = patch.value {
1059            self.graph.update_vertex_value(id, value);
1060            summary.value_changed = true;
1061
1062            // Mark dependents as dirty. get_dependents is delta-aware, so no
1063            // CSR rebuild is required even when edits are pending (#125).
1064            let dependents = self.graph.authority_in_edge_readers(id);
1065            for dep in &dependents {
1066                self.graph.set_dirty(*dep, true);
1067            }
1068            summary.dependents_marked_dirty = dependents;
1069        }
1070
1071        if let Some(_formula) = patch.formula {
1072            // This would need proper formula update implementation
1073            // For now, we'll mark as changed
1074            summary.formula_changed = true;
1075        }
1076
1077        Ok(summary)
1078    }
1079
1080    /// No-op kept for journal replay of old `EdgeAdded` events: the
1081    /// dependency authority derives every edge from formulas.
1082    pub fn add_edge(&mut self, from: VertexId, to: VertexId) -> bool {
1083        if from == to {
1084            return false; // Prevent self-loops
1085        }
1086
1087        // TODO: Add edge through proper API when available
1088        // For now, return true to indicate intent
1089        true
1090    }
1091
1092    /// No-op kept for journal replay of old `EdgeRemoved` events.
1093    pub fn remove_edge(&mut self, _from: VertexId, _to: VertexId) -> bool {
1094        // TODO: Remove edge through proper API when available
1095        true
1096    }
1097
1098    /// Insert rows at the specified position, shifting existing rows down
1099    pub fn insert_rows(
1100        &mut self,
1101        sheet_id: SheetId,
1102        before: u32,
1103        count: u32,
1104    ) -> Result<ShiftSummary, EditorError> {
1105        let result = self.insert_rows_impl(sheet_id, before, count);
1106        self.graph.authority_end_structural();
1107        result
1108    }
1109
1110    fn insert_rows_impl(
1111        &mut self,
1112        sheet_id: SheetId,
1113        before: u32,
1114        count: u32,
1115    ) -> Result<ShiftSummary, EditorError> {
1116        self.graph.authority_note_structural_shift();
1117        if count == 0 {
1118            return Ok(ShiftSummary::default());
1119        }
1120
1121        let mut summary = ShiftSummary::default();
1122
1123        // Begin batch for efficiency
1124        self.begin_batch();
1125        let conservative = crate::engine::graph::StructuralOccupancy::conservative();
1126        let occupancy = self.structural_occupancy.as_ref().unwrap_or(&conservative);
1127        let range_dependents = self.graph.authority_structural_band_readers(
1128            sheet_id,
1129            crate::engine::graph::StructuralEdit::InsertRows { before },
1130            occupancy,
1131        );
1132        #[cfg(test)]
1133        {
1134            summary.structural_dependents_dirtied = range_dependents.clone();
1135        }
1136        self.graph.mark_dirty_many(&range_dependents);
1137
1138        // 1. Collect vertices to shift (those at or after the insert point)
1139        let vertices_to_shift: Vec<(VertexId, GridAddr)> = self
1140            .graph
1141            .grid_vertices_in_sheet(sheet_id)
1142            .filter(|(_, coord)| coord.row() >= before)
1143            .collect();
1144
1145        if let Some(logger) = &mut self.change_logger {
1146            logger.begin_compound(format!(
1147                "InsertRows sheet={sheet_id} before={before} count={count}"
1148            ));
1149        }
1150        // 2-3. Shift vertices (emit VertexMoved) and adjust formulas
1151        // (emit FormulaAdjusted).
1152        let op = ShiftOperation::InsertRows {
1153            sheet_id,
1154            before,
1155            count,
1156        };
1157        self.shift_and_adjust(
1158            sheet_id,
1159            &op,
1160            vertices_to_shift,
1161            |old_coord| GridAddr::new(old_coord.row() + count, old_coord.col()),
1162            &mut summary,
1163        )?;
1164
1165        // 4. Adjust named ranges
1166        let old_names = if self.has_logger() {
1167            Some(self.snapshot_named_definitions())
1168        } else {
1169            None
1170        };
1171        self.graph.adjust_named_ranges(&op)?;
1172        if let Some(old_names) = old_names {
1173            let new_names = self.snapshot_named_definitions();
1174            for ((scope, name), old_definition) in old_names {
1175                if let Some(new_definition) = new_names.get(&(scope, name.clone()))
1176                    && *new_definition != old_definition
1177                {
1178                    self.log_change(ChangeEvent::NamedRangeAdjusted {
1179                        name,
1180                        scope,
1181                        old_definition,
1182                        new_definition: new_definition.clone(),
1183                    });
1184                }
1185            }
1186        }
1187
1188        // 5. Log change event
1189        if let Some(logger) = &mut self.change_logger {
1190            logger.end_compound();
1191        }
1192
1193        self.commit_batch();
1194
1195        Ok(summary)
1196    }
1197
1198    /// Delete rows at the specified position, shifting remaining rows up
1199    pub fn delete_rows(
1200        &mut self,
1201        sheet_id: SheetId,
1202        start: u32,
1203        count: u32,
1204    ) -> Result<ShiftSummary, EditorError> {
1205        let result = self.delete_rows_impl(sheet_id, start, count);
1206        self.graph.authority_end_structural();
1207        result
1208    }
1209
1210    fn delete_rows_impl(
1211        &mut self,
1212        sheet_id: SheetId,
1213        start: u32,
1214        count: u32,
1215    ) -> Result<ShiftSummary, EditorError> {
1216        if count == 0 {
1217            self.graph.authority_note_structural_shift();
1218            return Ok(ShiftSummary::default());
1219        }
1220        // The deleted band's readers (and theirs) are dirty, in the pre-edit
1221        // frame: value cells have no vertex whose removal would dirty them
1222        // (decision 27). Only occupied columns when the caller knows them
1223        // (an empty column's readers see no change).
1224        let end = start.saturating_add(count - 1);
1225        let rects: Vec<(SheetId, u32, u32, u32, u32)> = match self
1226            .structural_occupancy
1227            .as_ref()
1228            .and_then(|o| o.occupied_column_runs())
1229        {
1230            Some(runs) => runs
1231                .into_iter()
1232                .map(|(c0, c1)| (sheet_id, start, end, c0, c1))
1233                .collect(),
1234            None => vec![(sheet_id, start, end, 0, 16_383)],
1235        };
1236        let _ = self.graph.mark_dirty_rects(&rects);
1237        self.graph.authority_note_structural_shift();
1238
1239        let mut summary = ShiftSummary::default();
1240
1241        self.begin_batch();
1242
1243        if let Some(logger) = &mut self.change_logger {
1244            logger.begin_compound(format!(
1245                "DeleteRows sheet={sheet_id} start={start} count={count}"
1246            ));
1247        }
1248
1249        // 1. Delete vertices in the range
1250        let vertices_to_delete: Vec<VertexId> = self
1251            .graph
1252            .grid_vertices_in_sheet(sheet_id)
1253            .filter(|(_, coord)| coord.row() >= start && coord.row() < start + count)
1254            .map(|(id, _)| id)
1255            .collect();
1256        let conservative = crate::engine::graph::StructuralOccupancy::conservative();
1257        let occupancy = self.structural_occupancy.as_ref().unwrap_or(&conservative);
1258        let range_dependents = self.graph.authority_structural_band_readers(
1259            sheet_id,
1260            crate::engine::graph::StructuralEdit::DeleteRows {
1261                start,
1262                end: start.saturating_add(count).saturating_sub(1).max(start),
1263            },
1264            occupancy,
1265        );
1266        #[cfg(test)]
1267        {
1268            summary.structural_dependents_dirtied = range_dependents.clone();
1269        }
1270        self.graph.mark_dirty_many(&range_dependents);
1271
1272        for id in vertices_to_delete {
1273            self.remove_vertex(id)?;
1274            summary.vertices_deleted.push(id);
1275        }
1276        // 2-3. Shift the remaining vertices (emit VertexMoved) and adjust
1277        // formulas (emit FormulaAdjusted).
1278        let vertices_to_shift: Vec<(VertexId, GridAddr)> = self
1279            .graph
1280            .grid_vertices_in_sheet(sheet_id)
1281            .filter(|(_, coord)| coord.row() >= start + count)
1282            .collect();
1283        let op = ShiftOperation::DeleteRows {
1284            sheet_id,
1285            start,
1286            count,
1287        };
1288        self.shift_and_adjust(
1289            sheet_id,
1290            &op,
1291            vertices_to_shift,
1292            |old_coord| GridAddr::new(old_coord.row() - count, old_coord.col()),
1293            &mut summary,
1294        )?;
1295
1296        // 4. Adjust named ranges
1297        let old_names = if self.has_logger() {
1298            Some(self.snapshot_named_definitions())
1299        } else {
1300            None
1301        };
1302        self.graph.adjust_named_ranges(&op)?;
1303        if let Some(old_names) = old_names {
1304            let new_names = self.snapshot_named_definitions();
1305            for ((scope, name), old_definition) in old_names {
1306                if let Some(new_definition) = new_names.get(&(scope, name.clone()))
1307                    && *new_definition != old_definition
1308                {
1309                    self.log_change(ChangeEvent::NamedRangeAdjusted {
1310                        name,
1311                        scope,
1312                        old_definition,
1313                        new_definition: new_definition.clone(),
1314                    });
1315                }
1316            }
1317        }
1318
1319        // 5. Log change event
1320        if let Some(logger) = &mut self.change_logger {
1321            logger.end_compound();
1322        }
1323
1324        self.commit_batch();
1325
1326        Ok(summary)
1327    }
1328
1329    /// Insert columns at the specified position, shifting existing columns right
1330    pub fn insert_columns(
1331        &mut self,
1332        sheet_id: SheetId,
1333        before: u32,
1334        count: u32,
1335    ) -> Result<ShiftSummary, EditorError> {
1336        let result = self.insert_columns_impl(sheet_id, before, count);
1337        self.graph.authority_end_structural();
1338        result
1339    }
1340
1341    fn insert_columns_impl(
1342        &mut self,
1343        sheet_id: SheetId,
1344        before: u32,
1345        count: u32,
1346    ) -> Result<ShiftSummary, EditorError> {
1347        self.graph.authority_note_structural_shift();
1348        if count == 0 {
1349            return Ok(ShiftSummary::default());
1350        }
1351
1352        let mut summary = ShiftSummary::default();
1353
1354        // Begin batch for efficiency
1355        self.begin_batch();
1356        let conservative = crate::engine::graph::StructuralOccupancy::conservative();
1357        let occupancy = self.structural_occupancy.as_ref().unwrap_or(&conservative);
1358        let range_dependents = self.graph.authority_structural_band_readers(
1359            sheet_id,
1360            crate::engine::graph::StructuralEdit::InsertColumns { before },
1361            occupancy,
1362        );
1363        #[cfg(test)]
1364        {
1365            summary.structural_dependents_dirtied = range_dependents.clone();
1366        }
1367        self.graph.mark_dirty_many(&range_dependents);
1368
1369        // 1. Collect vertices to shift (those at or after the insert point)
1370        let vertices_to_shift: Vec<(VertexId, GridAddr)> = self
1371            .graph
1372            .grid_vertices_in_sheet(sheet_id)
1373            .filter(|(_, coord)| coord.col() >= before)
1374            .collect();
1375
1376        if let Some(logger) = &mut self.change_logger {
1377            logger.begin_compound(format!(
1378                "InsertColumns sheet={sheet_id} before={before} count={count}"
1379            ));
1380        }
1381        // 2-3. Shift vertices (emit VertexMoved) and adjust formulas
1382        // (emit FormulaAdjusted).
1383        let op = ShiftOperation::InsertColumns {
1384            sheet_id,
1385            before,
1386            count,
1387        };
1388        self.shift_and_adjust(
1389            sheet_id,
1390            &op,
1391            vertices_to_shift,
1392            |old_coord| GridAddr::new(old_coord.row(), old_coord.col() + count),
1393            &mut summary,
1394        )?;
1395
1396        // 4. Adjust named ranges
1397        let old_names = if self.has_logger() {
1398            Some(self.snapshot_named_definitions())
1399        } else {
1400            None
1401        };
1402        self.graph.adjust_named_ranges(&op)?;
1403        if let Some(old_names) = old_names {
1404            let new_names = self.snapshot_named_definitions();
1405            for ((scope, name), old_definition) in old_names {
1406                if let Some(new_definition) = new_names.get(&(scope, name.clone()))
1407                    && *new_definition != old_definition
1408                {
1409                    self.log_change(ChangeEvent::NamedRangeAdjusted {
1410                        name,
1411                        scope,
1412                        old_definition,
1413                        new_definition: new_definition.clone(),
1414                    });
1415                }
1416            }
1417        }
1418
1419        // 5. Log change event
1420        if let Some(logger) = &mut self.change_logger {
1421            logger.end_compound();
1422        }
1423
1424        self.commit_batch();
1425
1426        Ok(summary)
1427    }
1428
1429    /// Steps 2 and 3 of a row/column insert or delete: move the vertices
1430    /// at or past the edit (logging `VertexMoved`), then adjust every
1431    /// formula (logging `FormulaAdjusted`).
1432    ///
1433    /// Program 2 (contract decision 20.5): virtual family member runs are
1434    /// shifted and adjusted as one first (`shift_virtual_runs`); their
1435    /// members are not moved or rewritten one by one, and their
1436    /// `FormulaAdjusted` events go to the log as one run record, expanded
1437    /// only when read. Runs the block transform cannot express come back
1438    /// to the per-cell maps and take the per-vertex path below. Events are
1439    /// logged in vertex-id order (moves, then adjustments), the per-vertex
1440    /// order legacy logged moves in.
1441    fn shift_and_adjust(
1442        &mut self,
1443        sheet_id: SheetId,
1444        op: &ShiftOperation,
1445        vertices_to_shift: Vec<(VertexId, GridAddr)>,
1446        shift: impl Fn(GridAddr) -> GridAddr,
1447        summary: &mut ShiftSummary,
1448    ) -> Result<(), EditorError> {
1449        let journal = self.has_logger();
1450        self.graph.shift_retired_ids(op, journal);
1451        let runs = self.graph.shift_virtual_runs(op);
1452
1453        for (id, old_coord) in vertices_to_shift {
1454            let new_coord = shift(old_coord);
1455            if self.has_logger() {
1456                self.log_change(ChangeEvent::VertexMoved {
1457                    id,
1458                    sheet_id,
1459                    old_coord,
1460                    new_coord,
1461                });
1462            }
1463            if self.graph.is_virtual_member(id) {
1464                // Moved with its run.
1465                debug_assert_eq!(self.graph.get_grid_addr(id), Some(new_coord));
1466            } else {
1467                // Legacy moved vertices one at a time in id order: of two
1468                // vertices shifted onto one cell (a tombstone or stale
1469                // vertex, and a live one) the later id kept the mapping. A
1470                // member its run moved there already keeps the cell when
1471                // its id is later.
1472                let cell = CellRef::new(
1473                    sheet_id,
1474                    Coord::new(new_coord.row(), new_coord.col(), true, true),
1475                );
1476                let member_moves_later = self
1477                    .graph
1478                    .virtual_member_at(&cell)
1479                    .is_some_and(|m| m.0 > id.0 && moved_run_holds(&runs, m));
1480                self.move_vertex_inner(id, new_coord, !member_moves_later)?;
1481            }
1482            summary.vertices_moved.push(id);
1483        }
1484        self.graph.after_shift_moves(&runs);
1485
1486        let adjuster = ReferenceAdjuster::new();
1487        let mut adjusted_runs: Vec<&crate::engine::graph::ShiftedRun> =
1488            runs.iter().filter(|r| r.adjusted.is_some()).collect();
1489        adjusted_runs.sort_unstable_by_key(|r| r.first.0);
1490        let mut next_run = 0;
1491        let formula_vertices = self.graph.materialized_formula_vertices_sorted();
1492        for id in formula_vertices {
1493            while next_run < adjusted_runs.len() && adjusted_runs[next_run].first.0 < id.0 {
1494                self.log_adjusted_run(adjusted_runs[next_run], summary);
1495                next_run += 1;
1496            }
1497            if let Some(ast) = self.get_formula_ast(id)
1498                && let Some(adjusted) = adjuster.adjust_ast_if_changed_in_context(
1499                    &ast,
1500                    op,
1501                    &ReferenceContext::new(self.graph.get_sheet_id(id), self.graph.sheet_reg()),
1502                )
1503            {
1504                if self.has_logger() {
1505                    self.log_change(ChangeEvent::FormulaAdjusted {
1506                        id,
1507                        addr: self.graph.get_cell_ref_for_vertex(id),
1508                        old_ast: ast.clone(),
1509                        new_ast: adjusted.clone(),
1510                    });
1511                }
1512                self.graph.update_vertex_formula(id, adjusted)?;
1513                self.graph.mark_vertex_dirty(id);
1514                summary.formulas_updated += 1;
1515            }
1516        }
1517        for run in &adjusted_runs[next_run..] {
1518            self.log_adjusted_run(run, summary);
1519        }
1520        Ok(())
1521    }
1522
1523    /// A run the block transform adjusted: its members' `FormulaAdjusted`
1524    /// events as one record (the transform already rewrote and dirtied
1525    /// them).
1526    fn log_adjusted_run(
1527        &mut self,
1528        run: &crate::engine::graph::ShiftedRun,
1529        summary: &mut ShiftSummary,
1530    ) {
1531        summary.formulas_updated += run.len as usize;
1532        let Some((old_first, new_first)) = &run.adjusted else {
1533            return;
1534        };
1535        if let Some(logger) = &mut self.change_logger {
1536            logger.record_formula_run(FormulaRunAdjusted {
1537                first: run.first,
1538                len: run.len,
1539                sheet_id: run.sheet,
1540                col: run.col,
1541                row0: run.row0,
1542                old_first: old_first.clone(),
1543                new_first: new_first.clone(),
1544            });
1545        }
1546    }
1547
1548    /// Delete columns at the specified position, shifting remaining columns left
1549    pub fn delete_columns(
1550        &mut self,
1551        sheet_id: SheetId,
1552        start: u32,
1553        count: u32,
1554    ) -> Result<ShiftSummary, EditorError> {
1555        let result = self.delete_columns_impl(sheet_id, start, count);
1556        self.graph.authority_end_structural();
1557        result
1558    }
1559
1560    fn delete_columns_impl(
1561        &mut self,
1562        sheet_id: SheetId,
1563        start: u32,
1564        count: u32,
1565    ) -> Result<ShiftSummary, EditorError> {
1566        if count == 0 {
1567            self.graph.authority_note_structural_shift();
1568            return Ok(ShiftSummary::default());
1569        }
1570        // The deleted band's readers (and theirs) are dirty, in the pre-edit
1571        // frame: value cells have no vertex whose removal would dirty them
1572        // (decision 27).
1573        let _ = self.graph.mark_dirty_rects(&[(
1574            sheet_id,
1575            0,
1576            1_048_575,
1577            start,
1578            start.saturating_add(count - 1),
1579        )]);
1580        self.graph.authority_note_structural_shift();
1581
1582        let mut summary = ShiftSummary::default();
1583
1584        self.begin_batch();
1585
1586        if let Some(logger) = &mut self.change_logger {
1587            logger.begin_compound(format!(
1588                "DeleteColumns sheet={sheet_id} start={start} count={count}"
1589            ));
1590        }
1591
1592        // 1. Delete vertices in the range
1593        let vertices_to_delete: Vec<VertexId> = self
1594            .graph
1595            .grid_vertices_in_sheet(sheet_id)
1596            .filter(|(_, coord)| coord.col() >= start && coord.col() < start + count)
1597            .map(|(id, _)| id)
1598            .collect();
1599        let conservative = crate::engine::graph::StructuralOccupancy::conservative();
1600        let occupancy = self.structural_occupancy.as_ref().unwrap_or(&conservative);
1601        let range_dependents = self.graph.authority_structural_band_readers(
1602            sheet_id,
1603            crate::engine::graph::StructuralEdit::DeleteColumns {
1604                start,
1605                end: start.saturating_add(count).saturating_sub(1).max(start),
1606            },
1607            occupancy,
1608        );
1609        #[cfg(test)]
1610        {
1611            summary.structural_dependents_dirtied = range_dependents.clone();
1612        }
1613        self.graph.mark_dirty_many(&range_dependents);
1614
1615        for id in vertices_to_delete {
1616            self.remove_vertex(id)?;
1617            summary.vertices_deleted.push(id);
1618        }
1619        // 2-3. Shift the remaining vertices (emit VertexMoved) and adjust
1620        // formulas (emit FormulaAdjusted).
1621        let vertices_to_shift: Vec<(VertexId, GridAddr)> = self
1622            .graph
1623            .grid_vertices_in_sheet(sheet_id)
1624            .filter(|(_, coord)| coord.col() >= start + count)
1625            .collect();
1626        let op = ShiftOperation::DeleteColumns {
1627            sheet_id,
1628            start,
1629            count,
1630        };
1631        self.shift_and_adjust(
1632            sheet_id,
1633            &op,
1634            vertices_to_shift,
1635            |old_coord| GridAddr::new(old_coord.row(), old_coord.col() - count),
1636            &mut summary,
1637        )?;
1638
1639        // 4. Adjust named ranges
1640        let old_names = if self.has_logger() {
1641            Some(self.snapshot_named_definitions())
1642        } else {
1643            None
1644        };
1645        self.graph.adjust_named_ranges(&op)?;
1646        if let Some(old_names) = old_names {
1647            let new_names = self.snapshot_named_definitions();
1648            for ((scope, name), old_definition) in old_names {
1649                if let Some(new_definition) = new_names.get(&(scope, name.clone()))
1650                    && *new_definition != old_definition
1651                {
1652                    self.log_change(ChangeEvent::NamedRangeAdjusted {
1653                        name,
1654                        scope,
1655                        old_definition,
1656                        new_definition: new_definition.clone(),
1657                    });
1658                }
1659            }
1660        }
1661
1662        // 5. Log change event
1663        if let Some(logger) = &mut self.change_logger {
1664            logger.end_compound();
1665        }
1666
1667        self.commit_batch();
1668
1669        Ok(summary)
1670    }
1671
1672    /// Shift rows down/up within a sheet (Excel's insert/delete rows)
1673    pub fn shift_rows(&mut self, sheet_id: SheetId, start_row: u32, delta: i32) {
1674        if delta == 0 {
1675            return;
1676        }
1677
1678        // Log change event for undo/redo
1679        let change_event = ChangeEvent::SetValue {
1680            addr: CellRef {
1681                sheet_id,
1682                coord: Coord::new(start_row, 0, true, true),
1683            },
1684            old_value: None,
1685            old_formula: None,
1686            new: LiteralValue::Text(format!("Row shift: start={start_row}, delta={delta}")),
1687        };
1688        self.log_change(change_event);
1689
1690        // TODO: Implement actual row shifting logic
1691        // This would require coordination with the vertex store and dependency tracking
1692    }
1693
1694    /// Shift columns left/right within a sheet (Excel's insert/delete columns)
1695    pub fn shift_columns(&mut self, sheet_id: SheetId, start_col: u32, delta: i32) {
1696        if delta == 0 {
1697            return;
1698        }
1699
1700        // Log change event
1701        let change_event = ChangeEvent::SetValue {
1702            addr: CellRef {
1703                sheet_id,
1704                coord: Coord::new(0, start_col, true, true),
1705            },
1706            old_value: None,
1707            old_formula: None,
1708            new: LiteralValue::Text(format!("Column shift: start={start_col}, delta={delta}")),
1709        };
1710        self.log_change(change_event);
1711
1712        // TODO: Implement actual column shifting logic
1713        // This would require coordination with the vertex store and dependency tracking
1714    }
1715
1716    /// Set a cell value. A value cell has no vertex (decision 27): returns
1717    /// `VertexId(0)`; a formula it replaces retires its id.
1718    pub fn set_cell_value(&mut self, cell_ref: CellRef, value: LiteralValue) -> VertexId {
1719        self.set_cell_value_with_old_state(cell_ref, value, None, None)
1720    }
1721
1722    /// Like [`set_cell_value`](Self::set_cell_value), but lets the caller
1723    /// supply old state captured from an external source of truth (e.g. the
1724    /// Arrow store, whose values are invisible here when the graph value cache
1725    /// is disabled) for the change-log event.
1726    ///
1727    /// Precedence matches the historical append-then-patch flow
1728    /// (`ChangeLog::patch_last_cell_event_old_state`): state the editor
1729    /// captures from the graph wins; caller-supplied state only fills fields
1730    /// the graph left `None`.
1731    pub fn set_cell_value_with_old_state(
1732        &mut self,
1733        cell_ref: CellRef,
1734        value: LiteralValue,
1735        fallback_old_value: Option<LiteralValue>,
1736        fallback_old_formula: Option<ASTNode>,
1737    ) -> VertexId {
1738        if value != LiteralValue::Empty {
1739            self.materialize_empty_tables(cell_ref);
1740        }
1741        let sheet_name = self.graph.sheet_name(cell_ref.sheet_id).to_string();
1742
1743        // Capture old state before modification (value + formula); fall back
1744        // to caller-supplied state for anything the graph cannot see.
1745        let old_id = self.graph.get_vertex_id_for_address(&cell_ref);
1746        let old_value = old_id
1747            .and_then(|id| self.graph.get_value(id))
1748            .or(fallback_old_value);
1749        let old_formula = old_id
1750            .and_then(|id| self.get_formula_ast(id))
1751            .or(fallback_old_formula);
1752
1753        // Record projection ownership as well as the value when a literal
1754        // replaces a spill child, so undo restores a generated cell.
1755        let spill_owner = self
1756            .graph
1757            .spill_registry_anchor_for_cell(cell_ref)
1758            .or(old_id);
1759        let spill_snapshot =
1760            spill_owner.and_then(|id| self.snapshot_spill_for_anchor(id).map(|s| (id, s)));
1761        let did_spill_clear = spill_snapshot.is_some();
1762        if let Some((anchor, old_spill)) = spill_snapshot {
1763            if let Some(logger) = &mut self.change_logger {
1764                logger.begin_compound(format!(
1765                    "SetValueWithSpillClear sheet={} row={} col={}",
1766                    cell_ref.sheet_id,
1767                    cell_ref.coord.row(),
1768                    cell_ref.coord.col()
1769                ));
1770            }
1771            self.graph.clear_spill_region(anchor);
1772            if self.graph.get_cell_ref(anchor) != Some(cell_ref) {
1773                self.graph.mark_dirty_many(&[anchor]);
1774            }
1775            self.log_change(ChangeEvent::SpillCleared {
1776                anchor,
1777                old: old_spill,
1778            });
1779        }
1780
1781        // Use the existing DependencyGraph API
1782        // VertexEditor operates on internal 0-based coords; graph APIs are 1-based.
1783        match self.graph.set_cell_value(
1784            &sheet_name,
1785            cell_ref.coord.row() + 1,
1786            cell_ref.coord.col() + 1,
1787            value.clone(),
1788        ) {
1789            Ok(_) => {
1790                // Log change event
1791                let change_event = ChangeEvent::SetValue {
1792                    addr: cell_ref,
1793                    old_value,
1794                    old_formula,
1795                    new: value,
1796                };
1797                self.log_change(change_event);
1798
1799                if did_spill_clear && let Some(logger) = &mut self.change_logger {
1800                    logger.end_compound();
1801                }
1802
1803                // A value cell has no vertex (decision 27).
1804                VertexId::new(0)
1805            }
1806            Err(_) => VertexId::new(0),
1807        }
1808    }
1809
1810    /// Set a cell formula, creating the vertex if it doesn't exist.
1811    ///
1812    /// Legacy compatibility API: failures return vertex zero. Prefer
1813    /// [`Self::try_set_cell_formula`] when failure must be observable.
1814    pub fn set_cell_formula(&mut self, cell_ref: CellRef, formula: ASTNode) -> VertexId {
1815        self.set_cell_formula_with_old_state(cell_ref, formula, None, None)
1816    }
1817
1818    /// Set a formula, reporting binding/admission failures without clearing its old spill.
1819    pub fn try_set_cell_formula(
1820        &mut self,
1821        cell_ref: CellRef,
1822        formula: ASTNode,
1823    ) -> Result<VertexId, ExcelError> {
1824        self.try_set_cell_formula_with_old_state(cell_ref, formula, None, None)
1825    }
1826
1827    /// Like [`set_cell_formula`](Self::set_cell_formula), but lets the caller
1828    /// supply old state captured from an external source of truth (e.g. the
1829    /// Arrow store) for the change-log event. Same precedence as
1830    /// [`set_cell_value_with_old_state`](Self::set_cell_value_with_old_state):
1831    /// graph-captured state wins, caller state only fills `None` fields.
1832    pub fn set_cell_formula_with_old_state(
1833        &mut self,
1834        cell_ref: CellRef,
1835        formula: ASTNode,
1836        fallback_old_value: Option<LiteralValue>,
1837        fallback_old_formula: Option<ASTNode>,
1838    ) -> VertexId {
1839        self.try_set_cell_formula_with_old_state(
1840            cell_ref,
1841            formula,
1842            fallback_old_value,
1843            fallback_old_formula,
1844        )
1845        .unwrap_or_else(|_| VertexId::new(0))
1846    }
1847
1848    /// Fallible counterpart to [`Self::set_cell_formula_with_old_state`].
1849    /// Graph-captured old state takes precedence over caller-provided state.
1850    pub fn try_set_cell_formula_with_old_state(
1851        &mut self,
1852        cell_ref: CellRef,
1853        formula: ASTNode,
1854        fallback_old_value: Option<LiteralValue>,
1855        fallback_old_formula: Option<ASTNode>,
1856    ) -> Result<VertexId, ExcelError> {
1857        self.set_cell_formula_with_old_state_and_plan(
1858            cell_ref,
1859            formula,
1860            fallback_old_value,
1861            fallback_old_formula,
1862            None,
1863        )
1864    }
1865
1866    pub(crate) fn set_cell_formula_with_prepared_plan(
1867        &mut self,
1868        cell_ref: CellRef,
1869        formula: ASTNode,
1870        fallback_old_value: Option<LiteralValue>,
1871        fallback_old_formula: Option<ASTNode>,
1872        ast_id: crate::engine::arena::AstNodeId,
1873        plan: crate::engine::ingest_pipeline::DependencyPlanRow,
1874    ) -> VertexId {
1875        self.set_cell_formula_with_old_state_and_plan(
1876            cell_ref,
1877            formula,
1878            fallback_old_value,
1879            fallback_old_formula,
1880            Some((ast_id, plan)),
1881        )
1882        .unwrap_or_else(|_| VertexId::new(0))
1883    }
1884
1885    fn set_cell_formula_with_old_state_and_plan(
1886        &mut self,
1887        cell_ref: CellRef,
1888        formula: ASTNode,
1889        fallback_old_value: Option<LiteralValue>,
1890        fallback_old_formula: Option<ASTNode>,
1891        prepared: Option<(
1892            crate::engine::arena::AstNodeId,
1893            crate::engine::ingest_pipeline::DependencyPlanRow,
1894        )>,
1895    ) -> Result<VertexId, ExcelError> {
1896        let sheet_name = self.graph.sheet_name(cell_ref.sheet_id).to_string();
1897
1898        // Capture old state before modification (value + formula); fall back
1899        // to caller-supplied state for anything the graph cannot see.
1900        let old_id = self.graph.get_vertex_id_for_address(&cell_ref);
1901        let old_value = old_id
1902            .and_then(|id| self.graph.get_value(id))
1903            .or(fallback_old_value);
1904        let old_formula = old_id
1905            .and_then(|id| self.get_formula_ast(id))
1906            .or(fallback_old_formula);
1907
1908        // Snapshot old spill values before updating, but do not clear or log anything
1909        // until the fallible binding/admission path succeeds.
1910        let spill_owner = self
1911            .graph
1912            .spill_registry_anchor_for_cell(cell_ref)
1913            .or(old_id);
1914        let spill_snapshot =
1915            spill_owner.and_then(|id| self.snapshot_spill_for_anchor(id).map(|s| (id, s)));
1916        let did_spill_clear = spill_snapshot.is_some();
1917
1918        // VertexEditor operates on internal 0-based coords; graph APIs are 1-based.
1919        let result = if let Some((ast_id, plan)) = prepared {
1920            self.graph.set_cell_formula_with_plan(
1921                &sheet_name,
1922                cell_ref.coord.row() + 1,
1923                cell_ref.coord.col() + 1,
1924                ast_id,
1925                &plan,
1926                plan.volatile,
1927                plan.dynamic,
1928            )
1929        } else {
1930            self.graph.set_cell_formula(
1931                &sheet_name,
1932                cell_ref.coord.row() + 1,
1933                cell_ref.coord.col() + 1,
1934                formula.clone(),
1935            )
1936        };
1937        match result {
1938            Ok(summary) => {
1939                self.materialize_empty_tables(cell_ref);
1940                if let Some((anchor, old_spill)) = spill_snapshot {
1941                    if let Some(logger) = &mut self.change_logger {
1942                        logger.begin_compound(format!(
1943                            "SetFormulaWithSpillClear sheet={} row={} col={}",
1944                            cell_ref.sheet_id,
1945                            cell_ref.coord.row(),
1946                            cell_ref.coord.col()
1947                        ));
1948                    }
1949                    self.graph.clear_spill_region(anchor);
1950                    if self.graph.get_cell_ref(anchor) != Some(cell_ref) {
1951                        self.graph.mark_dirty_many(&[anchor]);
1952                    }
1953                    self.log_change(ChangeEvent::SpillCleared {
1954                        anchor,
1955                        old: old_spill,
1956                    });
1957                }
1958                // Log change event
1959                let change_event = ChangeEvent::SetFormula {
1960                    addr: cell_ref,
1961                    old_value,
1962                    old_formula,
1963                    new: formula,
1964                };
1965                self.log_change(change_event);
1966
1967                if did_spill_clear && let Some(logger) = &mut self.change_logger {
1968                    logger.end_compound();
1969                }
1970
1971                Ok(summary
1972                    .affected_vertices
1973                    .into_iter()
1974                    .next()
1975                    .unwrap_or(VertexId::new(0)))
1976            }
1977            Err(error) => Err(error),
1978        }
1979    }
1980
1981    // Range operations
1982
1983    /// Set values for a rectangular range of cells
1984    pub fn set_range_values(
1985        &mut self,
1986        sheet_id: SheetId,
1987        start_row: u32,
1988        start_col: u32,
1989        values: &[Vec<LiteralValue>],
1990    ) -> Result<RangeSummary, EditorError> {
1991        let mut summary = RangeSummary::default();
1992
1993        self.begin_batch();
1994        // One multi-source dirty propagation for the whole rectangle instead
1995        // of a full BFS per cell (the loop body cannot error, so the scope
1996        // always closes before returning).
1997        self.graph.begin_deferred_dirty();
1998
1999        for (row_offset, row_values) in values.iter().enumerate() {
2000            for (col_offset, value) in row_values.iter().enumerate() {
2001                let row = start_row + row_offset as u32;
2002                let col = start_col + col_offset as u32;
2003                let cell_ref = self.graph.make_cell_ref_internal(sheet_id, row, col);
2004                let existing_id = self.graph.get_vertex_id_for_address(&cell_ref);
2005
2006                let id = self.set_cell_value(cell_ref, value.clone());
2007                match existing_id {
2008                    Some(existing_id) => summary.vertices_updated.push(existing_id),
2009                    None if id.0 != 0 => summary.vertices_created.push(id),
2010                    None => {}
2011                }
2012                summary.cells_affected += 1;
2013            }
2014        }
2015
2016        let _ = self.graph.end_deferred_dirty();
2017        self.commit_batch();
2018
2019        Ok(summary)
2020    }
2021
2022    /// Clear all cells in a rectangular range
2023    pub fn clear_range(
2024        &mut self,
2025        sheet_id: SheetId,
2026        start_row: u32,
2027        start_col: u32,
2028        end_row: u32,
2029        end_col: u32,
2030    ) -> Result<RangeSummary, EditorError> {
2031        let mut summary = RangeSummary::default();
2032
2033        self.begin_batch();
2034
2035        // Collect vertices in range
2036        let vertices_in_range: Vec<_> = self
2037            .graph
2038            .grid_vertices_in_sheet(sheet_id)
2039            .filter(|(_, coord)| {
2040                let row = coord.row();
2041                let col = coord.col();
2042                row >= start_row && row <= end_row && col >= start_col && col <= end_col
2043            })
2044            .collect();
2045
2046        let mut vertex_cells = rustc_hash::FxHashSet::default();
2047        for (id, coord) in vertices_in_range {
2048            self.remove_vertex(id)?;
2049            vertex_cells.insert((coord.row(), coord.col()));
2050            summary.cells_affected += 1;
2051        }
2052        // Value and referenced cells (legacy's vertices) leave the used
2053        // extent too, and count as cleared cells, as their vertices did.
2054        for (col, r0, r1) in
2055            self.graph
2056                .forget_extent_cells(sheet_id, (start_row, end_row), (start_col, end_col))
2057        {
2058            summary.cells_affected += (r0..=r1)
2059                .filter(|&row| !vertex_cells.contains(&(row, col)))
2060                .count();
2061        }
2062
2063        self.commit_batch();
2064
2065        Ok(summary)
2066    }
2067
2068    /// Copy a range to a new location
2069    pub fn copy_range(
2070        &mut self,
2071        sheet_id: SheetId,
2072        from_start_row: u32,
2073        from_start_col: u32,
2074        from_end_row: u32,
2075        from_end_col: u32,
2076        to_sheet_id: SheetId,
2077        to_row: u32,
2078        to_col: u32,
2079    ) -> Result<RangeSummary, EditorError> {
2080        let row_offset = to_row as i32 - from_start_row as i32;
2081        let col_offset = to_col as i32 - from_start_col as i32;
2082
2083        let mut summary = RangeSummary::default();
2084        let mut cell_data = Vec::new();
2085
2086        // Collect source data
2087        let vertices_in_range: Vec<_> = self
2088            .graph
2089            .grid_vertices_in_sheet(sheet_id)
2090            .filter(|(_, coord)| {
2091                let row = coord.row();
2092                let col = coord.col();
2093                row >= from_start_row
2094                    && row <= from_end_row
2095                    && col >= from_start_col
2096                    && col <= from_end_col
2097            })
2098            .collect();
2099
2100        for (id, coord) in vertices_in_range {
2101            let row = coord.row();
2102            let col = coord.col();
2103
2104            // Get value or formula
2105            if let Some(formula) = self.get_formula_ast(id) {
2106                cell_data.push((
2107                    row - from_start_row,
2108                    col - from_start_col,
2109                    CellData::Formula(formula),
2110                ));
2111            } else if let Some(value) = self.graph.get_value(id) {
2112                cell_data.push((
2113                    row - from_start_row,
2114                    col - from_start_col,
2115                    CellData::Value(value),
2116                ));
2117            }
2118        }
2119
2120        self.begin_batch();
2121
2122        // Apply to destination with relative adjustment
2123        for (row_idx, col_idx, data) in cell_data {
2124            let dest_row = (to_row as i32 + row_idx as i32) as u32;
2125            let dest_col = (to_col as i32 + col_idx as i32) as u32;
2126
2127            match data {
2128                CellData::Value(value) => {
2129                    let cell_ref =
2130                        self.graph
2131                            .make_cell_ref_internal(to_sheet_id, dest_row, dest_col);
2132
2133                    if let Some(existing_id) = self.graph.get_vertex_id_for_address(&cell_ref) {
2134                        self.graph.update_vertex_value(existing_id, value);
2135                        self.graph.mark_vertex_dirty(existing_id);
2136                        summary.vertices_updated.push(existing_id);
2137                    } else {
2138                        let meta =
2139                            VertexMeta::new(dest_row, dest_col, to_sheet_id, VertexKind::Cell);
2140                        let id = self.try_add_vertex(meta)?;
2141                        self.graph.update_vertex_value(id, value);
2142                        summary.vertices_created.push(id);
2143                    }
2144                }
2145                CellData::Formula(formula) => {
2146                    // Adjust relative references in formula
2147                    let adjuster = RelativeReferenceAdjuster::new(row_offset, col_offset);
2148                    let adjusted = adjuster.adjust_formula(&formula);
2149
2150                    let cell_ref =
2151                        self.graph
2152                            .make_cell_ref_internal(to_sheet_id, dest_row, dest_col);
2153
2154                    if let Some(existing_id) = self.graph.get_vertex_id_for_address(&cell_ref) {
2155                        self.graph.update_vertex_formula(existing_id, adjusted)?;
2156                        summary.vertices_updated.push(existing_id);
2157                    } else {
2158                        let meta = VertexMeta::new(
2159                            dest_row,
2160                            dest_col,
2161                            to_sheet_id,
2162                            VertexKind::FormulaScalar,
2163                        );
2164                        let id = self.try_add_vertex(meta)?;
2165                        self.graph.update_vertex_formula(id, adjusted)?;
2166                        summary.vertices_created.push(id);
2167                    }
2168                }
2169            }
2170
2171            summary.cells_affected += 1;
2172        }
2173
2174        self.commit_batch();
2175
2176        Ok(summary)
2177    }
2178
2179    /// Move a range to a new location (copy + clear source)
2180    pub fn move_range(
2181        &mut self,
2182        sheet_id: SheetId,
2183        from_start_row: u32,
2184        from_start_col: u32,
2185        from_end_row: u32,
2186        from_end_col: u32,
2187        to_sheet_id: SheetId,
2188        to_row: u32,
2189        to_col: u32,
2190    ) -> Result<RangeSummary, EditorError> {
2191        let result = self.move_range_impl(
2192            sheet_id,
2193            from_start_row,
2194            from_start_col,
2195            from_end_row,
2196            from_end_col,
2197            to_sheet_id,
2198            to_row,
2199            to_col,
2200        );
2201        self.graph.authority_end_structural();
2202        result
2203    }
2204
2205    fn move_range_impl(
2206        &mut self,
2207        sheet_id: SheetId,
2208        from_start_row: u32,
2209        from_start_col: u32,
2210        from_end_row: u32,
2211        from_end_col: u32,
2212        to_sheet_id: SheetId,
2213        to_row: u32,
2214        to_col: u32,
2215    ) -> Result<RangeSummary, EditorError> {
2216        self.graph.authority_note_structural(true);
2217        // First copy the range
2218        let mut summary = self.copy_range(
2219            sheet_id,
2220            from_start_row,
2221            from_start_col,
2222            from_end_row,
2223            from_end_col,
2224            to_sheet_id,
2225            to_row,
2226            to_col,
2227        )?;
2228
2229        // Then clear the source range
2230        let clear_summary = self.clear_range(
2231            sheet_id,
2232            from_start_row,
2233            from_start_col,
2234            from_end_row,
2235            from_end_col,
2236        )?;
2237
2238        summary.cells_moved = clear_summary.cells_affected;
2239
2240        // Update external references to moved cells
2241        let row_offset = to_row as i32 - from_start_row as i32;
2242        let col_offset = to_col as i32 - from_start_col as i32;
2243
2244        // Find all formulas that reference the moved range
2245        let all_formula_vertices: Vec<_> = self.graph.vertices_with_formulas().collect();
2246
2247        let from_sheet_name = self.graph.sheet_name(sheet_id).to_string();
2248        let to_sheet_name = self.graph.sheet_name(to_sheet_id).to_string();
2249        let adjuster = MoveReferenceAdjuster::new(
2250            sheet_id,
2251            from_sheet_name,
2252            from_start_row,
2253            from_start_col,
2254            from_end_row,
2255            from_end_col,
2256            to_sheet_id,
2257            to_sheet_name,
2258            row_offset,
2259            col_offset,
2260        );
2261
2262        for formula_id in all_formula_vertices {
2263            if let Some(formula) = self.get_formula_ast(formula_id) {
2264                let formula_sheet_id = self.graph.get_vertex_sheet_id(formula_id);
2265                if let Some(adjusted) = adjuster.adjust_if_references(&formula, formula_sheet_id) {
2266                    self.graph.update_vertex_formula(formula_id, adjusted)?;
2267                }
2268            }
2269        }
2270
2271        Ok(summary)
2272    }
2273
2274    /// Define a named range
2275    pub fn define_name(
2276        &mut self,
2277        name: &str,
2278        definition: NamedDefinition,
2279        scope: NameScope,
2280    ) -> Result<(), EditorError> {
2281        self.graph.define_name(name, definition.clone(), scope)?;
2282
2283        self.log_change(ChangeEvent::DefineName {
2284            name: name.to_string(),
2285            scope,
2286            definition,
2287        });
2288
2289        Ok(())
2290    }
2291
2292    /// Helper to create definitions from coordinates for a single cell
2293    pub fn define_name_for_cell(
2294        &mut self,
2295        name: &str,
2296        sheet_name: &str,
2297        row: u32,
2298        col: u32,
2299        scope: NameScope,
2300    ) -> Result<(), EditorError> {
2301        let sheet_id = self
2302            .graph
2303            .sheet_id(sheet_name)
2304            .ok_or_else(|| EditorError::InvalidName {
2305                name: sheet_name.to_string(),
2306                reason: "Sheet not found".to_string(),
2307            })?;
2308        let cell_ref = CellRef::new(sheet_id, Coord::from_excel(row, col, true, true));
2309        self.define_name(name, NamedDefinition::Cell(cell_ref), scope)
2310    }
2311
2312    /// Helper to create definitions from coordinates for a range
2313    pub fn define_name_for_range(
2314        &mut self,
2315        name: &str,
2316        sheet_name: &str,
2317        start_row: u32,
2318        start_col: u32,
2319        end_row: u32,
2320        end_col: u32,
2321        scope: NameScope,
2322    ) -> Result<(), EditorError> {
2323        let sheet_id = self
2324            .graph
2325            .sheet_id(sheet_name)
2326            .ok_or_else(|| EditorError::InvalidName {
2327                name: sheet_name.to_string(),
2328                reason: "Sheet not found".to_string(),
2329            })?;
2330        let start = CellRef::new(
2331            sheet_id,
2332            Coord::from_excel(start_row, start_col, true, true),
2333        );
2334        let end = CellRef::new(sheet_id, Coord::from_excel(end_row, end_col, true, true));
2335        let range_ref = crate::reference::RangeRef::new(start, end);
2336        self.define_name(name, NamedDefinition::Range(range_ref), scope)
2337    }
2338
2339    /// Update an existing named range definition
2340    pub fn update_name(
2341        &mut self,
2342        name: &str,
2343        new_definition: NamedDefinition,
2344        scope: NameScope,
2345    ) -> Result<(), EditorError> {
2346        // Get the old definition for the change log
2347        let old_definition = self
2348            .graph
2349            .resolve_name(
2350                name,
2351                match scope {
2352                    NameScope::Sheet(id) => id,
2353                    NameScope::Workbook => 0,
2354                },
2355            )
2356            .cloned();
2357
2358        self.graph
2359            .update_name(name, new_definition.clone(), scope)?;
2360
2361        if let Some(old_def) = old_definition {
2362            self.log_change(ChangeEvent::UpdateName {
2363                name: name.to_string(),
2364                scope,
2365                old_definition: old_def,
2366                new_definition,
2367            });
2368        }
2369
2370        Ok(())
2371    }
2372
2373    /// Delete a named range
2374    pub fn delete_name(&mut self, name: &str, scope: NameScope) -> Result<(), EditorError> {
2375        // Capture old definition *before* deletion so undo can restore it.
2376        let old_def = if self.has_logger() {
2377            self.graph
2378                .resolve_name(
2379                    name,
2380                    match scope {
2381                        NameScope::Sheet(id) => id,
2382                        NameScope::Workbook => 0,
2383                    },
2384                )
2385                .cloned()
2386        } else {
2387            None
2388        };
2389
2390        self.graph.delete_name(name, scope)?;
2391        self.log_change(ChangeEvent::DeleteName {
2392            name: name.to_string(),
2393            scope,
2394            old_definition: old_def,
2395        });
2396
2397        Ok(())
2398    }
2399}
2400
2401/// Helper enum for cell data
2402enum CellData {
2403    Value(LiteralValue),
2404    Formula(ASTNode),
2405}
2406
2407impl<'g> Drop for VertexEditor<'g> {
2408    fn drop(&mut self) {
2409        // Ensure batch operations are committed when the editor is dropped
2410        if self.batch_mode {
2411            self.commit_batch();
2412        }
2413    }
2414}
2415
2416#[cfg(test)]
2417mod tests {
2418    use super::*;
2419    use crate::engine::graph::editor::change_log::{ChangeEvent, ChangeLog};
2420    use crate::reference::Coord;
2421
2422    fn create_test_graph() -> DependencyGraph {
2423        DependencyGraph::new()
2424    }
2425
2426    #[test]
2427    fn test_vertex_editor_creation() {
2428        let mut graph = create_test_graph();
2429        let editor = VertexEditor::new(&mut graph);
2430        assert!(!editor.has_logger());
2431        assert!(!editor.batch_mode);
2432    }
2433
2434    #[test]
2435    fn test_vertex_editor_with_logger() {
2436        let mut graph = create_test_graph();
2437        let mut log = ChangeLog::new();
2438        let editor = VertexEditor::with_logger(&mut graph, &mut log);
2439        assert!(editor.has_logger());
2440        assert!(!editor.batch_mode);
2441    }
2442
2443    #[test]
2444    fn test_add_vertex() {
2445        let mut graph = create_test_graph();
2446        let mut editor = VertexEditor::new(&mut graph);
2447
2448        let meta = VertexMeta::new(5, 10, 0, VertexKind::Cell).dirty();
2449        let vertex_id = editor.add_vertex(meta);
2450
2451        // Verify vertex was created (simplified check)
2452        assert!(vertex_id.0 > 0);
2453    }
2454
2455    #[test]
2456    fn test_batch_operations() {
2457        let mut graph = create_test_graph();
2458        let mut editor = VertexEditor::new(&mut graph);
2459
2460        assert!(!editor.batch_mode);
2461        editor.begin_batch();
2462        assert!(editor.batch_mode);
2463
2464        // Add multiple vertices in batch mode
2465        let meta1 = VertexMeta::new(1, 1, 0, VertexKind::Cell);
2466        let meta2 = VertexMeta::new(2, 2, 0, VertexKind::Cell);
2467
2468        let id1 = editor.add_vertex(meta1);
2469        let id2 = editor.add_vertex(meta2);
2470
2471        // Add edge between them
2472        assert!(editor.add_edge(id1, id2));
2473
2474        editor.commit_batch();
2475        assert!(!editor.batch_mode);
2476    }
2477
2478    #[test]
2479    fn test_remove_vertex() {
2480        let mut graph = create_test_graph();
2481        let mut editor = VertexEditor::new(&mut graph);
2482
2483        let meta = VertexMeta::new(3, 4, 0, VertexKind::Cell).dirty();
2484        let vertex_id = editor.add_vertex(meta);
2485
2486        // Now removal returns Result
2487        assert!(editor.remove_vertex(vertex_id).is_ok());
2488    }
2489
2490    #[test]
2491    fn test_remove_vertex_clears_spill_registry_for_anchor() {
2492        let mut graph = create_test_graph();
2493        let sheet_id = graph.sheet_id_mut("Sheet1");
2494
2495        // Create anchor vertex at A1 (0-based internal coord 0,0).
2496        let anchor_cell = CellRef::new(sheet_id, Coord::new(0, 0, true, true));
2497        let anchor_vid = {
2498            let mut editor = VertexEditor::new(&mut graph);
2499            // A formula anchors a spill (value cells have no vertex,
2500            // decision 27; this used a value cell's vertex).
2501            editor.set_cell_formula(anchor_cell, formualizer_parse::parser::parse("=0").unwrap())
2502        };
2503
2504        let target_cells = vec![
2505            CellRef::new(sheet_id, Coord::new(0, 0, true, true)),
2506            CellRef::new(sheet_id, Coord::new(0, 1, true, true)),
2507            CellRef::new(sheet_id, Coord::new(1, 0, true, true)),
2508            CellRef::new(sheet_id, Coord::new(1, 1, true, true)),
2509        ];
2510        let values = vec![
2511            vec![LiteralValue::Number(1.0), LiteralValue::Number(2.0)],
2512            vec![LiteralValue::Number(3.0), LiteralValue::Number(4.0)],
2513        ];
2514
2515        graph
2516            .commit_spill_region_atomic_with_fault(anchor_vid, target_cells.clone(), values, None)
2517            .unwrap();
2518
2519        assert!(graph.spill_registry_has_anchor(anchor_vid));
2520        for cell in &target_cells {
2521            assert_eq!(
2522                graph.spill_registry_anchor_for_cell(*cell),
2523                Some(anchor_vid)
2524            );
2525        }
2526
2527        {
2528            let mut editor = VertexEditor::new(&mut graph);
2529            editor.remove_vertex(anchor_vid).unwrap();
2530        }
2531
2532        assert!(!graph.spill_registry_has_anchor(anchor_vid));
2533        for cell in &target_cells {
2534            assert_eq!(graph.spill_registry_anchor_for_cell(*cell), None);
2535        }
2536        assert_eq!(graph.spill_registry_counts(), (0, 0));
2537    }
2538
2539    #[test]
2540    fn test_edge_operations() {
2541        let mut graph = create_test_graph();
2542        let mut editor = VertexEditor::new(&mut graph);
2543
2544        let meta1 = VertexMeta::new(1, 1, 0, VertexKind::Cell);
2545        let meta2 = VertexMeta::new(2, 2, 0, VertexKind::FormulaScalar);
2546
2547        let id1 = editor.add_vertex(meta1);
2548        let id2 = editor.add_vertex(meta2);
2549
2550        // Add edge
2551        assert!(editor.add_edge(id1, id2));
2552
2553        // Prevent self-loop
2554        assert!(!editor.add_edge(id1, id1));
2555
2556        // Remove edge
2557        assert!(editor.remove_edge(id1, id2));
2558    }
2559
2560    #[test]
2561    fn test_set_cell_value() {
2562        let mut graph = create_test_graph();
2563        let mut log = ChangeLog::new();
2564
2565        let cell_ref = CellRef {
2566            sheet_id: 0,
2567            coord: Coord::new(2, 3, true, true),
2568        };
2569        let value = LiteralValue::Number(42.0);
2570
2571        let vertex_id = {
2572            let mut editor = VertexEditor::with_logger(&mut graph, &mut log);
2573            editor.set_cell_value(cell_ref, value.clone())
2574        };
2575
2576        // A value cell has no vertex (decision 27): the sentinel id.
2577        assert_eq!(vertex_id.0, 0);
2578
2579        // Verify change log
2580        assert_eq!(log.len(), 1);
2581        match &log.events()[0] {
2582            ChangeEvent::SetValue { addr, new, .. } => {
2583                assert_eq!(addr.sheet_id, cell_ref.sheet_id);
2584                assert_eq!(addr.coord.row(), cell_ref.coord.row());
2585                assert_eq!(addr.coord.col(), cell_ref.coord.col());
2586                assert_eq!(new, &value);
2587            }
2588            _ => panic!("Expected SetValue event"),
2589        }
2590    }
2591
2592    #[test]
2593    fn test_set_cell_formula() {
2594        let mut graph = create_test_graph();
2595        let mut log = ChangeLog::new();
2596
2597        let cell_ref = CellRef {
2598            sheet_id: 0,
2599            coord: Coord::new(1, 1, true, true),
2600        };
2601
2602        use formualizer_parse::parser::ASTNodeType;
2603        let formula = formualizer_parse::parser::ASTNode {
2604            node_type: ASTNodeType::Literal(LiteralValue::Number(100.0)),
2605            source_token: None,
2606            contains_volatile: false,
2607        };
2608
2609        let vertex_id = {
2610            let mut editor = VertexEditor::with_logger(&mut graph, &mut log);
2611            editor.set_cell_formula(cell_ref, formula.clone())
2612        };
2613
2614        // Verify vertex was created (simplified check)
2615        assert!(vertex_id.0 > 0);
2616
2617        // Verify change log
2618        assert_eq!(log.len(), 1);
2619        match &log.events()[0] {
2620            ChangeEvent::SetFormula { addr, .. } => {
2621                assert_eq!(addr.sheet_id, cell_ref.sheet_id);
2622                assert_eq!(addr.coord.row(), cell_ref.coord.row());
2623                assert_eq!(addr.coord.col(), cell_ref.coord.col());
2624            }
2625            _ => panic!("Expected SetFormula event"),
2626        }
2627    }
2628
2629    #[test]
2630    fn test_shift_rows() {
2631        let mut graph = create_test_graph();
2632        let mut log = ChangeLog::new();
2633
2634        {
2635            let mut editor = VertexEditor::with_logger(&mut graph, &mut log);
2636
2637            // Create vertices at different rows
2638            let cell1 = CellRef {
2639                sheet_id: 0,
2640                coord: Coord::new(5, 1, true, true),
2641            };
2642            let cell2 = CellRef {
2643                sheet_id: 0,
2644                coord: Coord::new(10, 1, true, true),
2645            };
2646            let cell3 = CellRef {
2647                sheet_id: 0,
2648                coord: Coord::new(15, 1, true, true),
2649            };
2650
2651            editor.set_cell_value(cell1, LiteralValue::Number(1.0));
2652            editor.set_cell_value(cell2, LiteralValue::Number(2.0));
2653            editor.set_cell_value(cell3, LiteralValue::Number(3.0));
2654        }
2655
2656        // Clear change log to focus on shift operation
2657        log.clear();
2658
2659        {
2660            let mut editor = VertexEditor::with_logger(&mut graph, &mut log);
2661            // Shift rows starting at row 10, moving down by 2
2662            editor.shift_rows(0, 10, 2);
2663        }
2664
2665        // Verify change log contains the shift operation
2666        assert_eq!(log.len(), 1);
2667        match &log.events()[0] {
2668            ChangeEvent::SetValue { addr, new, .. } => {
2669                assert_eq!(addr.sheet_id, 0);
2670                assert_eq!(addr.coord.row(), 10);
2671                if let LiteralValue::Text(msg) = new {
2672                    assert!(msg.contains("Row shift"));
2673                    assert!(msg.contains("start=10"));
2674                    assert!(msg.contains("delta=2"));
2675                }
2676            }
2677            _ => panic!("Expected SetValue event for row shift"),
2678        }
2679    }
2680
2681    #[test]
2682    fn test_shift_columns() {
2683        let mut graph = create_test_graph();
2684        let mut log = ChangeLog::new();
2685
2686        {
2687            let mut editor = VertexEditor::with_logger(&mut graph, &mut log);
2688
2689            // Create vertices at different columns
2690            let cell1 = CellRef {
2691                sheet_id: 0,
2692                coord: Coord::new(1, 5, true, true),
2693            };
2694            let cell2 = CellRef {
2695                sheet_id: 0,
2696                coord: Coord::new(1, 10, true, true),
2697            };
2698
2699            editor.set_cell_value(cell1, LiteralValue::Number(1.0));
2700            editor.set_cell_value(cell2, LiteralValue::Number(2.0));
2701        }
2702
2703        // Clear change log
2704        log.clear();
2705
2706        {
2707            let mut editor = VertexEditor::with_logger(&mut graph, &mut log);
2708            // Shift columns starting at col 8, moving right by 3
2709            editor.shift_columns(0, 8, 3);
2710        }
2711
2712        // Verify change log
2713        assert_eq!(log.len(), 1);
2714        match &log.events()[0] {
2715            ChangeEvent::SetValue { addr, new, .. } => {
2716                assert_eq!(addr.sheet_id, 0);
2717                assert_eq!(addr.coord.col(), 8);
2718                if let LiteralValue::Text(msg) = new {
2719                    assert!(msg.contains("Column shift"));
2720                    assert!(msg.contains("start=8"));
2721                    assert!(msg.contains("delta=3"));
2722                }
2723            }
2724            _ => panic!("Expected SetValue event for column shift"),
2725        }
2726    }
2727
2728    #[test]
2729    fn test_move_vertex() {
2730        let mut graph = create_test_graph();
2731        let mut editor = VertexEditor::new(&mut graph);
2732
2733        let meta = VertexMeta::new(5, 10, 0, VertexKind::Cell);
2734        let vertex_id = editor.add_vertex(meta);
2735
2736        // Move vertex returns Result
2737        assert!(editor.move_vertex(vertex_id, GridAddr::new(8, 12)).is_ok());
2738
2739        // Moving to same position should work
2740        assert!(editor.move_vertex(vertex_id, GridAddr::new(8, 12)).is_ok());
2741    }
2742
2743    #[test]
2744    fn test_vertex_meta_builder() {
2745        let meta = VertexMeta::new(1, 2, 3, VertexKind::FormulaScalar)
2746            .dirty()
2747            .volatile()
2748            .with_flags(0x08);
2749
2750        assert_eq!(meta.coord.row(), 1);
2751        assert_eq!(meta.coord.col(), 2);
2752        assert_eq!(meta.sheet_id, 3);
2753        assert_eq!(meta.kind, VertexKind::FormulaScalar);
2754        assert_eq!(meta.flags, 0x08); // Last with_flags call overwrites previous flags
2755    }
2756
2757    #[test]
2758    fn test_change_log_management() {
2759        let mut graph = create_test_graph();
2760        let mut log = ChangeLog::new();
2761
2762        {
2763            let mut editor = VertexEditor::with_logger(&mut graph, &mut log);
2764            let cell_ref = CellRef {
2765                sheet_id: 0,
2766                coord: Coord::new(0, 0, true, true),
2767            };
2768            editor.set_cell_value(cell_ref, LiteralValue::Number(1.0));
2769            editor.set_cell_value(cell_ref, LiteralValue::Number(2.0));
2770        }
2771
2772        assert_eq!(log.len(), 2);
2773
2774        log.clear();
2775        assert_eq!(log.len(), 0);
2776    }
2777
2778    #[test]
2779    fn test_editor_drop_commits_batch() {
2780        let mut graph = create_test_graph();
2781        {
2782            let mut editor = VertexEditor::new(&mut graph);
2783            editor.begin_batch();
2784
2785            let meta = VertexMeta::new(1, 1, 0, VertexKind::Cell);
2786            editor.add_vertex(meta);
2787
2788            // Editor will be dropped here, should commit batch
2789        }
2790
2791        // If we reach here without hanging, the batch was properly committed
2792    }
2793}