wsb 0.275.7622

Workspace tools for every project under $PRJ: git across submodules, template stamping, GitHub, and the workbench
Documentation
//! JSON edited in place: one string replaced, every other byte of the file —
//! key order, indentation, line ends, the last newline — as it was written.
//!
//! A manifest or a lock is the project's file, and a version bump that
//! re-serialises it rewrites what nobody asked to change.

use anyhow::{bail, Context, Result};

/// One open container while scanning: an object, with the key whose value
/// is being read, or an array.
enum Frame {
    Object { key: Option<String>, key_next: bool },
    Array,
}

/// Replace the string at `path` (object keys from the top) with `value`.
/// `Ok(None)` when there is no member there; an error when the text is not
/// JSON or the member there is not a string.
pub fn replace_string(content: &str, path: &[&str], value: &str) -> Result<Option<String>> {
    serde_json::from_str::<serde_json::Value>(content).context("not JSON")?;
    let replacement = serde_json::to_string(value)?;
    let bytes = content.as_bytes();
    // The byte after the string literal that starts at `start`; the text is
    // JSON, so every literal is closed.
    let string_end = |start: usize| -> usize {
        let mut at = start + 1;
        while bytes[at] != b'"' {
            at += if bytes[at] == b'\\' { 2 } else { 1 };
        }
        at + 1
    };
    let skip_space = |mut at: usize| -> usize {
        while bytes[at].is_ascii_whitespace() {
            at += 1;
        }
        at
    };
    let at_path = |stack: &[Frame]| {
        stack.len() == path.len()
            && stack.iter().zip(path).all(|(frame, want)| {
                matches!(frame, Frame::Object { key: Some(key), .. } if key == want)
            })
    };
    let mut stack: Vec<Frame> = Vec::new();
    let mut at = 0;
    while at < bytes.len() {
        match bytes[at] {
            b'"' => {
                let end = string_end(at);
                if let Some(Frame::Object { key, key_next }) = stack.last_mut() {
                    if *key_next {
                        *key = Some(serde_json::from_str(&content[at..end])?);
                        *key_next = false;
                        let value_at = skip_space(skip_space(end) + 1);
                        if at_path(&stack) && bytes[value_at] != b'"' {
                            bail!("{} is not a string", path.join("."));
                        }
                        at = value_at;
                        continue;
                    }
                }
                if at_path(&stack) {
                    return Ok(Some(format!("{}{replacement}{}", &content[..at], &content[end..])));
                }
                at = end;
                continue;
            }
            b'{' => stack.push(Frame::Object { key: None, key_next: true }),
            b'[' => stack.push(Frame::Array),
            b'}' | b']' => {
                stack.pop();
            }
            b',' => {
                if let Some(Frame::Object { key_next, .. }) = stack.last_mut() {
                    *key_next = true;
                }
            }
            _ => {}
        }
        at += 1;
    }
    Ok(None)
}

/// Write `value` as the top-level string member `key` of a JSON object: in
/// place when it is there, else added as the object's first member,
/// indented as its next one is.
pub fn set_top_level_string(content: &str, key: &str, value: &str) -> Result<String> {
    let parsed: serde_json::Value = serde_json::from_str(content).context("not JSON")?;
    if !parsed.is_object() {
        bail!("not a JSON object");
    }
    if let Some(replaced) = replace_string(content, &[key], value)? {
        return Ok(replaced);
    }
    let bytes = content.as_bytes();
    let opening = content.find('{').expect("a JSON object has an opening brace");
    let mut first = opening + 1;
    while bytes[first].is_ascii_whitespace() {
        first += 1;
    }
    let indent = &content[opening + 1..first];
    let member = format!("{}: {}", serde_json::to_string(key)?, serde_json::to_string(value)?);
    Ok(if bytes[first] == b'}' {
        format!("{}{member}{}", &content[..opening + 1], &content[opening + 1..])
    } else {
        format!("{}{indent}{member},{}", &content[..opening + 1], &content[opening + 1..])
    })
}

#[cfg(test)]
#[path = "json_edit_tests.rs"]
mod tests;