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::collections::HashSet;
  9use std::io::{self, Read};
 10use std::process;
 11use std::sync::LazyLock;
 12
 13static KEYMAP_MACOS: LazyLock<KeymapFile> = LazyLock::new(|| {
 14    load_keymap("keymaps/default-macos.json").expect("Failed to load MacOS keymap")
 15});
 16
 17static KEYMAP_LINUX: LazyLock<KeymapFile> = LazyLock::new(|| {
 18    load_keymap("keymaps/default-linux.json").expect("Failed to load Linux keymap")
 19});
 20
 21static ALL_ACTIONS: LazyLock<Vec<ActionDef>> = LazyLock::new(dump_all_gpui_actions);
 22
 23pub fn make_app() -> Command {
 24    Command::new("zed-docs-preprocessor")
 25        .about("Preprocesses Zed Docs content to provide rich action & keybinding support and more")
 26        .subcommand(
 27            Command::new("supports")
 28                .arg(Arg::new("renderer").required(true))
 29                .about("Check whether a renderer is supported by this preprocessor"),
 30        )
 31}
 32
 33fn main() -> Result<()> {
 34    let matches = make_app().get_matches();
 35    // call a zed:: function so everything in `zed` crate is linked and
 36    // all actions in the actual app are registered
 37    zed::stdout_is_a_pty();
 38
 39    if let Some(sub_args) = matches.subcommand_matches("supports") {
 40        handle_supports(sub_args);
 41    } else {
 42        handle_preprocessing()?;
 43    }
 44
 45    Ok(())
 46}
 47
 48#[derive(Debug, Clone, PartialEq, Eq, Hash)]
 49enum Error {
 50    ActionNotFound { action_name: String },
 51    DeprecatedActionUsed { used: String, should_be: String },
 52}
 53
 54impl Error {
 55    fn new_for_not_found_action(action_name: String) -> Self {
 56        for action in &*ALL_ACTIONS {
 57            for alias in action.deprecated_aliases {
 58                if alias == &action_name {
 59                    return Error::DeprecatedActionUsed {
 60                        used: action_name.clone(),
 61                        should_be: action.name.to_string(),
 62                    };
 63                }
 64            }
 65        }
 66        Error::ActionNotFound {
 67            action_name: action_name.to_string(),
 68        }
 69    }
 70}
 71
 72impl std::fmt::Display for Error {
 73    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
 74        match self {
 75            Error::ActionNotFound { action_name } => write!(f, "Action not found: {}", action_name),
 76            Error::DeprecatedActionUsed { used, should_be } => write!(
 77                f,
 78                "Deprecated action used: {} should be {}",
 79                used, should_be
 80            ),
 81        }
 82    }
 83}
 84
 85fn handle_preprocessing() -> Result<()> {
 86    let mut stdin = io::stdin();
 87    let mut input = String::new();
 88    stdin.read_to_string(&mut input)?;
 89
 90    let (_ctx, mut book) = CmdPreprocessor::parse_input(input.as_bytes())?;
 91
 92    let mut errors = HashSet::<Error>::new();
 93
 94    template_and_validate_keybindings(&mut book, &mut errors);
 95    template_and_validate_actions(&mut book, &mut errors);
 96
 97    if !errors.is_empty() {
 98        const ANSI_RED: &'static str = "\x1b[31m";
 99        const ANSI_RESET: &'static str = "\x1b[0m";
100        for error in &errors {
101            eprintln!("{ANSI_RED}ERROR{ANSI_RESET}: {}", error);
102        }
103        return Err(anyhow::anyhow!("Found {} errors in docs", errors.len()));
104    }
105
106    serde_json::to_writer(io::stdout(), &book)?;
107
108    Ok(())
109}
110
111fn handle_supports(sub_args: &ArgMatches) -> ! {
112    let renderer = sub_args
113        .get_one::<String>("renderer")
114        .expect("Required argument");
115    let supported = renderer != "not-supported";
116    if supported {
117        process::exit(0);
118    } else {
119        process::exit(1);
120    }
121}
122
123fn template_and_validate_keybindings(book: &mut Book, errors: &mut HashSet<Error>) {
124    let regex = Regex::new(r"\{#kb (.*?)\}").unwrap();
125
126    for_each_chapter_mut(book, |chapter| {
127        chapter.content = regex
128            .replace_all(&chapter.content, |caps: &regex::Captures| {
129                let action = caps[1].trim();
130                if find_action_by_name(action).is_none() {
131                    errors.insert(Error::new_for_not_found_action(action.to_string()));
132                    return String::new();
133                }
134                let macos_binding = find_binding("macos", action).unwrap_or_default();
135                let linux_binding = find_binding("linux", action).unwrap_or_default();
136
137                if macos_binding.is_empty() && linux_binding.is_empty() {
138                    return "<div>No default binding</div>".to_string();
139                }
140
141                format!("<kbd class=\"keybinding\">{macos_binding}|{linux_binding}</kbd>")
142            })
143            .into_owned()
144    });
145}
146
147fn template_and_validate_actions(book: &mut Book, errors: &mut HashSet<Error>) {
148    let regex = Regex::new(r"\{#action (.*?)\}").unwrap();
149
150    for_each_chapter_mut(book, |chapter| {
151        chapter.content = regex
152            .replace_all(&chapter.content, |caps: &regex::Captures| {
153                let name = caps[1].trim();
154                let Some(action) = find_action_by_name(name) else {
155                    errors.insert(Error::new_for_not_found_action(name.to_string()));
156                    return String::new();
157                };
158                format!("<code class=\"hljs\">{}</code>", &action.human_name)
159            })
160            .into_owned()
161    });
162}
163
164fn find_action_by_name(name: &str) -> Option<&ActionDef> {
165    ALL_ACTIONS
166        .binary_search_by(|action| action.name.cmp(name))
167        .ok()
168        .map(|index| &ALL_ACTIONS[index])
169}
170
171fn find_binding(os: &str, action: &str) -> Option<String> {
172    let keymap = match os {
173        "macos" => &KEYMAP_MACOS,
174        "linux" | "freebsd" => &KEYMAP_LINUX,
175        _ => unreachable!("Not a valid OS: {}", os),
176    };
177
178    // Find the binding in reverse order, as the last binding takes precedence.
179    keymap.sections().rev().find_map(|section| {
180        section.bindings().rev().find_map(|(keystroke, a)| {
181            if name_for_action(a.to_string()) == action {
182                Some(keystroke.to_string())
183            } else {
184                None
185            }
186        })
187    })
188}
189
190/// Removes any configurable options from the stringified action if existing,
191/// ensuring that only the actual action name is returned. If the action consists
192/// only of a string and nothing else, the string is returned as-is.
193///
194/// Example:
195///
196/// This will return the action name unmodified.
197///
198/// ```
199/// let action_as_str = "assistant::Assist";
200/// let action_name = name_for_action(action_as_str);
201/// assert_eq!(action_name, "assistant::Assist");
202/// ```
203///
204/// This will return the action name with any trailing options removed.
205///
206///
207/// ```
208/// let action_as_str = "\"editor::ToggleComments\", {\"advance_downwards\":false}";
209/// let action_name = name_for_action(action_as_str);
210/// assert_eq!(action_name, "editor::ToggleComments");
211/// ```
212fn name_for_action(action_as_str: String) -> String {
213    action_as_str
214        .split(",")
215        .next()
216        .map(|name| name.trim_matches('"').to_string())
217        .unwrap_or(action_as_str)
218}
219
220fn load_keymap(asset_path: &str) -> Result<KeymapFile> {
221    let content = util::asset_str::<settings::SettingsAssets>(asset_path);
222    KeymapFile::parse(content.as_ref())
223}
224
225fn for_each_chapter_mut<F>(book: &mut Book, mut func: F)
226where
227    F: FnMut(&mut Chapter),
228{
229    book.for_each_mut(|item| {
230        let BookItem::Chapter(chapter) = item else {
231            return;
232        };
233        func(chapter);
234    });
235}
236
237#[derive(Debug, serde::Serialize)]
238struct ActionDef {
239    name: &'static str,
240    human_name: String,
241    deprecated_aliases: &'static [&'static str],
242}
243
244fn dump_all_gpui_actions() -> Vec<ActionDef> {
245    let mut actions = gpui::generate_list_of_all_registered_actions()
246        .map(|action| ActionDef {
247            name: action.name,
248            human_name: command_palette::humanize_action_name(action.name),
249            deprecated_aliases: action.deprecated_aliases,
250        })
251        .collect::<Vec<ActionDef>>();
252
253    actions.sort_by_key(|a| a.name);
254
255    return actions;
256}