mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-24 10:16:59 -06:00
fix(script): only a well-formed $N reference is replaced; $$ is a literal $ (#749)
This commit is contained in:
@@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
|
||||
|
||||
Newest first.
|
||||
|
||||
- **2026-10-13** · Command scripts look up only a well-formed `$N.path` / `$last.path` reference; other strings starting with `$`, such as a GREP replacement of `$1` and a nonbreaking space, stay as written, and `$$` writes a literal `$` (#749). No readiness percentage change.
|
||||
- **2026-10-12** · macOS Dock Quit uses the same unsaved-document prompts as window and menu Quit. Repeated requests keep the current answers; Cancel retains every tab and unsaved recovery checkpoint, and completed Save/Don't Save answers exit through the ordinary event loop. No readiness percentage change.
|
||||
- **2026-10-12** · Orderly native exit writes the latest recovery state after finalizing current quit approvals; incomplete or stale plans keep their documents, and Undo to the saved state clears stale recovery. Unavailable or failed recovery is reported. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change.
|
||||
- **2026-10-12** · Recovered documents keep a checkpoint until saved, closed or reverted, including a second crash before autosave. Restarted document IDs and explicit recovery folders preserve other snapshots; failed republishing retains the original. No readiness percentage change.
|
||||
|
||||
@@ -55,6 +55,8 @@ References to earlier results (in string parameters):
|
||||
comments and blank lines are not steps. `$last` is the previous step.
|
||||
Paths go through result fields and array indices: "$1.story", "$3.ids.0".
|
||||
"${N.path}" inside a longer string, replaced by the value's text: "Frame ${0.id}".
|
||||
Any other string starting with `$` stays as written ("$1-$2", "$5 off").
|
||||
"$$" a literal `$` anywhere: "$$1" passes `$1` (a GREP group), "$${0.id}" passes `${0.id}`.
|
||||
|
||||
Text parameters: "\n" starts a new paragraph, "\u2028" is a forced line break, "\t" a tab.
|
||||
Most commands ignore parameters they do not know instead of rejecting them: check names
|
||||
|
||||
+54
-10
@@ -5,7 +5,8 @@
|
||||
//! becomes the JSON value at `path` in the result of step `N` (0-based): after
|
||||
//! `frame.create → {"id": 8, "story": 9}`, `{"story": "$0.story"}` passes `9`. Paths are dotted
|
||||
//! keys and array indices (`$2.ids.0`). Inside longer strings `${N.path}` is replaced by the
|
||||
//! value's text (`"Frame ${0.id}"`).
|
||||
//! value's text (`"Frame ${0.id}"`). Any other string starting with `$` is plain text (`"$1-$2"`,
|
||||
//! `"$5 off"`), and `$$` anywhere writes a literal `$` (`"$$1"` passes `$1`, a GREP group).
|
||||
//!
|
||||
//! **Script text** ([`parse`]): a JSON array of steps, JSON lines (`{"command": …, "params": …}`),
|
||||
//! or compact lines `command.id {json}` / `command.id={json}` / `command.id`; blank lines and
|
||||
@@ -92,27 +93,44 @@ fn short(v: &Value) -> String {
|
||||
if s.len() > 120 { format!("{}…", &s[..s.floor_char_boundary(120)]) } else { s }
|
||||
}
|
||||
|
||||
/// Whether `r` (the text after `$`) is a whole reference: digits or `last`, then zero or more
|
||||
/// `.segment` of ASCII letters, digits, `_` or `-`.
|
||||
fn is_reference(r: &str) -> bool {
|
||||
let mut parts = r.split('.');
|
||||
let head = parts.next().unwrap_or("");
|
||||
let head_ok = head == "last" || (!head.is_empty() && head.bytes().all(|b| b.is_ascii_digit()));
|
||||
head_ok && parts.all(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-'))
|
||||
}
|
||||
|
||||
/// Replace references in `params` with values from earlier `results`.
|
||||
pub fn resolve(params: &Value, results: &[Value]) -> Result<Value, String> {
|
||||
Ok(match params {
|
||||
Value::String(s) => {
|
||||
if let Some(r) = s.strip_prefix('$').filter(|r| !r.is_empty() && !r.contains(['{', ' '])) {
|
||||
if let Some(r) = s.strip_prefix('$').filter(|r| is_reference(r)) {
|
||||
return lookup(r, results);
|
||||
}
|
||||
if !s.contains("${") {
|
||||
if !s.contains("${") && !s.contains("$$") {
|
||||
return Ok(params.clone());
|
||||
}
|
||||
let mut out = String::new();
|
||||
let mut rest = s.as_str();
|
||||
while let Some(i) = rest.find("${") {
|
||||
while let Some(i) = rest.find('$') {
|
||||
out.push_str(&rest[..i]);
|
||||
let end = rest[i..].find('}').ok_or_else(|| format!("unclosed `${{` in {s:?}"))? + i;
|
||||
let v = lookup(&rest[i + 2..end], results)?;
|
||||
match v {
|
||||
Value::String(t) => out.push_str(&t),
|
||||
other => out.push_str(&other.to_string()),
|
||||
let after = &rest[i + 1..];
|
||||
if let Some(tail) = after.strip_prefix('$') {
|
||||
out.push('$');
|
||||
rest = tail;
|
||||
} else if let Some(inner) = after.strip_prefix('{') {
|
||||
let end = inner.find('}').ok_or_else(|| format!("unclosed `${{` in {s:?}"))?;
|
||||
match lookup(&inner[..end], results)? {
|
||||
Value::String(t) => out.push_str(&t),
|
||||
other => out.push_str(&other.to_string()),
|
||||
}
|
||||
rest = &inner[end + 1..];
|
||||
} else {
|
||||
out.push('$');
|
||||
rest = after;
|
||||
}
|
||||
rest = &rest[end + 1..];
|
||||
}
|
||||
out.push_str(rest);
|
||||
Value::String(out)
|
||||
@@ -196,6 +214,32 @@ mod tests {
|
||||
assert_eq!(r, json!({"story": 9, "id": 5, "label": "Frame 8 of A", "keep": "$", "list": ["A"]}));
|
||||
assert!(resolve(&json!({"x": "$5.id"}), &results).unwrap_err().contains("hasn't run"));
|
||||
assert!(resolve(&json!({"x": "$0.nope"}), &results).unwrap_err().contains("no `nope`"));
|
||||
assert!(resolve(&json!({"x": "Frame ${0.id"}), &results).unwrap_err().contains("unclosed"));
|
||||
}
|
||||
|
||||
/// GREP replacements start with `$` too; only a well-formed reference is looked up (#749).
|
||||
#[test]
|
||||
fn only_well_formed_references_resolve() {
|
||||
let results = vec![json!({"id": 8})];
|
||||
let p = json!({"a": "$1\u{a0}", "b": "$1-$2", "c": "$5 off", "d": "$$1", "e": "$${0.id}", "f": "$0.id", "g": "${0.id}x", "h": "$1$$", "i": "$1."});
|
||||
let r = resolve(&p, &results).unwrap();
|
||||
assert_eq!(r, json!({"a": "$1\u{a0}", "b": "$1-$2", "c": "$5 off", "d": "$1", "e": "${0.id}", "f": 8, "g": "8x", "h": "$1$", "i": "$1."}));
|
||||
}
|
||||
|
||||
/// Czech typesetting: a nonbreaking space after one-letter prepositions.
|
||||
#[test]
|
||||
fn a_script_grep_change_keeps_its_group_reference() {
|
||||
let mut s = crate::Session::new();
|
||||
let steps = vec![
|
||||
Step { command: "file.new".into(), params: json!({}) },
|
||||
Step { command: "frame.create".into(), params: json!({"rect": [36, 36, 300, 300], "content": "text", "text": "a v Praze"}) },
|
||||
Step { command: "find.change".into(), params: json!({"find": r"\b([av]) ", "change": "$1\u{a0}", "grep": true, "caseSensitive": true}) },
|
||||
Step { command: "story.get".into(), params: json!({"story": "$1.story"}) },
|
||||
];
|
||||
let r = s.run_script(&steps);
|
||||
assert!(r.failed.is_none(), "{r:?}");
|
||||
assert_eq!(r.results[2]["count"], 2);
|
||||
assert_eq!(r.results[3]["text"], "a\u{a0}v\u{a0}Praze");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -118,7 +118,8 @@ pub fn tool_definitions() -> Vec<Value> {
|
||||
and message (earlier commands stay applied; use edit.undo to revert them). Later steps can use earlier results: a \
|
||||
parameter string \"$N.path\" is replaced by that value of step N's result (0-based; \"$last.path\" for the previous \
|
||||
step), e.g. after frame.create → {\"id\": 8, \"story\": 9}, {\"story\": \"$0.story\"} passes 9; \"${N.path}\" \
|
||||
interpolates into longer strings. Give `commands`, or `script` text (one `command.id {json}` per line).",
|
||||
interpolates into longer strings. Other strings starting with $ stay as written; \"$$\" is a literal $ \
|
||||
(\"$$1\" passes the GREP group $1). Give `commands`, or `script` text (one `command.id {json}` per line).",
|
||||
obj(
|
||||
json!({
|
||||
"commands": {
|
||||
|
||||
+2
-1
@@ -24,7 +24,8 @@ A script is one command per line (`command.id {json params}`; `#` comments), JSO
|
||||
`{command, params}`, in UTF-8 with or without a byte order mark. A parameter string `"$N.path"` becomes that value
|
||||
of step N's result: N is the 0-based step index (comments and blank lines are not steps, `$last` is the previous
|
||||
step) and the path goes through result fields and array indices (`"$1.story"`, `"$3.ids.0"`); `"${N.path}"`
|
||||
interpolates inside longer strings. In text parameters `\n` starts a new paragraph and `\u2028` is a forced line
|
||||
interpolates inside longer strings. Any other string starting with `$` stays as written (`"$1-$2"`, `"$5 off"`), and
|
||||
`$$` writes a literal `$` anywhere (`"$$1"` passes `$1`, a GREP group). In text parameters `\n` starts a new paragraph and `\u2028` is a forced line
|
||||
break. Most commands ignore parameters they don't know instead of rejecting them, so check names with `describe`.
|
||||
`designcraft-cli script --help` prints this reference.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user