schemaui 0.12.2

A Rust library for generating TUI and Web UIs from JSON Schemas for configuration management.
Documentation
use std::env;
use std::fs;
use std::path::{Path, PathBuf};

use serde::Deserialize;
use unicode_width::UnicodeWidthStr;

#[path = "src/keymap_spec.rs"]
mod keymap_spec;

const SHORTCUTS_BEGIN: &str = "<!-- AUTO-GENERATED:SHORTCUTS:BEGIN -->";
const SHORTCUTS_END: &str = "<!-- AUTO-GENERATED:SHORTCUTS:END -->";

const CONTEXT_ORDER: [&str; 12] = [
    "default",
    "collection",
    "overlay",
    "popup",
    "help",
    "text",
    "numeric",
    "boolean",
    "enum",
    "multiSelect",
    "composite",
    "arrayBuffer",
];

fn default_dispatch() -> bool {
    true
}

#[derive(Debug, Deserialize)]
struct KeymapDocEntry {
    id: String,
    description: String,
    #[serde(rename = "descriptionZh")]
    description_zh: Option<String>,
    contexts: Vec<String>,
    #[serde(default = "default_dispatch")]
    dispatch: bool,
    combos: Vec<String>,
}

#[derive(Clone, Copy)]
enum Locale {
    En,
    Zh,
}

impl Locale {
    fn table_headers(self) -> (&'static str, &'static str, &'static str) {
        match self {
            Locale::En => ("Shortcut", "Action", "Kind"),
            Locale::Zh => ("快捷键", "动作", "类型"),
        }
    }

    fn context_heading(self, context: &str) -> &'static str {
        match (self, context) {
            (Locale::En, "default") => "Default context",
            (Locale::En, "collection") => "Collection context",
            (Locale::En, "overlay") => "Overlay context",
            (Locale::En, "popup") => "Popup context",
            (Locale::En, "help") => "Help context",
            (Locale::En, "text") => "Text field context",
            (Locale::En, "numeric") => "Numeric field context",
            (Locale::En, "boolean") => "Boolean field context",
            (Locale::En, "enum") => "Enum field context",
            (Locale::En, "multiSelect") => "Multi-select field context",
            (Locale::En, "composite") => "Composite field context",
            (Locale::En, "arrayBuffer") => "Array buffer field context",
            (Locale::Zh, "default") => "默认上下文",
            (Locale::Zh, "collection") => "集合上下文",
            (Locale::Zh, "overlay") => "覆盖层上下文",
            (Locale::Zh, "popup") => "弹窗上下文",
            (Locale::Zh, "help") => "帮助上下文",
            (Locale::Zh, "text") => "文本字段上下文",
            (Locale::Zh, "numeric") => "数值字段上下文",
            (Locale::Zh, "boolean") => "布尔字段上下文",
            (Locale::Zh, "enum") => "枚举字段上下文",
            (Locale::Zh, "multiSelect") => "多选字段上下文",
            (Locale::Zh, "composite") => "复合字段上下文",
            (Locale::Zh, "arrayBuffer") => "数组缓冲字段上下文",
            _ => "Unknown context",
        }
    }

    fn description(self, entry: &KeymapDocEntry) -> &str {
        match self {
            Locale::En => &entry.description,
            Locale::Zh => entry
                .description_zh
                .as_deref()
                .expect("missing descriptionZh while rendering zh docs"),
        }
    }

    fn kind(self, dispatch: bool) -> &'static str {
        match (self, dispatch) {
            (Locale::En, true) => "command",
            (Locale::En, false) => "local edit",
            (Locale::Zh, true) => "命令",
            (Locale::Zh, false) => "局部编辑",
        }
    }
}

fn main() {
    for path in [
        "build.rs",
        "README.md",
        "README.ZH.md",
        "keymap/default.keymap.json",
    ] {
        println!("cargo:rerun-if-changed={path}");
    }

    let manifest_dir =
        PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR is missing"));
    if let Err(error) = sync_shortcut_docs(&manifest_dir) {
        panic!("failed to sync README shortcut docs: {error}");
    }
}

fn sync_shortcut_docs(root: &Path) -> Result<(), String> {
    let keymap_path = root.join("keymap/default.keymap.json");
    let keymap_text =
        fs::read_to_string(&keymap_path).map_err(|err| format!("read {keymap_path:?}: {err}"))?;
    let entries: Vec<KeymapDocEntry> =
        serde_json::from_str(&keymap_text).map_err(|err| format!("parse keymap JSON: {err}"))?;

    validate_entries(&entries)?;

    update_marked_block(
        &root.join("README.md"),
        &render_shortcut_block(&entries, Locale::En),
    )?;
    update_marked_block(
        &root.join("README.ZH.md"),
        &render_shortcut_block(&entries, Locale::Zh),
    )?;

    Ok(())
}

fn validate_entries(entries: &[KeymapDocEntry]) -> Result<(), String> {
    keymap_spec::validate_unique_ids(entries.iter().map(|entry| entry.id.as_str()))?;
    for entry in entries {
        if entry.combos.is_empty() {
            return Err(format!(
                "keymap entry {} must declare at least one combo",
                entry.id
            ));
        }
        if entry.contexts.is_empty() {
            return Err(format!(
                "keymap entry {} must declare at least one context",
                entry.id
            ));
        }
        if entry
            .description_zh
            .as_deref()
            .unwrap_or("")
            .trim()
            .is_empty()
        {
            return Err(format!(
                "keymap entry {} must provide non-empty descriptionZh",
                entry.id
            ));
        }
        for context in &entry.contexts {
            if !CONTEXT_ORDER.contains(&context.as_str()) {
                return Err(format!(
                    "keymap entry {} uses unsupported context {}",
                    entry.id, context
                ));
            }
        }
    }

    Ok(())
}

fn render_shortcut_block(entries: &[KeymapDocEntry], locale: Locale) -> String {
    let (shortcut_header, action_header, kind_header) = locale.table_headers();
    let mut lines = Vec::new();

    for context in CONTEXT_ORDER {
        let context_entries = entries
            .iter()
            .filter(|entry| entry.contexts.iter().any(|item| item == context))
            .collect::<Vec<_>>();

        if context_entries.is_empty() {
            continue;
        }

        lines.push(format!("#### {}", locale.context_heading(context)));
        lines.push(String::new());
        let rows = context_entries
            .into_iter()
            .map(|entry| {
                vec![
                    entry
                        .combos
                        .iter()
                        .map(|combo| format!("`{}`", escape_markdown_cell(combo)))
                        .collect::<Vec<_>>()
                        .join(" / "),
                    escape_markdown_cell(locale.description(entry)),
                    format!("`{}`", locale.kind(entry.dispatch)),
                ]
            })
            .collect::<Vec<_>>();
        lines.extend(render_markdown_table(
            [shortcut_header, action_header, kind_header],
            &rows,
        ));

        if context != CONTEXT_ORDER[CONTEXT_ORDER.len() - 1] {
            lines.push(String::new());
        }
    }

    lines.join("\n").trim_end().to_string()
}

fn render_markdown_table<const N: usize>(headers: [&str; N], rows: &[Vec<String>]) -> Vec<String> {
    let mut widths = headers.map(UnicodeWidthStr::width);
    for row in rows {
        for (index, cell) in row.iter().enumerate().take(N) {
            widths[index] = widths[index].max(UnicodeWidthStr::width(cell.as_str()));
        }
    }

    let header_line = render_markdown_row(headers.iter().copied(), &widths);
    let separator_line =
        render_markdown_row(widths.iter().map(|width| "-".repeat(*width)), &widths);
    let mut lines = vec![header_line, separator_line];
    for row in rows {
        lines.push(render_markdown_row(
            row.iter().take(N).map(String::as_str),
            &widths,
        ));
    }
    lines
}

fn render_markdown_row<I, S>(cells: I, widths: &[usize]) -> String
where
    I: IntoIterator<Item = S>,
    S: AsRef<str>,
{
    let mut row = String::new();
    for (index, cell) in cells.into_iter().enumerate() {
        let cell = cell.as_ref();
        row.push('|');
        row.push(' ');
        row.push_str(cell);
        let padding = widths[index].saturating_sub(UnicodeWidthStr::width(cell));
        row.push_str(&" ".repeat(padding));
        row.push(' ');
    }
    row.push('|');
    row
}

fn escape_markdown_cell(input: &str) -> String {
    input.replace('|', "\\|")
}

fn update_marked_block(path: &Path, generated: &str) -> Result<(), String> {
    let original = fs::read_to_string(path).map_err(|err| format!("read {path:?}: {err}"))?;
    let begin_matches = original.match_indices(SHORTCUTS_BEGIN).collect::<Vec<_>>();
    let end_matches = original.match_indices(SHORTCUTS_END).collect::<Vec<_>>();

    if begin_matches.len() != 1 || end_matches.len() != 1 {
        return Err(format!(
            "{path:?} must contain exactly one {SHORTCUTS_BEGIN} marker and one {SHORTCUTS_END} marker"
        ));
    }

    let begin = begin_matches[0].0;
    let end = end_matches[0].0;

    if begin >= end {
        return Err(format!("invalid marker order in {path:?}"));
    }

    let prefix = &original[..begin + SHORTCUTS_BEGIN.len()];
    let suffix = &original[end..];
    let updated = format!("{prefix}\n\n{generated}\n\n{suffix}");

    if updated != original {
        fs::write(path, updated).map_err(|err| format!("write {path:?}: {err}"))?;
    }

    Ok(())
}