fresh-editor 0.4.2

A lightweight, fast terminal-based text editor with LSP support and TypeScript plugins
Documentation
//! Generate editable TypeScript from a recorded macro.
//!
//! A recorded macro is a `Vec<Action>`. These pure functions render it into the
//! two `init.ts` forms described in
//! `docs/internal/macro-system-improvements.md`:
//!
//! - **save** — [`generate_define_block`] emits
//!   `editor.defineMacro("q", [ ...steps ])`, which re-seeds the register at
//!   startup so `@q` works in a fresh session exactly like a hand-recorded one.
//! - **promote** — [`generate_promote_block`] emits a `registerHandler` +
//!   `registerCommand` stub whose body is the same steps wrapped in
//!   `executeActions`, ready to be edited into arbitrary logic.
//!
//! Both forms are wrapped in `// fresh:macro <key>` … `// fresh:end macro <key>`
//! sentinels so [`upsert_macro_block`] can rewrite a macro in place instead of
//! appending duplicates — saving then promoting the same register updates the
//! one block rather than leaving two.

use std::collections::HashMap;

use serde_json::Value;

use crate::input::keybindings::Action;

/// JS double-quoted string literal for `s`, with correct escaping. JSON string
/// syntax is a subset of JS string syntax, so `serde_json` gives us a valid JS
/// literal for free.
fn js_string(s: &str) -> String {
    serde_json::to_string(s).unwrap_or_else(|_| "\"\"".to_string())
}

/// Render a payload-args map as a JS object literal (`{ char: "x" }`), or
/// `None` for the common empty case. Keys are sorted for deterministic output;
/// the known arg keys (`char`, `text`, `theme`, `name`, `map`) are all valid JS
/// identifiers, so they're emitted unquoted.
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(", ")))
}

/// Render one action as an `ActionSpec` object literal.
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)),
    }
}

/// Render the comma-separated, one-per-line step list (each line already
/// indented by `indent` and terminated with `,\n`).
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
}

/// A human-readable summary of the literal text the macro types, for a leading
/// comment. Consecutive `insert_char` actions are coalesced into runs; e.g. a
/// macro that types `- ` then moves then types `x` yields `"- " "x"`. Returns
/// `None` when the macro types nothing.
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;
    }
    // `{:?}` quotes and escapes each run.
    Some(
        runs.iter()
            .map(|r| format!("{:?}", r))
            .collect::<Vec<_>>()
            .join(" "),
    )
}

/// Turn a register key into a valid JS identifier fragment for a handler name.
fn sanitize_ident(c: char) -> String {
    if c.is_ascii_alphanumeric() {
        c.to_string()
    } else {
        format!("u{:04x}", c as u32)
    }
}

/// The opening sentinel line for `register` (with its trailing space — the
/// marker matcher relies on it to avoid `q` matching `q2`).
fn start_marker(register: char) -> String {
    format!("// fresh:macro {} ", register)
}

/// The closing sentinel line for `register`.
fn end_marker(register: char) -> String {
    format!("// fresh:end macro {}", register)
}

/// `editor.defineMacro(...)` block (the "save to init.ts" form), sentinel-wrapped.
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(&register.to_string()),
        render_steps(actions, "  ")
    ));
    out.push_str(&end_marker(register));
    out.push('\n');
    out
}

/// `registerHandler` + `registerCommand` block (the "promote to command" form),
/// sentinel-wrapped. The recorded steps become an ordinary `executeActions`
/// call inside a real function the user can extend with arbitrary logic.
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
}

/// Insert or replace `register`'s sentinel block in `existing` init.ts source.
///
/// If a block for `register` already exists (matched by its sentinels), it is
/// replaced in place — so re-saving or promoting the same register updates one
/// block. Otherwise `block` is appended, separated by a blank line. `block` is
/// expected to be sentinel-wrapped (the output of `generate_*_block`).
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;
            // Extend past the end-marker line (include its trailing newline).
            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;
        }
    }

    // No existing block — append with a blank-line separator.
    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 "));
        // Blank-line separation between prior content and the block.
        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);

        // Replace with a different macro under the same register.
        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]));
        // Updating 'a' must not disturb 'b'.
        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"));
    }
}