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: ®ex::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: ®ex::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}