use std::collections::HashMap;
use serde_json::Value;
use crate::input::keybindings::Action;
fn js_string(s: &str) -> String {
serde_json::to_string(s).unwrap_or_else(|_| "\"\"".to_string())
}
fn render_args(args: &HashMap<String, Value>) -> Option<String> {
if args.is_empty() {
return None;
}
let mut keys: Vec<&String> = args.keys().collect();
keys.sort();
let parts: Vec<String> = keys
.iter()
.map(|k| {
let v = &args[*k];
format!(
"{}: {}",
k,
serde_json::to_string(v).unwrap_or_else(|_| "null".to_string())
)
})
.collect();
Some(format!("{{ {} }}", parts.join(", ")))
}
fn render_step(action: &Action) -> String {
let spec = action.to_action_spec();
match render_args(&spec.args) {
Some(args) => format!("{{ action: {}, args: {} }}", js_string(&spec.action), args),
None => format!("{{ action: {} }}", js_string(&spec.action)),
}
}
fn render_steps(actions: &[Action], indent: &str) -> String {
let mut s = String::new();
for action in actions {
s.push_str(indent);
s.push_str(&render_step(action));
s.push_str(",\n");
}
s
}
fn typed_text_summary(actions: &[Action]) -> Option<String> {
let mut runs: Vec<String> = Vec::new();
let mut cur = String::new();
for a in actions {
if let Action::InsertChar(c) = a {
cur.push(*c);
} else if !cur.is_empty() {
runs.push(std::mem::take(&mut cur));
}
}
if !cur.is_empty() {
runs.push(cur);
}
if runs.is_empty() {
return None;
}
Some(
runs.iter()
.map(|r| format!("{:?}", r))
.collect::<Vec<_>>()
.join(" "),
)
}
fn sanitize_ident(c: char) -> String {
if c.is_ascii_alphanumeric() {
c.to_string()
} else {
format!("u{:04x}", c as u32)
}
}
fn start_marker(register: char) -> String {
format!("// fresh:macro {} ", register)
}
fn end_marker(register: char) -> String {
format!("// fresh:end macro {}", register)
}
pub fn generate_define_block(register: char, actions: &[Action]) -> String {
let mut out = String::new();
out.push_str(&format!(
"// fresh:macro {} — generated by Fresh, editable\n",
register
));
if let Some(t) = typed_text_summary(actions) {
out.push_str(&format!("// types: {}\n", t));
}
out.push_str(&format!(
"editor.defineMacro({}, [\n{}]);\n",
js_string(®ister.to_string()),
render_steps(actions, " ")
));
out.push_str(&end_marker(register));
out.push('\n');
out
}
pub fn generate_promote_block(register: char, actions: &[Action]) -> String {
let handler = format!("macro_{}", sanitize_ident(register));
let mut out = String::new();
out.push_str(&format!(
"// fresh:macro {} — promoted to a command; edit freely\n",
register
));
if let Some(t) = typed_text_summary(actions) {
out.push_str(&format!("// types: {}\n", t));
}
out.push_str(&format!(
"registerHandler({}, async function () {{\n",
js_string(&handler)
));
out.push_str(" // Originally recorded; edit freely from here.\n");
out.push_str(&format!(
" await editor.executeActions([\n{} ]);\n",
render_steps(actions, " ")
));
out.push_str(" // ↓ Arbitrary logic the recording could never express, e.g.:\n");
out.push_str(" // for (const cursor of editor.getAllCursors()) { /* ... */ }\n");
out.push_str("});\n");
out.push_str(&format!(
"editor.registerCommand({}, {}, {});\n",
js_string(&format!("Macro {}", register)),
js_string(&format!("Run promoted macro {}", register)),
js_string(&handler)
));
out.push_str(&end_marker(register));
out.push('\n');
out
}
pub fn upsert_macro_block(existing: &str, register: char, block: &str) -> String {
let start = start_marker(register);
let end = end_marker(register);
if let Some(s) = existing.find(&start) {
if let Some(e_rel) = existing[s..].find(&end) {
let e = s + e_rel;
let after = existing[e..]
.find('\n')
.map(|n| e + n + 1)
.unwrap_or(existing.len());
let mut result = String::with_capacity(existing.len() + block.len());
result.push_str(&existing[..s]);
result.push_str(block);
result.push_str(&existing[after..]);
return result;
}
}
let mut result = existing.to_string();
if !result.is_empty() {
if !result.ends_with('\n') {
result.push('\n');
}
result.push('\n');
}
result.push_str(block);
result
}
#[cfg(test)]
mod tests {
use super::*;
fn typing(text: &str) -> Vec<Action> {
text.chars().map(Action::InsertChar).collect()
}
#[test]
fn define_block_round_trips_through_sentinels() {
let mut actions = vec![Action::MoveLineStart];
actions.extend(typing("- "));
let block = generate_define_block('q', &actions);
assert!(block.starts_with("// fresh:macro q "));
assert!(block.contains("editor.defineMacro(\"q\", ["));
assert!(block.contains("{ action: \"move_line_start\" }"));
assert!(block.contains("{ action: \"insert_char\", args: { char: \"-\" } }"));
assert!(block.contains("{ action: \"insert_char\", args: { char: \" \" } }"));
assert!(block.contains("// types: \"- \""));
assert!(block.trim_end().ends_with("// fresh:end macro q"));
}
#[test]
fn promote_block_has_handler_and_command() {
let actions = vec![Action::MoveLineStart, Action::InsertChar('x')];
let block = generate_promote_block('0', &actions);
assert!(block.contains("registerHandler(\"macro_0\", async function () {"));
assert!(block.contains("await editor.executeActions(["));
assert!(block.contains("editor.registerCommand(\"Macro 0\""));
assert!(block.contains("\"macro_0\""));
assert!(block.trim_end().ends_with("// fresh:end macro 0"));
}
#[test]
fn upsert_appends_when_absent() {
let existing = "const editor = getEditor();\n";
let block = generate_define_block('q', &[Action::MoveLeft]);
let merged = upsert_macro_block(existing, 'q', &block);
assert!(merged.starts_with(existing));
assert!(merged.contains("// fresh:macro q "));
assert!(merged.contains("getEditor();\n\n// fresh:macro q "));
}
#[test]
fn upsert_replaces_existing_block_in_place() {
let existing = "// before\n";
let first = generate_define_block('q', &[Action::MoveLeft]);
let merged = upsert_macro_block(existing, 'q', &first);
let second = generate_define_block('q', &[Action::MoveRight, Action::MoveRight]);
let merged2 = upsert_macro_block(&merged, 'q', &second);
assert_eq!(merged2.matches("// fresh:macro q ").count(), 1);
assert!(merged2.contains("move_right"));
assert!(!merged2.contains("move_left"));
assert!(merged2.starts_with("// before\n"));
}
#[test]
fn upsert_preserves_surrounding_blocks() {
let mut src = String::from("const editor = getEditor();\n");
src = upsert_macro_block(&src, 'a', &generate_define_block('a', &[Action::MoveLeft]));
src = upsert_macro_block(&src, 'b', &generate_define_block('b', &[Action::MoveRight]));
let updated = upsert_macro_block(&src, 'a', &generate_define_block('a', &[Action::MoveUp]));
assert!(updated.contains("// fresh:macro a "));
assert!(updated.contains("// fresh:macro b "));
assert!(updated.contains("move_up"));
assert!(updated.contains("move_right"));
assert!(!updated.contains("move_left"));
}
}