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