1use anyhow::{Context, Result};
2use mdbook::BookItem;
3use mdbook::book::{Book, Chapter};
4use mdbook::preprocess::CmdPreprocessor;
5use regex::Regex;
6use settings::KeymapFile;
7use std::borrow::Cow;
8use std::collections::{HashMap, HashSet};
9use std::io::{self, Read};
10use std::process;
11use std::sync::{LazyLock, OnceLock};
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 KEYMAP_WINDOWS: LazyLock<KeymapFile> = LazyLock::new(|| {
22 load_keymap("keymaps/default-windows.json").expect("Failed to load Windows keymap")
23});
24
25static ALL_ACTIONS: LazyLock<Vec<ActionDef>> = LazyLock::new(dump_all_gpui_actions);
26
27const FRONT_MATTER_COMMENT: &str = "<!-- ZED_META {} -->";
28
29fn main() -> Result<()> {
30 zlog::init();
31 zlog::init_output_stderr();
32 // call a zed:: function so everything in `zed` crate is linked and
33 // all actions in the actual app are registered
34 zed::stdout_is_a_pty();
35 let args = std::env::args().skip(1).collect::<Vec<_>>();
36
37 match args.get(0).map(String::as_str) {
38 Some("supports") => {
39 let renderer = args.get(1).expect("Required argument");
40 let supported = renderer != "not-supported";
41 if supported {
42 process::exit(0);
43 } else {
44 process::exit(1);
45 }
46 }
47 Some("postprocess") => handle_postprocessing()?,
48 _ => handle_preprocessing()?,
49 }
50
51 Ok(())
52}
53
54#[derive(Debug, Clone, PartialEq, Eq, Hash)]
55enum PreprocessorError {
56 ActionNotFound { action_name: String },
57 DeprecatedActionUsed { used: String, should_be: String },
58 InvalidFrontmatterLine(String),
59}
60
61impl PreprocessorError {
62 fn new_for_not_found_action(action_name: String) -> Self {
63 for action in &*ALL_ACTIONS {
64 for alias in action.deprecated_aliases {
65 if alias == &action_name {
66 return PreprocessorError::DeprecatedActionUsed {
67 used: action_name,
68 should_be: action.name.to_string(),
69 };
70 }
71 }
72 }
73 PreprocessorError::ActionNotFound { action_name }
74 }
75}
76
77impl std::fmt::Display for PreprocessorError {
78 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
79 match self {
80 PreprocessorError::InvalidFrontmatterLine(line) => {
81 write!(f, "Invalid frontmatter line: {}", line)
82 }
83 PreprocessorError::ActionNotFound { action_name } => {
84 write!(f, "Action not found: {}", action_name)
85 }
86 PreprocessorError::DeprecatedActionUsed { used, should_be } => write!(
87 f,
88 "Deprecated action used: {} should be {}",
89 used, should_be
90 ),
91 }
92 }
93}
94
95fn handle_preprocessing() -> Result<()> {
96 let mut stdin = io::stdin();
97 let mut input = String::new();
98 stdin.read_to_string(&mut input)?;
99
100 let (_ctx, mut book) = CmdPreprocessor::parse_input(input.as_bytes())?;
101
102 let mut errors = HashSet::<PreprocessorError>::new();
103
104 handle_frontmatter(&mut book, &mut errors);
105 template_big_table_of_actions(&mut book);
106 template_and_validate_keybindings(&mut book, &mut errors);
107 template_and_validate_actions(&mut book, &mut errors);
108
109 if !errors.is_empty() {
110 const ANSI_RED: &str = "\x1b[31m";
111 const ANSI_RESET: &str = "\x1b[0m";
112 for error in &errors {
113 eprintln!("{ANSI_RED}ERROR{ANSI_RESET}: {}", error);
114 }
115 return Err(anyhow::anyhow!("Found {} errors in docs", errors.len()));
116 }
117
118 serde_json::to_writer(io::stdout(), &book)?;
119
120 Ok(())
121}
122
123fn handle_frontmatter(book: &mut Book, errors: &mut HashSet<PreprocessorError>) {
124 let frontmatter_regex = Regex::new(r"(?s)^\s*---(.*?)---").unwrap();
125 for_each_chapter_mut(book, |chapter| {
126 let new_content = frontmatter_regex.replace(&chapter.content, |caps: ®ex::Captures| {
127 let frontmatter = caps[1].trim();
128 let frontmatter = frontmatter.trim_matches(&[' ', '-', '\n']);
129 let mut metadata = HashMap::<String, String>::default();
130 for line in frontmatter.lines() {
131 let Some((name, value)) = line.split_once(':') else {
132 errors.insert(PreprocessorError::InvalidFrontmatterLine(format!(
133 "{}: {}",
134 chapter_breadcrumbs(chapter),
135 line
136 )));
137 continue;
138 };
139 let name = name.trim();
140 let value = value.trim();
141 metadata.insert(name.to_string(), value.to_string());
142 }
143 FRONT_MATTER_COMMENT.replace(
144 "{}",
145 &serde_json::to_string(&metadata).expect("Failed to serialize metadata"),
146 )
147 });
148 if let Cow::Owned(content) = new_content {
149 chapter.content = content;
150 }
151 });
152}
153
154fn template_big_table_of_actions(book: &mut Book) {
155 for_each_chapter_mut(book, |chapter| {
156 let needle = "{#ACTIONS_TABLE#}";
157 if let Some(start) = chapter.content.rfind(needle) {
158 chapter.content.replace_range(
159 start..start + needle.len(),
160 &generate_big_table_of_actions(),
161 );
162 }
163 });
164}
165
166fn template_and_validate_keybindings(book: &mut Book, errors: &mut HashSet<PreprocessorError>) {
167 let regex = Regex::new(r"\{#kb (.*?)\}").unwrap();
168
169 for_each_chapter_mut(book, |chapter| {
170 chapter.content = regex
171 .replace_all(&chapter.content, |caps: ®ex::Captures| {
172 let action = caps[1].trim();
173 if find_action_by_name(action).is_none() {
174 errors.insert(PreprocessorError::new_for_not_found_action(
175 action.to_string(),
176 ));
177 return String::new();
178 }
179 let macos_binding = find_binding("macos", action).unwrap_or_default();
180 let linux_binding = find_binding("linux", action).unwrap_or_default();
181
182 if macos_binding.is_empty() && linux_binding.is_empty() {
183 return "<div>No default binding</div>".to_string();
184 }
185
186 format!("<kbd class=\"keybinding\">{macos_binding}|{linux_binding}</kbd>")
187 })
188 .into_owned()
189 });
190}
191
192fn template_and_validate_actions(book: &mut Book, errors: &mut HashSet<PreprocessorError>) {
193 let regex = Regex::new(r"\{#action (.*?)\}").unwrap();
194
195 for_each_chapter_mut(book, |chapter| {
196 chapter.content = regex
197 .replace_all(&chapter.content, |caps: ®ex::Captures| {
198 let name = caps[1].trim();
199 let Some(action) = find_action_by_name(name) else {
200 errors.insert(PreprocessorError::new_for_not_found_action(
201 name.to_string(),
202 ));
203 return String::new();
204 };
205 format!("<code class=\"hljs\">{}</code>", &action.human_name)
206 })
207 .into_owned()
208 });
209}
210
211fn find_action_by_name(name: &str) -> Option<&ActionDef> {
212 ALL_ACTIONS
213 .binary_search_by(|action| action.name.cmp(name))
214 .ok()
215 .map(|index| &ALL_ACTIONS[index])
216}
217
218fn find_binding(os: &str, action: &str) -> Option<String> {
219 let keymap = match os {
220 "macos" => &KEYMAP_MACOS,
221 "linux" | "freebsd" => &KEYMAP_LINUX,
222 "windows" => &KEYMAP_WINDOWS,
223 _ => unreachable!("Not a valid OS: {}", os),
224 };
225
226 // Find the binding in reverse order, as the last binding takes precedence.
227 keymap.sections().rev().find_map(|section| {
228 section.bindings().rev().find_map(|(keystroke, a)| {
229 if name_for_action(a.to_string()) == action {
230 Some(keystroke.to_string())
231 } else {
232 None
233 }
234 })
235 })
236}
237
238/// Removes any configurable options from the stringified action if existing,
239/// ensuring that only the actual action name is returned. If the action consists
240/// only of a string and nothing else, the string is returned as-is.
241///
242/// Example:
243///
244/// This will return the action name unmodified.
245///
246/// ```
247/// let action_as_str = "assistant::Assist";
248/// let action_name = name_for_action(action_as_str);
249/// assert_eq!(action_name, "assistant::Assist");
250/// ```
251///
252/// This will return the action name with any trailing options removed.
253///
254///
255/// ```
256/// let action_as_str = "\"editor::ToggleComments\", {\"advance_downwards\":false}";
257/// let action_name = name_for_action(action_as_str);
258/// assert_eq!(action_name, "editor::ToggleComments");
259/// ```
260fn name_for_action(action_as_str: String) -> String {
261 action_as_str
262 .split(",")
263 .next()
264 .map(|name| name.trim_matches('"').to_string())
265 .unwrap_or(action_as_str)
266}
267
268fn chapter_breadcrumbs(chapter: &Chapter) -> String {
269 let mut breadcrumbs = Vec::with_capacity(chapter.parent_names.len() + 1);
270 breadcrumbs.extend(chapter.parent_names.iter().map(String::as_str));
271 breadcrumbs.push(chapter.name.as_str());
272 format!("[{:?}] {}", chapter.source_path, breadcrumbs.join(" > "))
273}
274
275fn load_keymap(asset_path: &str) -> Result<KeymapFile> {
276 let content = util::asset_str::<settings::SettingsAssets>(asset_path);
277 KeymapFile::parse(content.as_ref())
278}
279
280fn for_each_chapter_mut<F>(book: &mut Book, mut func: F)
281where
282 F: FnMut(&mut Chapter),
283{
284 book.for_each_mut(|item| {
285 let BookItem::Chapter(chapter) = item else {
286 return;
287 };
288 func(chapter);
289 });
290}
291
292#[derive(Debug, serde::Serialize)]
293struct ActionDef {
294 name: &'static str,
295 human_name: String,
296 deprecated_aliases: &'static [&'static str],
297 docs: Option<&'static str>,
298}
299
300fn dump_all_gpui_actions() -> Vec<ActionDef> {
301 let mut actions = gpui::generate_list_of_all_registered_actions()
302 .map(|action| ActionDef {
303 name: action.name,
304 human_name: command_palette::humanize_action_name(action.name),
305 deprecated_aliases: action.deprecated_aliases,
306 docs: action.documentation,
307 })
308 .collect::<Vec<ActionDef>>();
309
310 actions.sort_by_key(|a| a.name);
311
312 actions
313}
314
315fn handle_postprocessing() -> Result<()> {
316 let logger = zlog::scoped!("render");
317 let mut ctx = mdbook::renderer::RenderContext::from_json(io::stdin())?;
318 let output = ctx
319 .config
320 .get_mut("output")
321 .expect("has output")
322 .as_table_mut()
323 .expect("output is table");
324 let zed_html = output.remove("zed-html").expect("zed-html output defined");
325 let default_description = zed_html
326 .get("default-description")
327 .expect("Default description not found")
328 .as_str()
329 .expect("Default description not a string")
330 .to_string();
331 let default_title = zed_html
332 .get("default-title")
333 .expect("Default title not found")
334 .as_str()
335 .expect("Default title not a string")
336 .to_string();
337
338 output.insert("html".to_string(), zed_html);
339 mdbook::Renderer::render(&mdbook::renderer::HtmlHandlebars::new(), &ctx)?;
340 let ignore_list = ["toc.html"];
341
342 let root_dir = ctx.destination.clone();
343 let mut files = Vec::with_capacity(128);
344 let mut queue = Vec::with_capacity(64);
345 queue.push(root_dir.clone());
346 while let Some(dir) = queue.pop() {
347 for entry in std::fs::read_dir(&dir).context("failed to read docs dir")? {
348 let Ok(entry) = entry else {
349 continue;
350 };
351 let file_type = entry.file_type().context("Failed to determine file type")?;
352 if file_type.is_dir() {
353 queue.push(entry.path());
354 }
355 if file_type.is_file()
356 && matches!(
357 entry.path().extension().and_then(std::ffi::OsStr::to_str),
358 Some("html")
359 )
360 {
361 if ignore_list.contains(&&*entry.file_name().to_string_lossy()) {
362 zlog::info!(logger => "Ignoring {}", entry.path().to_string_lossy());
363 } else {
364 files.push(entry.path());
365 }
366 }
367 }
368 }
369
370 zlog::info!(logger => "Processing {} `.html` files", files.len());
371 let meta_regex = Regex::new(&FRONT_MATTER_COMMENT.replace("{}", "(.*)")).unwrap();
372 for file in files {
373 let contents = std::fs::read_to_string(&file)?;
374 let mut meta_description = None;
375 let mut meta_title = None;
376 let contents = meta_regex.replace(&contents, |caps: ®ex::Captures| {
377 let metadata: HashMap<String, String> = serde_json::from_str(&caps[1]).with_context(|| format!("JSON Metadata: {:?}", &caps[1])).expect("Failed to deserialize metadata");
378 for (kind, content) in metadata {
379 match kind.as_str() {
380 "description" => {
381 meta_description = Some(content);
382 }
383 "title" => {
384 meta_title = Some(content);
385 }
386 _ => {
387 zlog::warn!(logger => "Unrecognized frontmatter key: {} in {:?}", kind, pretty_path(&file, &root_dir));
388 }
389 }
390 }
391 String::new()
392 });
393 let meta_description = meta_description.as_ref().unwrap_or_else(|| {
394 zlog::warn!(logger => "No meta description found for {:?}", pretty_path(&file, &root_dir));
395 &default_description
396 });
397 let page_title = extract_title_from_page(&contents, pretty_path(&file, &root_dir));
398 let meta_title = meta_title.as_ref().unwrap_or_else(|| {
399 zlog::debug!(logger => "No meta title found for {:?}", pretty_path(&file, &root_dir));
400 &default_title
401 });
402 let meta_title = format!("{} | {}", page_title, meta_title);
403 zlog::trace!(logger => "Updating {:?}", pretty_path(&file, &root_dir));
404 let contents = contents.replace("#description#", meta_description);
405 let contents = title_regex()
406 .replace(&contents, |_: ®ex::Captures| {
407 format!("<title>{}</title>", meta_title)
408 })
409 .to_string();
410 // let contents = contents.replace("#title#", &meta_title);
411 std::fs::write(file, contents)?;
412 }
413 return Ok(());
414
415 fn pretty_path<'a>(
416 path: &'a std::path::PathBuf,
417 root: &'a std::path::PathBuf,
418 ) -> &'a std::path::Path {
419 path.strip_prefix(&root).unwrap_or(path)
420 }
421 fn extract_title_from_page(contents: &str, pretty_path: &std::path::Path) -> String {
422 let title_tag_contents = &title_regex()
423 .captures(contents)
424 .with_context(|| format!("Failed to find title in {:?}", pretty_path))
425 .expect("Page has <title> element")[1];
426
427 title_tag_contents
428 .trim()
429 .strip_suffix("- Zed")
430 .unwrap_or(title_tag_contents)
431 .trim()
432 .to_string()
433 }
434}
435
436fn title_regex() -> &'static Regex {
437 static TITLE_REGEX: OnceLock<Regex> = OnceLock::new();
438 TITLE_REGEX.get_or_init(|| Regex::new(r"<title>\s*(.*?)\s*</title>").unwrap())
439}
440
441fn generate_big_table_of_actions() -> String {
442 let actions = &*ALL_ACTIONS;
443 let mut output = String::new();
444
445 let mut actions_sorted = actions.iter().collect::<Vec<_>>();
446 actions_sorted.sort_by_key(|a| a.name);
447
448 // Start the definition list with custom styling for better spacing
449 output.push_str("<dl style=\"line-height: 1.8;\">\n");
450
451 for action in actions_sorted.into_iter() {
452 // Add the humanized action name as the term with margin
453 output.push_str(
454 "<dt style=\"margin-top: 1.5em; margin-bottom: 0.5em; font-weight: bold;\"><code>",
455 );
456 output.push_str(&action.human_name);
457 output.push_str("</code></dt>\n");
458
459 // Add the definition with keymap name and description
460 output.push_str("<dd style=\"margin-left: 2em; margin-bottom: 1em;\">\n");
461
462 // Add the description, escaping HTML if needed
463 if let Some(description) = action.docs {
464 output.push_str(
465 &description
466 .replace("&", "&")
467 .replace("<", "<")
468 .replace(">", ">"),
469 );
470 output.push_str("<br>\n");
471 }
472 output.push_str("Keymap Name: <code>");
473 output.push_str(action.name);
474 output.push_str("</code><br>\n");
475 if !action.deprecated_aliases.is_empty() {
476 output.push_str("Deprecated Alias(es): ");
477 for alias in action.deprecated_aliases.iter() {
478 output.push_str("<code>");
479 output.push_str(alias);
480 output.push_str("</code>, ");
481 }
482 }
483 output.push_str("\n</dd>\n");
484 }
485
486 // Close the definition list
487 output.push_str("</dl>\n");
488
489 output
490}