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