1344 lines
53 KiB
Rust
1344 lines
53 KiB
Rust
//! Multi-line mixed `batch_design` DSL program executor.
|
|
//!
|
|
//! Mirrors TS `packages/pen-mcp/src/tools/batch-design-dsl.ts`
|
|
//! (`runBatchDesignDsl` + `executeLine`) for the line grammar: arbitrary
|
|
//! programs mixing I/U/C/R/G/M/D operations with shared bindings and
|
|
//! slash-path expressions, executed line by line.
|
|
//!
|
|
//! ## Line-failure policy
|
|
//!
|
|
//! The agent-facing tool surface is TRANSACTIONAL (Pencil's contract:
|
|
//! "if any operation fails, every already-executed operation of the
|
|
//! batch is rolled back"): when any line fails, NO command ships — the
|
|
//! live document is untouched, `errors[]` lists every failing line
|
|
//! (200-char preview), and the envelope carries `applied:false` plus a
|
|
//! resend hint. A half-applied batch is worse than a rejected one for a
|
|
//! model in a feedback loop: the loop's next batch would build on a tree
|
|
//! the model believes complete.
|
|
//!
|
|
//! The orchestrator's internal script-gen path opts back into the old
|
|
//! TS best-effort semantics (failing lines dropped, survivors apply)
|
|
//! via the internal `_line_policy=best_effort` arg — it runs against a
|
|
//! scratch document, surfaces drops as warnings, and has its own
|
|
//! retry/cleanup ladder downstream (`program_gen.rs`).
|
|
//!
|
|
//! ## Snapshot simulation + one host command
|
|
//!
|
|
//! TS mutates the document in-process; the Rust tool stays `&self` and
|
|
//! must hand the host applier a command. The executor therefore runs
|
|
//! every line against a CLONE of the document snapshot
|
|
//! (`sim.apply(...)` — the exact code the host runs at apply), so:
|
|
//! - per-line success/failure matches what the host would decide,
|
|
//! - bindings resolve to the REAL ids the host will assign (the
|
|
//! executor assigns authored ids off the sim allocator and emits
|
|
//! `InsertAuthoredSubtree`, the `batch_design_result.rs` id-predict
|
|
//! discipline),
|
|
//! - later lines observe earlier lines' mutations.
|
|
//!
|
|
//! The surviving commands ride home as ONE `EditorCommand::Batch`
|
|
//! (atomic at apply; a live-doc divergence rejects the whole batch
|
|
//! rather than landing silently-wrong ids).
|
|
//!
|
|
//! ## Documented divergences from TS
|
|
//!
|
|
//! - Inserted subtree ids are remapped to fresh editor ids (Rust id
|
|
//! discipline); slash paths written against AUTHORED child ids keep
|
|
//! working through an alias table (authored → final, first-wins).
|
|
//! - An `I`/`C`/`G` whose parent does not resolve to a container is a
|
|
//! per-line ERROR; TS's `insertNodeInTree` silently drops the node
|
|
//! while still reporting a binding for it.
|
|
//! - `M()` with a non-integer index errors; TS's `parseInt` NaN would
|
|
//! silently splice at index 0.
|
|
//! - `C()` ignores `descendants` overrides: TS clones with fresh ids
|
|
//! first, so override keys (source ids) never match — a no-op there,
|
|
//! an explicit skip here.
|
|
|
|
use std::collections::{BTreeMap, BTreeSet};
|
|
|
|
use jian_ops_schema::node::{ContainerProps, LayoutMode, PenNode};
|
|
use jian_scene::layout_scene::SceneNode;
|
|
use op_editor_core::command_node::remap_subtree_ids_mapping;
|
|
use op_editor_core::{EditorState, NodeId, PenNodeExt};
|
|
use regex::Regex;
|
|
use serde_json::{json, Value};
|
|
|
|
use super::batch_design::{
|
|
ensure_node_ids, find_top_level_char, normalize_node_shape, split_operations,
|
|
};
|
|
use super::batch_direct_ops::{split_top_level_args, update_command_from_value};
|
|
use super::batch_page::optional_page_id;
|
|
use super::{EditorCommand, ToolOutcome};
|
|
|
|
/// Run a mixed multi-op DSL program against the document snapshot and
|
|
/// return the TS `handleBatchDesign` envelope:
|
|
/// `{ results, nodeCount, postProcessed?, errors? }`.
|
|
pub(crate) fn run_batch_design_program(
|
|
snapshot: &EditorState,
|
|
operations: &str,
|
|
args: &BTreeMap<String, String>,
|
|
) -> ToolOutcome {
|
|
let page_id = optional_page_id(args);
|
|
// TS batch_design `postProcess` defaults to false (unlike
|
|
// design_content's default-true).
|
|
let post_process = args
|
|
.get("postProcess")
|
|
.or_else(|| args.get("post_process"))
|
|
.map(|raw| matches!(raw.trim(), "true" | "1"))
|
|
.unwrap_or(false);
|
|
let lines = split_operations(operations);
|
|
let mut ctx = ProgramCtx {
|
|
sim: snapshot.clone(),
|
|
page_id: page_id.clone(),
|
|
bindings: BTreeMap::new(),
|
|
alias: BTreeMap::new(),
|
|
results: Vec::new(),
|
|
commands: Vec::new(),
|
|
post_process,
|
|
auto_seq: 0,
|
|
current_line: 0,
|
|
explicitly_sized_append_lines: explicitly_sized_append_lines(&lines),
|
|
};
|
|
// Pin the sim's active page to the requested page so sim READS
|
|
// (path lookups, node counts) see the same children every emitted
|
|
// command targets via its `page_id` field. An unknown page id is
|
|
// left to per-command apply rejection (consistent per-line errors).
|
|
if let Some(raw) = page_id.as_deref() {
|
|
if let Some(index) = resolve_page_index(&ctx.sim, raw) {
|
|
let _ = ctx.sim.apply(EditorCommand::SetActivePage {
|
|
index: index as u32,
|
|
});
|
|
}
|
|
}
|
|
// Live-doc node count BEFORE any line runs — the honest `nodeCount`
|
|
// for a rolled-back transaction (nothing will have been applied).
|
|
let baseline_count = count_forest(ctx.sim.active_children());
|
|
// Internal knob for the orchestrator's script-gen path (see module
|
|
// doc); absent → transactional, the agent-facing contract.
|
|
let transactional = args.get("_line_policy").map(String::as_str) != Some("best_effort");
|
|
|
|
let mut errors: Vec<Value> = Vec::new();
|
|
for (line_index, line) in lines.into_iter().enumerate() {
|
|
ctx.current_line = line_index;
|
|
if let Err(error) = execute_line(&line, &mut ctx) {
|
|
errors.push(json!({ "line": line_preview(&line), "error": error }));
|
|
}
|
|
}
|
|
|
|
if transactional && !errors.is_empty() {
|
|
// Roll the whole batch back: drop every recorded command so the
|
|
// host applies NOTHING. Bindings/results are dropped too — their
|
|
// node ids never land, and reporting them would invite the model
|
|
// to reference phantom nodes in its next batch.
|
|
let mut envelope = serde_json::Map::new();
|
|
envelope.insert("results".into(), Value::Array(Vec::new()));
|
|
envelope.insert("nodeCount".into(), json!(baseline_count));
|
|
envelope.insert("applied".into(), Value::Bool(false));
|
|
envelope.insert(
|
|
"hint".into(),
|
|
json!(format!(
|
|
"Transaction rolled back: {} operation(s) failed, so NONE of this batch was \
|
|
applied — the document is unchanged. Fix the failing line(s) and resend the \
|
|
whole corrected batch.",
|
|
errors.len()
|
|
)),
|
|
);
|
|
envelope.insert("errors".into(), Value::Array(errors));
|
|
return ToolOutcome::OkJson(Value::Object(envelope).to_string());
|
|
}
|
|
|
|
let node_count = count_forest(ctx.sim.active_children());
|
|
let mut envelope = serde_json::Map::new();
|
|
envelope.insert("results".into(), Value::Array(ctx.results));
|
|
envelope.insert("nodeCount".into(), json!(node_count));
|
|
if post_process {
|
|
envelope.insert("postProcessed".into(), Value::Bool(true));
|
|
}
|
|
if !errors.is_empty() {
|
|
envelope.insert("errors".into(), Value::Array(errors));
|
|
}
|
|
let json = Value::Object(envelope).to_string();
|
|
|
|
let mut commands = ctx.commands;
|
|
match commands.len() {
|
|
0 => ToolOutcome::OkJson(json),
|
|
1 => ToolOutcome::OkJsonWithCommand(json, commands.remove(0)),
|
|
_ => ToolOutcome::OkJsonWithCommand(json, EditorCommand::Batch { commands }),
|
|
}
|
|
}
|
|
|
|
struct ProgramCtx {
|
|
sim: EditorState,
|
|
page_id: Option<String>,
|
|
/// binding name → final (host-assigned) node id.
|
|
bindings: BTreeMap<String, String>,
|
|
/// authored id → final id for remapped inserts, first-wins (TS
|
|
/// `findNodeInTree` finds the first match in tree order).
|
|
alias: BTreeMap<String, String>,
|
|
results: Vec<Value>,
|
|
commands: Vec<EditorCommand>,
|
|
post_process: bool,
|
|
/// Monotonic counter for `_auto_*` bindless-line bindings.
|
|
auto_seq: usize,
|
|
/// Index of the operation currently being executed.
|
|
current_line: usize,
|
|
/// Append G() lines whose result binding receives explicit positive
|
|
/// numeric width and height later in this same program.
|
|
explicitly_sized_append_lines: BTreeSet<usize>,
|
|
}
|
|
|
|
impl ProgramCtx {
|
|
/// Emit `cmd` AND apply it to the sim. The sim apply is the line's
|
|
/// final validation gate — the host will run the same code.
|
|
fn emit(&mut self, cmd: EditorCommand, failure: &str) -> Result<(), String> {
|
|
if !self.sim.apply(cmd.clone()) {
|
|
return Err(failure.to_string());
|
|
}
|
|
self.commands.push(cmd);
|
|
Ok(())
|
|
}
|
|
|
|
fn bind(&mut self, binding: &str, node_id: &str) {
|
|
self.bindings
|
|
.insert(binding.to_string(), node_id.to_string());
|
|
// A re-bound name (a redraft, or scratch reuse) updates its existing
|
|
// results entry in place — consumers look bindings up by FIRST match,
|
|
// which must never point at a superseded draft's deleted id.
|
|
if let Some(entry) = self
|
|
.results
|
|
.iter_mut()
|
|
.find(|r| r.get("binding").and_then(Value::as_str) == Some(binding))
|
|
{
|
|
entry["nodeId"] = json!(node_id);
|
|
} else {
|
|
self.results
|
|
.push(json!({ "binding": binding, "nodeId": node_id }));
|
|
}
|
|
}
|
|
|
|
/// Assign fresh sim-allocator ids to `nodes` (in place); returns
|
|
/// the (authored → final) map. Authored ids are recorded into the
|
|
/// alias table so slash paths keep resolving TS-style.
|
|
fn remap(&mut self, nodes: &mut [PenNode]) -> Result<Vec<(String, String)>, String> {
|
|
let mut seed = self
|
|
.sim
|
|
.next_node_id_seed()
|
|
.ok_or("node id space exhausted")?;
|
|
let mut taken = self.sim.collect_node_ids();
|
|
let map = remap_subtree_ids_mapping(nodes, &mut seed, &mut taken)
|
|
.ok_or("node id space exhausted")?;
|
|
for (old, new) in &map {
|
|
if !old.starts_with("__op_tmp_") {
|
|
self.alias.entry(old.clone()).or_insert_with(|| new.clone());
|
|
}
|
|
}
|
|
Ok(map)
|
|
}
|
|
}
|
|
|
|
/// TS `executeLine` — one DSL operation.
|
|
fn execute_line(line: &str, ctx: &mut ProgramCtx) -> Result<(), String> {
|
|
// TS line grammar (dotAll `s` flag — pretty-printed JSON bodies
|
|
// carry literal newlines inside the arg list):
|
|
// binding=OP(args) for I/C/K/R/M/G
|
|
// OP(args) for I/C/K/R/G (auto-binding) and U/D/M (call)
|
|
let assign = regex(r"(?s)^(\w+)\s*=\s*([ICKRMG])\((.+)\)$");
|
|
let bindless = regex(r"(?s)^([ICKRG])\((.+)\)$");
|
|
let call = regex(r"(?s)^([UDM])\((.+)\)$");
|
|
|
|
if let Some(c) = assign.captures(line) {
|
|
let binding = c.get(1).map_or("", |m| m.as_str()).to_string();
|
|
let op = c.get(2).map_or("", |m| m.as_str());
|
|
let args = c.get(3).map_or("", |m| m.as_str());
|
|
return execute_assign(op, &binding, args, ctx);
|
|
}
|
|
if let Some(c) = bindless.captures(line) {
|
|
let op = c.get(1).map_or("", |m| m.as_str());
|
|
let args = c.get(2).map_or("", |m| m.as_str());
|
|
// Numbered off a dedicated counter — `results.len()` stalls when a
|
|
// rebind updates its entry in place, and a stalled counter would hand
|
|
// two bindless lines the SAME auto name (turning the second into a
|
|
// phantom "redraft" of the first).
|
|
let binding = format!("_auto_{}_{op}", ctx.auto_seq);
|
|
ctx.auto_seq += 1;
|
|
return execute_assign(op, &binding, args, ctx);
|
|
}
|
|
if let Some(c) = call.captures(line) {
|
|
let op = c.get(1).map_or("", |m| m.as_str());
|
|
let args = c.get(2).map_or("", |m| m.as_str());
|
|
return match op {
|
|
"U" => execute_update(args, ctx),
|
|
"D" => execute_delete(args, ctx),
|
|
"M" => execute_move(args, ctx).map(|_| ()),
|
|
_ => unreachable!(),
|
|
};
|
|
}
|
|
Err(format!("Cannot parse operation: {line}"))
|
|
}
|
|
|
|
/// Return append-G line indexes that are robustly sized by later U() calls in
|
|
/// the same program. Parsing uses the DSL's top-level delimiter rules, so an
|
|
/// '=' inside a quoted image prompt is never mistaken for a result binding.
|
|
fn explicitly_sized_append_lines(lines: &[String]) -> BTreeSet<usize> {
|
|
let mut sized = BTreeSet::new();
|
|
for (index, line) in lines.iter().enumerate() {
|
|
let Some((Some(binding), 'G', args)) = parsed_operation(line) else {
|
|
continue;
|
|
};
|
|
let parts = split_top_level_args(args);
|
|
let is_append = parts.len() == 4
|
|
&& matches!(
|
|
serde_json::from_str::<String>(parts[3].trim()),
|
|
Ok(placement) if placement == "append"
|
|
);
|
|
if !is_append {
|
|
continue;
|
|
}
|
|
|
|
let mut width_is_positive_number = false;
|
|
let mut height_is_positive_number = false;
|
|
for later in &lines[index + 1..] {
|
|
let Some((later_binding, op, later_args)) = parsed_operation(later) else {
|
|
continue;
|
|
};
|
|
// Rebinding closes this append's sizing window. A U() beyond it
|
|
// would target the newer node, not this image.
|
|
if later_binding == Some(binding) {
|
|
break;
|
|
}
|
|
if op != 'U' {
|
|
continue;
|
|
}
|
|
let Some(comma) = find_top_level_char(later_args, ',') else {
|
|
continue;
|
|
};
|
|
let target = strip_outer_quotes(later_args[..comma].trim());
|
|
if target != binding {
|
|
continue;
|
|
}
|
|
let Ok(value) = parse_json_arg(&later_args[comma + 1..]) else {
|
|
continue;
|
|
};
|
|
let Some(patch) = value.as_object() else {
|
|
continue;
|
|
};
|
|
if let Some(width) = patch.get("width") {
|
|
width_is_positive_number = positive_json_number(width);
|
|
}
|
|
if let Some(height) = patch.get("height") {
|
|
height_is_positive_number = positive_json_number(height);
|
|
}
|
|
}
|
|
if width_is_positive_number && height_is_positive_number {
|
|
sized.insert(index);
|
|
}
|
|
}
|
|
sized
|
|
}
|
|
|
|
/// Parse one complete DSL operation without splitting on delimiters nested in
|
|
/// calls or quoted strings. Returns `(binding, opcode, argument body)`.
|
|
fn parsed_operation(line: &str) -> Option<(Option<&str>, char, &str)> {
|
|
let line = line.trim().trim_end_matches(';').trim();
|
|
let (binding, call) = match find_top_level_char(line, '=') {
|
|
Some(eq) => {
|
|
let binding = line[..eq].trim();
|
|
if binding.is_empty()
|
|
|| !binding
|
|
.chars()
|
|
.all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
|
|
{
|
|
return None;
|
|
}
|
|
(Some(binding), line[eq + 1..].trim())
|
|
}
|
|
None => (None, line),
|
|
};
|
|
let mut chars = call.chars();
|
|
let op = chars.next()?;
|
|
let rest = chars.as_str();
|
|
if !rest.starts_with('(') || !call.ends_with(')') {
|
|
return None;
|
|
}
|
|
Some((binding, op, &rest[1..rest.len() - 1]))
|
|
}
|
|
|
|
fn positive_json_number(value: &Value) -> bool {
|
|
value
|
|
.as_f64()
|
|
.is_some_and(|number| number.is_finite() && number > 0.0)
|
|
}
|
|
|
|
fn execute_assign(op: &str, binding: &str, args: &str, ctx: &mut ProgramCtx) -> Result<(), String> {
|
|
match op {
|
|
"I" => execute_insert(binding, args, ctx),
|
|
"C" => execute_copy(binding, args, ctx),
|
|
"K" => execute_kit_instantiate(binding, args, ctx),
|
|
"R" => execute_replace(binding, args, ctx),
|
|
"G" => execute_image(binding, args, ctx),
|
|
"M" => {
|
|
let node_id = execute_move(args, ctx)?;
|
|
ctx.bind(binding, &node_id);
|
|
Ok(())
|
|
}
|
|
_ => unreachable!(),
|
|
}
|
|
}
|
|
|
|
/// `binding=I(parent, data)` — insert a (possibly nested) node.
|
|
fn execute_insert(binding: &str, args: &str, ctx: &mut ProgramCtx) -> Result<(), String> {
|
|
let comma = find_top_level_char(args, ',').ok_or("Insert requires parent and node data")?;
|
|
let parent_raw = args[..comma].trim();
|
|
let parent = if parent_raw == "null" {
|
|
None
|
|
} else {
|
|
Some(resolve_ref(parent_raw, &ctx.bindings))
|
|
};
|
|
let mut node = parse_node_json(&args[comma + 1..], ctx.post_process)?;
|
|
delete_superseded_draft(binding, parent.as_deref(), &node, ctx);
|
|
|
|
// TS auto-replace: a root-level frame insert replaces the first
|
|
// EMPTY root frame (inheriting its x/y) instead of siblinging it.
|
|
let mut pre_commands: Vec<(EditorCommand, &str)> = Vec::new();
|
|
if parent.is_none() && matches!(node, PenNode::Frame(_)) {
|
|
if let Some(empty) = first_empty_frame(ctx.sim.active_children()) {
|
|
let (id, x, y) = (empty.id_str().to_string(), empty.base().x, empty.base().y);
|
|
if x.is_some() {
|
|
node.base_mut().x = x;
|
|
}
|
|
if y.is_some() {
|
|
node.base_mut().y = y;
|
|
}
|
|
pre_commands.push((
|
|
EditorCommand::DeleteNode {
|
|
node_id: NodeId::new(id),
|
|
page_id: ctx.page_id.clone(),
|
|
},
|
|
"failed to replace the empty root frame",
|
|
));
|
|
}
|
|
}
|
|
|
|
let mut nodes = vec![node];
|
|
let map = ctx.remap(&mut nodes)?;
|
|
let root_id = map
|
|
.first()
|
|
.map(|(_, new)| new.clone())
|
|
.ok_or("Insert produced no node")?;
|
|
for (cmd, failure) in pre_commands {
|
|
ctx.emit(cmd, failure)?;
|
|
}
|
|
// Hoist node-level `state` as a SIBLING command — the program
|
|
// finisher batches ctx.commands itself; wrapping here would nest
|
|
// Batches, which apply rejects. Held until the insert below
|
|
// succeeds: emitting it first would leak an orphan `$app` state
|
|
// command into `ctx.commands` if the insert then fails (the line
|
|
// as a whole errors and is dropped, but a prior `ctx.emit` already
|
|
// recorded — state from a line that never landed must not ship).
|
|
let merge = super::batch_design::hoist_generation_state(&mut nodes);
|
|
ctx.emit(
|
|
EditorCommand::InsertAuthoredSubtree {
|
|
nodes,
|
|
parent_id: parent_node_id(parent.as_deref()),
|
|
page_id: ctx.page_id.clone(),
|
|
},
|
|
&format!(
|
|
"Insert parent not found or not a container: {}",
|
|
parent.as_deref().unwrap_or("null")
|
|
),
|
|
)?;
|
|
if let Some(merge) = merge {
|
|
// Emit AFTER the insert succeeds (a failed line must not leak
|
|
// orphan $app state), then swap the two recorded commands so
|
|
// the batch still carries MergeAppState before its insert.
|
|
// Sim-apply order between the two is immaterial: merge touches
|
|
// only doc.state, the insert only the tree. (If this merge emit
|
|
// itself ever failed post-insert, the line would error after
|
|
// its insert already landed — state dropped but never
|
|
// orphaned; an additive MergeAppState on the sim can't
|
|
// realistically fail.)
|
|
ctx.emit(merge, "merge generated app state")?;
|
|
let n = ctx.commands.len();
|
|
ctx.commands.swap(n - 1, n - 2);
|
|
}
|
|
ctx.bind(binding, &root_id);
|
|
Ok(())
|
|
}
|
|
|
|
/// `binding=C(sourceId, parent[, overrides])` — clone with fresh ids.
|
|
fn execute_copy(binding: &str, args: &str, ctx: &mut ProgramCtx) -> Result<(), String> {
|
|
let first = find_top_level_char(args, ',').ok_or("Copy requires sourceId, parent, and data")?;
|
|
let source_raw = args[..first].trim();
|
|
let rest = args[first + 1..].trim();
|
|
let (parent_raw, data_str) = match find_top_level_char(rest, ',') {
|
|
None => (rest, "{}"),
|
|
Some(second) => (rest[..second].trim(), rest[second + 1..].trim()),
|
|
};
|
|
let source_id = lookup_id(&resolve_ref(source_raw, &ctx.bindings), &ctx.alias);
|
|
let Some(source) =
|
|
op_editor_core::walkers::find_node(ctx.sim.active_children(), &NodeId::new(&source_id))
|
|
else {
|
|
return Err(format!("Copy source not found: {source_id}"));
|
|
};
|
|
let mut cloned_value =
|
|
serde_json::to_value(source).map_err(|e| format!("Copy source unserializable: {e}"))?;
|
|
// TS `Object.assign(cloned, data)` minus `id` (never overridden)
|
|
// and `descendants` (a TS no-op — see module docs).
|
|
let overrides = parse_json_arg(data_str)?;
|
|
let Some(overrides) = overrides.as_object() else {
|
|
return Err("C() overrides JSON must be an object".into());
|
|
};
|
|
if let Some(obj) = cloned_value.as_object_mut() {
|
|
for (key, value) in overrides {
|
|
if key == "id" || key == "descendants" {
|
|
continue;
|
|
}
|
|
obj.insert(key.clone(), value.clone());
|
|
}
|
|
}
|
|
normalize_node_shape(&mut cloned_value);
|
|
let mut node: PenNode = serde_json::from_value(cloned_value)
|
|
.map_err(|e| format!("C() overrides produce an invalid node: {e}"))?;
|
|
if ctx.post_process {
|
|
let _ = op_editor_core::command_refine::refine_subtree(&mut node);
|
|
}
|
|
|
|
let mut nodes = vec![node];
|
|
// Cloning mints fresh ids for the whole subtree
|
|
// (`cloneNodeWithNewIds` parity); the clone's old ids are the LIVE
|
|
// source ids, so they must NOT enter the alias table — `remap`'s
|
|
// first-wins entry never fires because the source ids already
|
|
// resolve directly in the sim tree.
|
|
let map = ctx.remap(&mut nodes)?;
|
|
let clone_id = map
|
|
.first()
|
|
.map(|(_, new)| new.clone())
|
|
.ok_or("Copy produced no node")?;
|
|
let parent = if parent_raw == "null" {
|
|
None
|
|
} else {
|
|
Some(resolve_ref(parent_raw, &ctx.bindings))
|
|
};
|
|
ctx.emit(
|
|
EditorCommand::InsertAuthoredSubtree {
|
|
nodes,
|
|
parent_id: parent_node_id(parent.as_deref()),
|
|
page_id: ctx.page_id.clone(),
|
|
},
|
|
&format!(
|
|
"Copy parent not found or not a container: {}",
|
|
parent.as_deref().unwrap_or("null")
|
|
),
|
|
)?;
|
|
ctx.bind(binding, &clone_id);
|
|
Ok(())
|
|
}
|
|
|
|
/// `binding=K("kit/component", parent[, overrides])` — instantiate a
|
|
/// built-in UI-kit component. Model-facing ids are compact aliases:
|
|
/// `starter/<component-id>` maps to kit `openpencil-starter`, and
|
|
/// `shadcn/<short-id>` maps to kit `shadcn-ui` with component
|
|
/// `shadcn-<short-id>` (for example `shadcn/btn-primary` →
|
|
/// `shadcn-ui` / `shadcn-btn-primary`). Exact `<kit-id>/<component-id>`
|
|
/// pairs are also accepted for imported/future kits.
|
|
fn execute_kit_instantiate(binding: &str, args: &str, ctx: &mut ProgramCtx) -> Result<(), String> {
|
|
let parts = split_top_level_args(args);
|
|
if !(2..=3).contains(&parts.len()) {
|
|
return Err("K() requires kitComponentId, parent, and optional overrides".into());
|
|
}
|
|
let kit_component_id = parse_string_arg(parts[0].trim(), "K() kitComponentId")?;
|
|
let (kit_id, component_id) = resolve_kit_component_id(&kit_component_id, &ctx.sim)?;
|
|
let parent_raw = parts[1].trim();
|
|
let parent = if matches!(parent_raw, "null" | "undefined") {
|
|
NodeId::NONE
|
|
} else {
|
|
let resolved = resolve_ref(parent_raw, &ctx.bindings);
|
|
NodeId::new(lookup_id(&resolved, &ctx.alias))
|
|
};
|
|
let overrides_json = match parts.get(2) {
|
|
None => None,
|
|
Some(raw) => {
|
|
let value = parse_json_arg(raw)?;
|
|
if !value.is_object() {
|
|
return Err("K() overrides JSON must be an object".into());
|
|
}
|
|
Some(value.to_string())
|
|
}
|
|
};
|
|
ctx.emit(
|
|
EditorCommand::InstantiateKitComponent {
|
|
kit_id,
|
|
component_id,
|
|
doc_x: None,
|
|
doc_y: None,
|
|
target_parent: parent,
|
|
page_id: ctx.page_id.clone(),
|
|
overrides_json,
|
|
},
|
|
"K() kit/component not found, parent is not a container, or overrides are invalid",
|
|
)?;
|
|
let node_id = ctx.sim.selection.anchor.clone();
|
|
if !node_id.is_real()
|
|
|| op_editor_core::walkers::find_node(ctx.sim.active_children(), &node_id).is_none()
|
|
{
|
|
return Err("K() did not produce a selected node".into());
|
|
}
|
|
ctx.bind(binding, node_id.as_str());
|
|
Ok(())
|
|
}
|
|
|
|
/// `binding=R(path, data)` — replace the node at `path` with a fresh
|
|
/// node built from `data` (same slot, fresh id, children dropped).
|
|
fn execute_replace(binding: &str, args: &str, ctx: &mut ProgramCtx) -> Result<(), String> {
|
|
let comma = find_top_level_char(args, ',').ok_or("Replace requires path and node data")?;
|
|
let path_raw = args[..comma].trim();
|
|
let path = resolve_path_expr(path_raw, &ctx.bindings);
|
|
let Some(old) = find_node_by_path(ctx.sim.active_children(), &path, &ctx.alias) else {
|
|
return Err(format!("Replace target not found: {path}"));
|
|
};
|
|
let old_id = old.id_str().to_string();
|
|
let mut node = parse_node_json(&args[comma + 1..], ctx.post_process)?;
|
|
// Drain node-level `state` BEFORE the probe clone; hold the merge
|
|
// and emit it only after the replace below succeeds (emit applies
|
|
// immediately — emitting the merge first would leak an orphan
|
|
// `$app` state command into `ctx.commands` if the replace then
|
|
// fails; state from a line that never landed must not ship).
|
|
let merge = super::batch_design::hoist_generation_state(std::slice::from_mut(&mut node));
|
|
|
|
// Predict the ids `cmd_replace_subtree` will assign: it remaps off
|
|
// the live seed/taken BEFORE removing the old subtree — identical
|
|
// inputs to this sim-side dry run, so the mapping is identical.
|
|
let mut probe = vec![node.clone()];
|
|
let map = ctx.remap(&mut probe)?;
|
|
let new_id = map
|
|
.first()
|
|
.map(|(_, new)| new.clone())
|
|
.ok_or("Replace produced no node")?;
|
|
ctx.emit(
|
|
EditorCommand::ReplaceSubtree {
|
|
node_id: NodeId::new(&old_id),
|
|
node: Box::new(node),
|
|
drop_children: true,
|
|
page_id: ctx.page_id.clone(),
|
|
},
|
|
&format!("Replace failed for: {path}"),
|
|
)?;
|
|
if let Some(merge) = merge {
|
|
// Emit AFTER the replace succeeds (a failed line must not leak
|
|
// orphan $app state), then swap the two recorded commands so
|
|
// the batch still carries MergeAppState before its replace.
|
|
// Sim-apply order between the two is immaterial: merge touches
|
|
// only doc.state, the replace only the tree. (If this merge
|
|
// emit itself ever failed post-replace, the line would error
|
|
// after its replace already landed — state dropped but never
|
|
// orphaned; an additive MergeAppState on the sim can't
|
|
// realistically fail.)
|
|
ctx.emit(merge, "merge generated app state")?;
|
|
let n = ctx.commands.len();
|
|
ctx.commands.swap(n - 1, n - 2);
|
|
}
|
|
ctx.bind(binding, &new_id);
|
|
Ok(())
|
|
}
|
|
|
|
/// `binding=G(parent, mode, prompt[, placement])` — emit an image node. The
|
|
/// parent accepts a binding or quoted existing id (`null` is rejected because
|
|
/// both placements require a concrete target); `mode` must be `search` or
|
|
/// `generate`. Placement defaults to `slot`; the explicit `append` escape
|
|
/// hatch allows a new sibling only under a horizontal/vertical flow parent.
|
|
/// No fetcher at this layer — `src` stays empty (browser-caller parity);
|
|
/// the host's own image pipeline enriches later.
|
|
fn execute_image(binding: &str, args: &str, ctx: &mut ProgramCtx) -> Result<(), String> {
|
|
let parts = split_top_level_args(args);
|
|
if !matches!(parts.len(), 3 | 4) {
|
|
return Err(format!("Invalid G() syntax: {args}"));
|
|
}
|
|
let parent_raw = parts[0].trim();
|
|
let parent = if matches!(parent_raw, "null" | "undefined" | "0" | "\"\"" | "\"0\"") {
|
|
String::new()
|
|
} else {
|
|
resolve_path_expr(parent_raw, &ctx.bindings)
|
|
};
|
|
let mode = serde_json::from_str::<String>(parts[1].trim())
|
|
.map_err(|_| format!("Invalid G() syntax: {args}"))?;
|
|
if !matches!(mode.as_str(), "search" | "generate") {
|
|
return Err(format!("G() mode must be search or generate: {mode}"));
|
|
}
|
|
let prompt = serde_json::from_str::<String>(parts[2].trim())
|
|
.map_err(|_| format!("Invalid G() syntax: {args}"))?;
|
|
let placement = match parts.get(3) {
|
|
None => "slot".to_string(),
|
|
Some(raw) => serde_json::from_str::<String>(raw.trim())
|
|
.map_err(|_| format!("Invalid G() syntax: {args}"))?,
|
|
};
|
|
if !matches!(placement.as_str(), "slot" | "append") {
|
|
return Err(format!(
|
|
"G() placement must be \"slot\" or \"append\", got {placement:?}"
|
|
));
|
|
}
|
|
let name: String = prompt.chars().take(40).collect();
|
|
let mut value = json!({
|
|
"type": "image",
|
|
"id": "__op_tmp_image_1",
|
|
"name": name,
|
|
"src": "",
|
|
"objectFit": "crop",
|
|
"width": 400,
|
|
"height": 300
|
|
});
|
|
if mode == "generate" {
|
|
value["imagePrompt"] = json!(prompt);
|
|
} else {
|
|
value["imageSearchQuery"] = json!(prompt);
|
|
}
|
|
if parent.trim().is_empty() || parent.trim() == "0" {
|
|
return Err(format!(
|
|
"G() placement {placement:?} requires an explicit frame/rectangle target id; create the target first instead of using null"
|
|
));
|
|
}
|
|
let target = find_node_by_path(ctx.sim.active_children(), &parent, &ctx.alias)
|
|
.ok_or_else(|| format!("G() parent not found or not a container: {parent}"))?;
|
|
// `parent` may be a slash path or an authored id that `find_node_by_path`
|
|
// translated through `ctx.alias`. The emitted insert must target the live
|
|
// resolved node id, never the caller's path/alias spelling.
|
|
let target_id = target.id_str().to_string();
|
|
let container = node_container(target)
|
|
.ok_or_else(|| format!("G() parent not found or not a container: {parent}"))?;
|
|
// Placement is an explicit structural contract. Slot-fill accepts only an
|
|
// EMPTY target; append accepts only an explicitly-authored flow parent.
|
|
// Never recover intent from names, dimensions, child kinds, or position.
|
|
match placement.as_str() {
|
|
"slot" => {
|
|
let child_ids = target
|
|
.children()
|
|
.into_iter()
|
|
.flatten()
|
|
.map(PenNode::id_str)
|
|
.collect::<Vec<_>>();
|
|
if !child_ids.is_empty() {
|
|
return Err(format!(
|
|
"G() slot target {} must be empty, but it has children [{}]. Pass the exact empty frame/rectangle slot id; use \"append\" only for an intentional child of an explicit horizontal/vertical flow parent",
|
|
target.id_str(),
|
|
child_ids.join(", ")
|
|
));
|
|
}
|
|
}
|
|
"append" => {
|
|
if explicit_flow_layout(container).is_none() {
|
|
return Err(format!(
|
|
"G() append target {} must declare layout \"horizontal\" or \"vertical\"; got {}. Append means a new flow sibling and is never an absolute overlay",
|
|
target.id_str(),
|
|
layout_label(container)
|
|
));
|
|
}
|
|
if !ctx
|
|
.explicitly_sized_append_lines
|
|
.contains(&ctx.current_line)
|
|
{
|
|
return Err(
|
|
"G() append requires a result binding followed in the same batch by U(binding, {\"width\": <positive number>, \"height\": <positive number>}); refusing an unsized flow child with default fill_container width and height"
|
|
.into(),
|
|
);
|
|
}
|
|
}
|
|
_ => unreachable!("placement validated above"),
|
|
}
|
|
if matches!(container.layout, Some(LayoutMode::None)) {
|
|
let has_declared_size = container.width.is_some() && container.height.is_some();
|
|
let resolved = has_declared_size
|
|
.then(|| resolved_node_size(&ctx.sim, &target_id))
|
|
.flatten();
|
|
let width = target.width_px().or_else(|| resolved.map(|size| size.0));
|
|
let height = target.height_px().or_else(|| resolved.map(|size| size.1));
|
|
let (Some(width), Some(height)) = (width, height) else {
|
|
return Err(format!(
|
|
"G() target {target_id} uses layout none, so it needs declared width and height that resolve above zero before an image can fill it"
|
|
));
|
|
};
|
|
if width <= 0.0 || height <= 0.0 {
|
|
return Err(format!(
|
|
"G() target {target_id} uses layout none, so it needs declared width and height that resolve above zero before an image can fill it"
|
|
));
|
|
}
|
|
value["x"] = json!(0);
|
|
value["y"] = json!(0);
|
|
value["width"] = json!(width);
|
|
value["height"] = json!(height);
|
|
} else {
|
|
// A G() image serves its target slot; its intrinsic/search size is
|
|
// not evidence for card geometry. Flex sizing keeps the image
|
|
// inside the slot and lets crop/object-fit do the visual work.
|
|
value["width"] = json!("fill_container");
|
|
value["height"] = json!("fill_container");
|
|
}
|
|
let node: PenNode =
|
|
serde_json::from_value(value).map_err(|e| format!("invalid G() image node: {e}"))?;
|
|
let mut nodes = vec![node];
|
|
let map = ctx.remap(&mut nodes)?;
|
|
let image_id = map
|
|
.first()
|
|
.map(|(_, new)| new.clone())
|
|
.ok_or("G() produced no node")?;
|
|
ctx.emit(
|
|
EditorCommand::InsertAuthoredSubtree {
|
|
nodes,
|
|
parent_id: NodeId::new(&target_id),
|
|
page_id: ctx.page_id.clone(),
|
|
},
|
|
&format!("G() parent not found or not a container: {parent}"),
|
|
)?;
|
|
ctx.bind(binding, &image_id);
|
|
Ok(())
|
|
}
|
|
|
|
fn explicit_flow_layout(container: &ContainerProps) -> Option<&'static str> {
|
|
match container.layout {
|
|
Some(LayoutMode::Horizontal) => Some("horizontal"),
|
|
Some(LayoutMode::Vertical) => Some("vertical"),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn layout_label(container: &ContainerProps) -> &'static str {
|
|
match container.layout {
|
|
Some(LayoutMode::Horizontal) => "horizontal",
|
|
Some(LayoutMode::Vertical) => "vertical",
|
|
Some(LayoutMode::None) => "none",
|
|
None => "omitted",
|
|
}
|
|
}
|
|
|
|
fn node_container(node: &PenNode) -> Option<&ContainerProps> {
|
|
match node {
|
|
PenNode::Frame(node) => Some(&node.container),
|
|
PenNode::Group(node) => Some(&node.container),
|
|
PenNode::Rectangle(node) => Some(&node.container),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn resolved_node_size(state: &EditorState, node_id: &str) -> Option<(f64, f64)> {
|
|
fn find(nodes: &[SceneNode], node_id: &str) -> Option<(f64, f64)> {
|
|
for node in nodes {
|
|
if node.id == node_id {
|
|
let bounds = node.aggregate_bounds();
|
|
return Some((f64::from(bounds.size.x), f64::from(bounds.size.y)));
|
|
}
|
|
if let Some(size) = find(&node.children, node_id) {
|
|
return Some(size);
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
let scene = op_pen_loader::editor_state_to_layout_scene(state);
|
|
scene
|
|
.pages
|
|
.iter()
|
|
.find_map(|page| find(&page.children, node_id))
|
|
}
|
|
|
|
/// `U(path, data)` — shallow-patch the node at `path`. No result entry
|
|
/// (TS call-form ops don't push results).
|
|
fn execute_update(args: &str, ctx: &mut ProgramCtx) -> Result<(), String> {
|
|
let comma = find_top_level_char(args, ',').ok_or("Update requires path and update data")?;
|
|
let path = resolve_path_expr(args[..comma].trim(), &ctx.bindings);
|
|
let mut value = parse_json_arg(&args[comma + 1..])?;
|
|
normalize_node_shape(&mut value);
|
|
let Some(target) = find_node_by_path(ctx.sim.active_children(), &path, &ctx.alias) else {
|
|
return Err(format!("Update target not found: {path}"));
|
|
};
|
|
let node_id = NodeId::new(target.id_str());
|
|
let cmd = update_command_from_value(node_id, &value)?;
|
|
let cmd = with_page_id(cmd, ctx.page_id.clone());
|
|
ctx.emit(cmd, &format!("Update failed for: {path}"))
|
|
}
|
|
|
|
/// `D(ref)` — delete. TS `removeNodeFromTree` silently no-ops on an
|
|
/// unknown id: no error, no result.
|
|
fn execute_delete(args: &str, ctx: &mut ProgramCtx) -> Result<(), String> {
|
|
let raw = strip_outer_quotes(args.trim());
|
|
let node_id = lookup_id(&resolve_ref(&raw, &ctx.bindings), &ctx.alias);
|
|
if op_editor_core::walkers::find_node(ctx.sim.active_children(), &NodeId::new(&node_id))
|
|
.is_none()
|
|
{
|
|
return Ok(());
|
|
}
|
|
ctx.emit(
|
|
EditorCommand::DeleteNode {
|
|
node_id: NodeId::new(&node_id),
|
|
page_id: ctx.page_id.clone(),
|
|
},
|
|
&format!("Delete failed for: {node_id}"),
|
|
)
|
|
}
|
|
|
|
/// `M(nodeId, parent[, index])` (call or bound form) — reparent.
|
|
/// Returns the moved node's id so the bound form can record it.
|
|
fn execute_move(args: &str, ctx: &mut ProgramCtx) -> Result<String, String> {
|
|
let parts = split_top_level_args(args);
|
|
if parts.len() < 2 {
|
|
return Err("Move requires nodeId and parent".into());
|
|
}
|
|
let node_id = lookup_id(&resolve_ref(parts[0].trim(), &ctx.bindings), &ctx.alias);
|
|
if op_editor_core::walkers::find_node(ctx.sim.active_children(), &NodeId::new(&node_id))
|
|
.is_none()
|
|
{
|
|
return Err(format!("Move target not found: {node_id}"));
|
|
}
|
|
let parent_raw = parts[1].trim();
|
|
let parent = if matches!(parent_raw, "null" | "undefined") {
|
|
None
|
|
} else {
|
|
Some(resolve_ref(parent_raw, &ctx.bindings))
|
|
};
|
|
let index = match parts.get(2) {
|
|
None => None,
|
|
Some(raw) => Some(
|
|
strip_outer_quotes(raw.trim())
|
|
.parse::<usize>()
|
|
.map_err(|_| format!("M() index must be a non-negative integer, got {raw:?}"))?,
|
|
),
|
|
};
|
|
ctx.emit(
|
|
EditorCommand::MoveNode {
|
|
node_id: NodeId::new(&node_id),
|
|
target_parent: parent_node_id(parent.as_deref()),
|
|
page_id: ctx.page_id.clone(),
|
|
index,
|
|
},
|
|
&format!("Move failed for: {node_id}"),
|
|
)?;
|
|
Ok(node_id)
|
|
}
|
|
|
|
/// A RE-USED binding whose previous node sits under the SAME parent with the
|
|
/// same type and (non-empty) name is a REDRAFT, not a new node: a weak model
|
|
/// deliberating in-channel re-emits its section several times ("Let me
|
|
/// redo…"), and appending every draft shipped SEVEN stacked navbars from one
|
|
/// response (measured: minimax-m3, test0703-m3.op). Delete the superseded
|
|
/// draft before the re-insert so the LAST draft wins. Scratch-style binding
|
|
/// reuse (`t=I(cardA, …)` then `t=I(cardB, …)` — different parent, or unnamed
|
|
/// leaves) keeps appending exactly as before; all four gates must agree
|
|
/// before anything is removed. A previous draft already gone (its ancestor
|
|
/// was itself redrafted) is skipped by the sim-apply guard.
|
|
fn delete_superseded_draft(
|
|
binding: &str,
|
|
parent: Option<&str>,
|
|
node: &PenNode,
|
|
ctx: &mut ProgramCtx,
|
|
) {
|
|
let Some(new_name) = node.base().name.as_deref().filter(|n| !n.is_empty()) else {
|
|
return;
|
|
};
|
|
let Some(prev_id) = ctx.bindings.get(binding).cloned() else {
|
|
return;
|
|
};
|
|
let parent_id = parent.map(|p| lookup_id(p, &ctx.alias));
|
|
let Some((prev, prev_parent)) =
|
|
find_node_with_parent(ctx.sim.active_children(), &prev_id, None)
|
|
else {
|
|
return;
|
|
};
|
|
let same_shape = std::mem::discriminant(prev) == std::mem::discriminant(node)
|
|
&& prev.base().name.as_deref() == Some(new_name);
|
|
if !same_shape || prev_parent != parent_id.as_deref() {
|
|
return;
|
|
}
|
|
let del = EditorCommand::DeleteNode {
|
|
node_id: NodeId::new(&prev_id),
|
|
page_id: ctx.page_id.clone(),
|
|
};
|
|
if ctx.sim.apply(del.clone()) {
|
|
ctx.commands.push(del);
|
|
}
|
|
}
|
|
|
|
/// Locate `id` in the forest and return it together with its parent's id
|
|
/// (`None` = document root).
|
|
fn find_node_with_parent<'a>(
|
|
nodes: &'a [PenNode],
|
|
id: &str,
|
|
parent: Option<&'a str>,
|
|
) -> Option<(&'a PenNode, Option<&'a str>)> {
|
|
for n in nodes {
|
|
if n.id_str() == id {
|
|
return Some((n, parent));
|
|
}
|
|
if let Some(children) = n.children() {
|
|
if let Some(hit) = find_node_with_parent(children, id, Some(n.id_str())) {
|
|
return Some(hit);
|
|
}
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
// --- Node JSON --------------------------------------------------------
|
|
|
|
/// Parse + normalize an I()/R() node body into a `PenNode` with
|
|
/// authored ids filled in (the caller remaps them to final ids).
|
|
fn parse_node_json(raw: &str, post_process: bool) -> Result<PenNode, String> {
|
|
let mut value = parse_json_arg(raw)?;
|
|
if !value.is_object() {
|
|
return Err("node data must be a JSON object".into());
|
|
}
|
|
normalize_node_shape(&mut value);
|
|
let mut tmp = 1usize;
|
|
ensure_node_ids(&mut value, &mut tmp);
|
|
let mut node: PenNode =
|
|
serde_json::from_value(value).map_err(|e| format!("invalid PenNode payload: {e}"))?;
|
|
if post_process {
|
|
// TS postProcess hooks (emoji strip, unique ids, layout-child
|
|
// position sanitize, screen-bounds clamp) — the deterministic
|
|
// subset shipped in `command_refine.rs`.
|
|
let _ = op_editor_core::command_refine::refine_subtree(&mut node);
|
|
}
|
|
Ok(node)
|
|
}
|
|
|
|
/// TS `parseJsonArg` — strict JSON first, then the lenient agent-typo
|
|
/// pipeline: quote unquoted keys, single→double quote delimiters,
|
|
/// strip empty-key artifacts and trailing commas.
|
|
fn parse_json_arg(raw: &str) -> Result<Value, String> {
|
|
let trimmed = raw.trim();
|
|
if let Ok(value) = serde_json::from_str::<Value>(trimmed) {
|
|
return Ok(value);
|
|
}
|
|
// `(?<=\{|,)\s*(\w+)\s*:` has a lookbehind; the Rust regex crate
|
|
// doesn't support those, so capture + reinsert the brace/comma.
|
|
let mut normalized = regex(r"([{,])\s*(\w+)\s*:")
|
|
.replace_all(trimmed, "$1 \"$2\":")
|
|
.into_owned();
|
|
normalized = replace_single_quote_delimiters(&normalized);
|
|
// Repair the common weak-model typo where a string value's closing quote
|
|
// fuses with the trailing comma — `"k":"700,"next":...` meant
|
|
// `"k":"700","next":...`. Anchored to a following `"<word>"` so it only
|
|
// fires when the next token looks like a new key (a valid `"a","b"` has the
|
|
// value's own close-quote before the comma and never matches).
|
|
normalized = regex(r#":\s*"([^"]*),"(\w+)""#)
|
|
.replace_all(&normalized, r#":"${1}","${2}""#)
|
|
.into_owned();
|
|
// Repair a string value missing its OPENING quote — `"width":fill_container"`
|
|
// meant `"width":"fill_container"` (weak model dropped the leading `"`).
|
|
// Anchored: a letter-led bareword that ENDS with a `"` right after a colon
|
|
// (a valid value starts WITH the quote, so `:"x"` never matches; bare
|
|
// `true`/`false`/`null` aren't followed by a `"` so they never match either).
|
|
normalized = regex(r#":(\s*)([A-Za-z][\w-]*)""#)
|
|
.replace_all(&normalized, r#":${1}"${2}""#)
|
|
.into_owned();
|
|
// Repair a FULLY-unquoted string value — `"width":fill_container_str,` meant
|
|
// `"width":"fill_container_str"` (a weak model emitted a bare identifier, e.g.
|
|
// a leaked JS variable name). A letter-led bareword between a colon and a
|
|
// `,`/`}`/`]`. Numbers are digit-led (never match); a real quoted value
|
|
// starts with `"` (never matches); `true`/`false`/`null` get re-unquoted next.
|
|
normalized = regex(r#":(\s*)([A-Za-z][\w-]*)(\s*[,}\]])"#)
|
|
.replace_all(&normalized, r#":${1}"${2}"${3}"#)
|
|
.into_owned();
|
|
normalized = regex(r#":(\s*)"(true|false|null)"(\s*[,}\]])"#)
|
|
.replace_all(&normalized, r#":${1}${2}${3}"#)
|
|
.into_owned();
|
|
normalized = regex(r#",\s*""\s*:\s*[^,}\]]+"#)
|
|
.replace_all(&normalized, "")
|
|
.into_owned();
|
|
normalized = regex(r",(\s*[}\]])")
|
|
.replace_all(&normalized, "$1")
|
|
.into_owned();
|
|
// Last resort: a weak model commonly drops the trailing closing brace(s) of a
|
|
// node — especially one with a nested object like `"stroke":{"thickness":{...}`
|
|
// — leaving `{...}` short a `}`. When that node is the program's ROOT binding,
|
|
// the parse failure cascades (every child's `I(rootBinding, ...)` then can't
|
|
// find its parent). Auto-closing the unbalanced brackets recovers it.
|
|
serde_json::from_str(&normalized)
|
|
.or_else(|_| serde_json::from_str(&close_unbalanced_brackets(&normalized)))
|
|
.map_err(|e| {
|
|
let snippet: String = raw.chars().take(300).collect();
|
|
let ellipsis = if raw.chars().count() > 300 { "..." } else { "" };
|
|
format!("Failed to parse JSON ({e}): {snippet}{ellipsis}")
|
|
})
|
|
}
|
|
|
|
fn parse_string_arg(raw: &str, label: &str) -> Result<String, String> {
|
|
let value = parse_json_arg(raw)?;
|
|
value
|
|
.as_str()
|
|
.map(str::to_string)
|
|
.ok_or_else(|| format!("{label} must be a JSON string"))
|
|
}
|
|
|
|
fn resolve_kit_component_id(raw: &str, state: &EditorState) -> Result<(String, String), String> {
|
|
let Some((kit_part, component_part)) = raw.split_once('/') else {
|
|
return Err(
|
|
"K() kitComponentId must be starter/<id>, shadcn/<id>, or <kit-id>/<component-id>"
|
|
.into(),
|
|
);
|
|
};
|
|
let kit_id = match kit_part {
|
|
"starter" => "openpencil-starter".to_string(),
|
|
"shadcn" => "shadcn-ui".to_string(),
|
|
other => other.to_string(),
|
|
};
|
|
let component_id = if kit_id == "shadcn-ui" && !component_part.starts_with("shadcn-") {
|
|
format!("shadcn-{component_part}")
|
|
} else {
|
|
component_part.to_string()
|
|
};
|
|
let Some(kit) = state.ui_kits.iter().find(|kit| kit.id == kit_id) else {
|
|
return Err(format!("K() kit not found: {kit_part}"));
|
|
};
|
|
if !kit
|
|
.components
|
|
.iter()
|
|
.any(|component| component.id == component_id)
|
|
{
|
|
return Err(format!(
|
|
"K() component not found: {raw} (resolved to {}/{})",
|
|
kit.id, component_id
|
|
));
|
|
}
|
|
Ok((kit.id.clone(), component_id))
|
|
}
|
|
|
|
/// Append the closing brackets for any `{`/`[` left open at end-of-string
|
|
/// (respecting string literals + escapes), in correct nesting order. A no-op on
|
|
/// already-balanced input. This is a best-effort repair for weak models that
|
|
/// drop trailing `}` / `]`; it cannot recover a value truncated mid-token, but
|
|
/// it rescues the dominant "forgot the closers" shape.
|
|
fn close_unbalanced_brackets(s: &str) -> String {
|
|
let mut stack: Vec<char> = Vec::new();
|
|
let mut in_string: Option<char> = None;
|
|
let mut escape = false;
|
|
for ch in s.chars() {
|
|
if escape {
|
|
escape = false;
|
|
continue;
|
|
}
|
|
if let Some(quote) = in_string {
|
|
if ch == '\\' {
|
|
escape = true;
|
|
} else if ch == quote {
|
|
in_string = None;
|
|
}
|
|
continue;
|
|
}
|
|
match ch {
|
|
'"' | '\'' => in_string = Some(ch),
|
|
'{' => stack.push('}'),
|
|
'[' => stack.push(']'),
|
|
'}' | ']' => {
|
|
stack.pop();
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
if stack.is_empty() {
|
|
return s.to_string();
|
|
}
|
|
let mut out = s.to_string();
|
|
while let Some(closer) = stack.pop() {
|
|
out.push(closer);
|
|
}
|
|
out
|
|
}
|
|
|
|
/// TS `replaceSingleQuoteDelimiters` — swap single-quote string
|
|
/// delimiters for double quotes, leaving apostrophes inside
|
|
/// double-quoted strings alone.
|
|
fn replace_single_quote_delimiters(s: &str) -> String {
|
|
let mut out = String::with_capacity(s.len());
|
|
let mut chars = s.chars();
|
|
let mut in_double = false;
|
|
let mut in_single = false;
|
|
while let Some(ch) = chars.next() {
|
|
if ch == '\\' && (in_double || in_single) {
|
|
out.push(ch);
|
|
if let Some(next) = chars.next() {
|
|
out.push(next);
|
|
}
|
|
continue;
|
|
}
|
|
if in_double {
|
|
if ch == '"' {
|
|
in_double = false;
|
|
}
|
|
out.push(ch);
|
|
} else if in_single {
|
|
if ch == '\'' {
|
|
in_single = false;
|
|
out.push('"');
|
|
} else {
|
|
out.push(ch);
|
|
}
|
|
} else if ch == '"' {
|
|
in_double = true;
|
|
out.push(ch);
|
|
} else if ch == '\'' {
|
|
in_single = true;
|
|
out.push('"');
|
|
} else {
|
|
out.push(ch);
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
// --- Reference + path resolution --------------------------------------
|
|
|
|
/// TS `resolveRef` — strip one leading + one trailing double quote,
|
|
/// then look the cleaned token up in the binding table.
|
|
fn resolve_ref(raw: &str, bindings: &BTreeMap<String, String>) -> String {
|
|
let cleaned = strip_outer_quotes(raw);
|
|
bindings.get(cleaned.as_str()).cloned().unwrap_or(cleaned)
|
|
}
|
|
|
|
fn strip_outer_quotes(raw: &str) -> String {
|
|
let s = raw.strip_prefix('"').unwrap_or(raw);
|
|
let s = s.strip_suffix('"').unwrap_or(s);
|
|
s.to_string()
|
|
}
|
|
|
|
/// TS `resolvePathExpr` — `binding+"/child"` concatenation, else a
|
|
/// plain (possibly quoted) ref.
|
|
fn resolve_path_expr(raw: &str, bindings: &BTreeMap<String, String>) -> String {
|
|
if raw.contains('+') {
|
|
return raw
|
|
.split('+')
|
|
.map(|part| {
|
|
let t = part.trim();
|
|
if t.starts_with('"') || t.starts_with('\'') {
|
|
// TS `slice(1, -1)` — drop the delimiters.
|
|
let mut cs = t.chars();
|
|
cs.next();
|
|
cs.next_back();
|
|
cs.as_str().to_string()
|
|
} else {
|
|
bindings.get(t).cloned().unwrap_or_else(|| t.to_string())
|
|
}
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join("");
|
|
}
|
|
resolve_ref(raw, bindings)
|
|
}
|
|
|
|
/// One path segment → live sim id, translating authored ids through
|
|
/// the alias table when the segment doesn't resolve directly.
|
|
fn lookup_id(id: &str, alias: &BTreeMap<String, String>) -> String {
|
|
alias.get(id).cloned().unwrap_or_else(|| id.to_string())
|
|
}
|
|
|
|
/// TS `findNodeByPath` — first segment is a deep `findNodeInTree`,
|
|
/// every later segment must be a DIRECT child id. Segments written
|
|
/// against authored (pre-remap) ids fall back through the alias table.
|
|
fn find_node_by_path<'a>(
|
|
children: &'a [PenNode],
|
|
path: &str,
|
|
alias: &BTreeMap<String, String>,
|
|
) -> Option<&'a PenNode> {
|
|
let mut parts = path.split('/');
|
|
let first = parts.next()?;
|
|
let first_id = lookup_id(first, alias);
|
|
let mut current = op_editor_core::walkers::find_node(children, &NodeId::new(&first_id))?;
|
|
for part in parts {
|
|
let part_id = lookup_id(part, alias);
|
|
let kids = current.children()?;
|
|
current = kids.iter().find(|c| c.id_str() == part_id)?;
|
|
}
|
|
Some(current)
|
|
}
|
|
|
|
// --- Small helpers ------------------------------------------------------
|
|
|
|
fn parent_node_id(parent: Option<&str>) -> NodeId {
|
|
match parent {
|
|
None => NodeId::NONE,
|
|
Some(raw) if raw.trim().is_empty() || raw.trim() == "0" => NodeId::NONE,
|
|
Some(raw) => NodeId::new(raw.trim()),
|
|
}
|
|
}
|
|
|
|
/// Stamp the program-level pageId onto a U() command built by the
|
|
/// shared direct-op builder (which emits `page_id: None`).
|
|
fn with_page_id(cmd: EditorCommand, page_id: Option<String>) -> EditorCommand {
|
|
match cmd {
|
|
EditorCommand::PatchNodeData {
|
|
node_id,
|
|
patch_json,
|
|
..
|
|
} => EditorCommand::PatchNodeData {
|
|
node_id,
|
|
patch_json,
|
|
page_id,
|
|
},
|
|
EditorCommand::UpdateNode {
|
|
node_id,
|
|
x,
|
|
y,
|
|
width,
|
|
height,
|
|
name,
|
|
fill_hex,
|
|
..
|
|
} => EditorCommand::UpdateNode {
|
|
node_id,
|
|
x,
|
|
y,
|
|
width,
|
|
height,
|
|
name,
|
|
fill_hex,
|
|
page_id,
|
|
},
|
|
other => other,
|
|
}
|
|
}
|
|
|
|
/// TS `isEmptyFrame` over the page roots — the first root-level frame
|
|
/// with no children.
|
|
fn first_empty_frame(children: &[PenNode]) -> Option<&PenNode> {
|
|
children.iter().find(|node| {
|
|
matches!(node, PenNode::Frame(_)) && node.children().map(|c| c.is_empty()).unwrap_or(true)
|
|
})
|
|
}
|
|
|
|
/// Mirror of `command_apply::command_page_index`'s explicit-page arm:
|
|
/// page id match first, then a legacy numeric index.
|
|
fn resolve_page_index(state: &EditorState, raw: &str) -> Option<usize> {
|
|
let raw = raw.trim();
|
|
if raw.is_empty() {
|
|
return None;
|
|
}
|
|
match state.doc.pages.as_ref() {
|
|
Some(pages) if !pages.is_empty() => pages
|
|
.iter()
|
|
.position(|page| page.id == raw)
|
|
.or_else(|| raw.parse::<usize>().ok().filter(|idx| *idx < pages.len())),
|
|
_ => raw.parse::<usize>().ok().filter(|idx| *idx == 0),
|
|
}
|
|
}
|
|
|
|
/// TS error-line preview: 200 chars + `...`.
|
|
fn line_preview(line: &str) -> String {
|
|
let mut preview: String = line.chars().take(200).collect();
|
|
if line.chars().count() > 200 {
|
|
preview.push_str("...");
|
|
}
|
|
preview
|
|
}
|
|
|
|
/// TS `countNodes`.
|
|
fn count_forest(nodes: &[PenNode]) -> usize {
|
|
nodes
|
|
.iter()
|
|
.map(|node| {
|
|
1 + node
|
|
.children()
|
|
.map(|children| count_forest(children))
|
|
.unwrap_or(0)
|
|
})
|
|
.sum()
|
|
}
|
|
|
|
fn regex(pattern: &str) -> Regex {
|
|
Regex::new(pattern).expect("static DSL regex must compile")
|
|
}
|