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