diff --git a/apps/figcraft-cli/src/files.rs b/apps/figcraft-cli/src/files.rs index 29f8cd5b6..a7bddeb3b 100644 --- a/apps/figcraft-cli/src/files.rs +++ b/apps/figcraft-cli/src/files.rs @@ -10,6 +10,12 @@ use serde_json::{Value, json}; use crate::{CliError, CliResult}; +/// A document's or plugin's text as a terminal should show it: control characters escaped, and +/// everything else (quotes, backslashes in paths) left as it is. +fn plain(text: &str) -> String { + text.chars().map(|c| if c.is_control() { c.escape_default().to_string() } else { c.to_string() }).collect() +} + pub fn load(path: &str) -> CliResult { let (doc, notes) = load_noted(path)?; if ext(path) == "penpot" { @@ -199,26 +205,33 @@ pub fn info(path: &str, as_json: bool) -> CliResult { return Ok(ExitCode::SUCCESS); } for n in ¬es { - eprintln!("note: {n}"); + eprintln!("note: {}", plain(&n.to_string())); } println!( "{} — {} ({} nodes, {} styles, {} variables, {} images)", - path, - doc.name, + plain(path), + plain(&doc.name), doc.node_count(), doc.styles.len(), doc.variables.variables.len(), doc.images.len() ); for p in &pages { - println!("page {} \"{}\": {} nodes, {} top-level", p["id"], p["name"].as_str().unwrap_or(""), p["nodes"], p["topLevel"]); + println!("page {} \"{}\": {} nodes, {} top-level", p["id"], plain(p["name"].as_str().unwrap_or("")), p["nodes"], p["topLevel"]); for f in p["frames"].as_array().into_iter().flatten() { - println!(" {} {} \"{}\" {}×{}", f["id"], f["type"].as_str().unwrap_or(""), f["name"].as_str().unwrap_or(""), f["width"], f["height"]); + println!( + " {} {} \"{}\" {}×{}", + f["id"], + f["type"].as_str().unwrap_or(""), + plain(f["name"].as_str().unwrap_or("")), + f["width"], + f["height"] + ); } } for m in &missing { let layers = if m.layers == 1 { "text layer" } else { "text layers" }; - println!("missing font \"{}\": {} {layers} (drawn in a fallback font)", m.family, m.layers); + println!("missing font \"{}\": {} {layers} (drawn in a fallback font)", plain(&m.family), m.layers); } Ok(ExitCode::SUCCESS) } @@ -438,7 +451,8 @@ pub fn run_script(script: &str, input: Option<&str>, out: Option<&str>) -> CliRe let cmd = steps.get(failed_at).map_or("", |step| step.command.as_str()); let i = failed_at; println!("{}", json!({"step": i, "command": cmd, "ok": false, "error": e.to_string(), "rolledBack": true})); - eprintln!("error: step {i} ({cmd}) failed: {e}"); + // Plugin-supplied text: escape terminal controls and line breaks. + eprintln!("error: step {i} ({}) failed: {}", plain(cmd), plain(&e.to_string())); eprintln!("the script's document changes were rolled back"); return Ok(ExitCode::from(1)); } @@ -491,6 +505,25 @@ pub fn connect_publish(file: &str, mappings: &str, dry_run: bool) -> CliResult"#).unwrap(); + std::fs::write(&script, r#"[{"command":"no.such\u001b"}]"#).unwrap(); + assert_eq!(info(svg.to_str().unwrap(), false).unwrap(), ExitCode::SUCCESS); + assert_eq!(run_script(script.to_str().unwrap(), None, None).unwrap(), ExitCode::from(1)); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn plain_escapes_controls_and_leaves_quotes_and_paths() { + assert_eq!(plain("a\u{1b}[31m\nb"), r"a\u{1b}[31m\nb"); + assert_eq!(plain(r"Bob's C:\files"), r"Bob's C:\files"); + } + #[test] fn script_formats() { assert_eq!(parse_script(r#"[{"command":"a"},{"command":"b","params":{"x":1}}]"#).unwrap().len(), 2); diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index 061084f9b..abca326e5 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -55,7 +55,8 @@ impl FigApp { /// Show command `id`'s error. pub(crate) fn failed(&mut self, id: &str, e: &figcraft_engine::EngineError) { let msg = e.to_string(); - log::warn!("{id}: {msg}"); + // A plugin's failure text may carry CR or terminal controls: log it escaped. + log::warn!("{id}: {}", msg.escape_debug()); self.show_error(msg); }