main.rs

  1use anyhow::Result;
  2use clap::{Arg, ArgMatches, Command};
  3use mdbook::BookItem;
  4use mdbook::book::{Book, Chapter};
  5use mdbook::preprocess::CmdPreprocessor;
  6use regex::Regex;
  7use settings::KeymapFile;
  8use std::io::{self, Read};
  9use std::process;
 10use std::sync::LazyLock;
 11
 12static KEYMAP_MACOS: LazyLock<KeymapFile> = LazyLock::new(|| {
 13    load_keymap("keymaps/default-macos.json").expect("Failed to load MacOS keymap")
 14});
 15
 16static KEYMAP_LINUX: LazyLock<KeymapFile> = LazyLock::new(|| {
 17    load_keymap("keymaps/default-linux.json").expect("Failed to load Linux keymap")
 18});
 19
 20pub fn make_app() -> Command {
 21    Command::new("zed-docs-preprocessor")
 22        .about("Preprocesses Zed Docs content to provide rich action & keybinding support and more")
 23        .subcommand(
 24            Command::new("supports")
 25                .arg(Arg::new("renderer").required(true))
 26                .about("Check whether a renderer is supported by this preprocessor"),
 27        )
 28}
 29
 30fn main() -> Result<()> {
 31    let matches = make_app().get_matches();
 32
 33    if let Some(sub_args) = matches.subcommand_matches("supports") {
 34        handle_supports(sub_args);
 35    } else {
 36        handle_preprocessing()?;
 37    }
 38
 39    Ok(())
 40}
 41
 42fn handle_preprocessing() -> Result<()> {
 43    let mut stdin = io::stdin();
 44    let mut input = String::new();
 45    stdin.read_to_string(&mut input)?;
 46
 47    let (_ctx, mut book) = CmdPreprocessor::parse_input(input.as_bytes())?;
 48
 49    template_keybinding(&mut book);
 50    template_action(&mut book);
 51
 52    serde_json::to_writer(io::stdout(), &book)?;
 53
 54    Ok(())
 55}
 56
 57fn handle_supports(sub_args: &ArgMatches) -> ! {
 58    let renderer = sub_args
 59        .get_one::<String>("renderer")
 60        .expect("Required argument");
 61    let supported = renderer != "not-supported";
 62    if supported {
 63        process::exit(0);
 64    } else {
 65        process::exit(1);
 66    }
 67}
 68
 69fn template_keybinding(book: &mut Book) {
 70    let regex = Regex::new(r"\{#kb (.*?)\}").unwrap();
 71
 72    for_each_chapter_mut(book, |chapter| {
 73        chapter.content = regex
 74            .replace_all(&chapter.content, |caps: &regex::Captures| {
 75                let action = caps[1].trim();
 76                let macos_binding = find_binding("macos", action).unwrap_or_default();
 77                let linux_binding = find_binding("linux", action).unwrap_or_default();
 78
 79                if macos_binding.is_empty() && linux_binding.is_empty() {
 80                    return "<div>No default binding</div>".to_string();
 81                }
 82
 83                format!("<kbd class=\"keybinding\">{macos_binding}|{linux_binding}</kbd>")
 84            })
 85            .into_owned()
 86    });
 87}
 88
 89fn template_action(book: &mut Book) {
 90    let regex = Regex::new(r"\{#action (.*?)\}").unwrap();
 91
 92    for_each_chapter_mut(book, |chapter| {
 93        chapter.content = regex
 94            .replace_all(&chapter.content, |caps: &regex::Captures| {
 95                let name = caps[1].trim();
 96
 97                let formatted_name = name
 98                    .chars()
 99                    .enumerate()
100                    .map(|(i, c)| {
101                        if i > 0 && c.is_uppercase() {
102                            format!(" {}", c.to_lowercase())
103                        } else {
104                            c.to_string()
105                        }
106                    })
107                    .collect::<String>()
108                    .trim()
109                    .to_string()
110                    .replace("::", ":");
111
112                format!("<code class=\"hljs\">{}</code>", formatted_name)
113            })
114            .into_owned()
115    });
116}
117
118fn find_binding(os: &str, action: &str) -> Option<String> {
119    let keymap = match os {
120        "macos" => &KEYMAP_MACOS,
121        "linux" => &KEYMAP_LINUX,
122        _ => unreachable!("Not a valid OS: {}", os),
123    };
124
125    // Find the binding in reverse order, as the last binding takes precedence.
126    keymap.sections().rev().find_map(|section| {
127        section.bindings().rev().find_map(|(keystroke, a)| {
128            if name_for_action(a.to_string()) == action {
129                Some(keystroke.to_string())
130            } else {
131                None
132            }
133        })
134    })
135}
136
137/// Removes any configurable options from the stringified action if existing,
138/// ensuring that only the actual action name is returned. If the action consists
139/// only of a string and nothing else, the string is returned as-is.
140///
141/// Example:
142///
143/// This will return the action name unmodified.
144///
145/// ```
146/// let action_as_str = "assistant::Assist";
147/// let action_name = name_for_action(action_as_str);
148/// assert_eq!(action_name, "assistant::Assist");
149/// ```
150///
151/// This will return the action name with any trailing options removed.
152///
153///
154/// ```
155/// let action_as_str = "\"editor::ToggleComments\", {\"advance_downwards\":false}";
156/// let action_name = name_for_action(action_as_str);
157/// assert_eq!(action_name, "editor::ToggleComments");
158/// ```
159fn name_for_action(action_as_str: String) -> String {
160    action_as_str
161        .split(",")
162        .next()
163        .map(|name| name.trim_matches('"').to_string())
164        .unwrap_or(action_as_str)
165}
166
167fn load_keymap(asset_path: &str) -> Result<KeymapFile> {
168    let content = util::asset_str::<settings::SettingsAssets>(asset_path);
169    KeymapFile::parse(content.as_ref())
170}
171
172fn for_each_chapter_mut<F>(book: &mut Book, mut func: F)
173where
174    F: FnMut(&mut Chapter),
175{
176    book.for_each_mut(|item| {
177        let BookItem::Chapter(chapter) = item else {
178            return;
179        };
180        func(chapter);
181    });
182}