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 {
57 action_name: String,
58 },
59 DeprecatedActionUsed {
60 used: String,
61 should_be: String,
62 },
63 InvalidFrontmatterLine(String),
64 InvalidSettingsJson {
65 file: std::path::PathBuf,
66 line: usize,
67 snippet: String,
68 error: String,
69 },
70}
71
72impl PreprocessorError {
73 fn new_for_not_found_action(action_name: String) -> Self {
74 for action in &*ALL_ACTIONS {
75 for alias in action.deprecated_aliases {
76 if alias == &action_name {
77 return PreprocessorError::DeprecatedActionUsed {
78 used: action_name,
79 should_be: action.name.to_string(),
80 };
81 }
82 }
83 }
84 PreprocessorError::ActionNotFound { action_name }
85 }
86
87 fn new_for_invalid_settings_json(
88 chapter: &Chapter,
89 location: usize,
90 snippet: String,
91 error: String,
92 ) -> Self {
93 PreprocessorError::InvalidSettingsJson {
94 file: chapter.path.clone().expect("chapter has path"),
95 line: chapter.content[..location].lines().count() + 1,
96 snippet,
97 error,
98 }
99 }
100}
101
102impl std::fmt::Display for PreprocessorError {
103 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
104 match self {
105 PreprocessorError::InvalidFrontmatterLine(line) => {
106 write!(f, "Invalid frontmatter line: {}", line)
107 }
108 PreprocessorError::ActionNotFound { action_name } => {
109 write!(f, "Action not found: {}", action_name)
110 }
111 PreprocessorError::DeprecatedActionUsed { used, should_be } => write!(
112 f,
113 "Deprecated action used: {} should be {}",
114 used, should_be
115 ),
116 PreprocessorError::InvalidSettingsJson {
117 file,
118 line,
119 snippet,
120 error,
121 } => {
122 write!(
123 f,
124 "Invalid settings JSON at {}:{}\nError: {}\n\n{}",
125 file.display(),
126 line,
127 error,
128 snippet
129 )
130 }
131 }
132 }
133}
134
135fn handle_preprocessing() -> Result<()> {
136 let mut stdin = io::stdin();
137 let mut input = String::new();
138 stdin.read_to_string(&mut input)?;
139
140 let (_ctx, mut book) = CmdPreprocessor::parse_input(input.as_bytes())?;
141
142 let mut errors = HashSet::<PreprocessorError>::new();
143 handle_frontmatter(&mut book, &mut errors);
144 template_big_table_of_actions(&mut book);
145 template_and_validate_keybindings(&mut book, &mut errors);
146 template_and_validate_actions(&mut book, &mut errors);
147 template_and_validate_json_snippets(&mut book, &mut errors);
148
149 if !errors.is_empty() {
150 const ANSI_RED: &str = "\x1b[31m";
151 const ANSI_RESET: &str = "\x1b[0m";
152 for error in &errors {
153 eprintln!("{ANSI_RED}ERROR{ANSI_RESET}: {}", error);
154 }
155 return Err(anyhow::anyhow!("Found {} errors in docs", errors.len()));
156 }
157
158 serde_json::to_writer(io::stdout(), &book)?;
159
160 Ok(())
161}
162
163fn handle_frontmatter(book: &mut Book, errors: &mut HashSet<PreprocessorError>) {
164 let frontmatter_regex = Regex::new(r"(?s)^\s*---(.*?)---").unwrap();
165 for_each_chapter_mut(book, |chapter| {
166 let new_content = frontmatter_regex.replace(&chapter.content, |caps: ®ex::Captures| {
167 let frontmatter = caps[1].trim();
168 let frontmatter = frontmatter.trim_matches(&[' ', '-', '\n']);
169 let mut metadata = HashMap::<String, String>::default();
170 for line in frontmatter.lines() {
171 let Some((name, value)) = line.split_once(':') else {
172 errors.insert(PreprocessorError::InvalidFrontmatterLine(format!(
173 "{}: {}",
174 chapter_breadcrumbs(chapter),
175 line
176 )));
177 continue;
178 };
179 let name = name.trim();
180 let value = value.trim();
181 metadata.insert(name.to_string(), value.to_string());
182 }
183 FRONT_MATTER_COMMENT.replace(
184 "{}",
185 &serde_json::to_string(&metadata).expect("Failed to serialize metadata"),
186 )
187 });
188 if let Cow::Owned(content) = new_content {
189 chapter.content = content;
190 }
191 });
192}
193
194fn template_big_table_of_actions(book: &mut Book) {
195 for_each_chapter_mut(book, |chapter| {
196 let needle = "{#ACTIONS_TABLE#}";
197 if let Some(start) = chapter.content.rfind(needle) {
198 chapter.content.replace_range(
199 start..start + needle.len(),
200 &generate_big_table_of_actions(),
201 );
202 }
203 });
204}
205
206fn format_binding(binding: String) -> String {
207 binding.replace("\\", "\\\\")
208}
209
210fn template_and_validate_keybindings(book: &mut Book, errors: &mut HashSet<PreprocessorError>) {
211 let regex = Regex::new(r"\{#kb (.*?)\}").unwrap();
212
213 for_each_chapter_mut(book, |chapter| {
214 chapter.content = regex
215 .replace_all(&chapter.content, |caps: ®ex::Captures| {
216 let action = caps[1].trim();
217 if find_action_by_name(action).is_none() {
218 errors.insert(PreprocessorError::new_for_not_found_action(
219 action.to_string(),
220 ));
221 return String::new();
222 }
223 let macos_binding = find_binding("macos", action).unwrap_or_default();
224 let linux_binding = find_binding("linux", action).unwrap_or_default();
225
226 if macos_binding.is_empty() && linux_binding.is_empty() {
227 return "<div>No default binding</div>".to_string();
228 }
229
230 let formatted_macos_binding = format_binding(macos_binding);
231 let formatted_linux_binding = format_binding(linux_binding);
232
233 format!("<kbd class=\"keybinding\">{formatted_macos_binding}|{formatted_linux_binding}</kbd>")
234 })
235 .into_owned()
236 });
237}
238
239fn template_and_validate_actions(book: &mut Book, errors: &mut HashSet<PreprocessorError>) {
240 let regex = Regex::new(r"\{#action (.*?)\}").unwrap();
241
242 for_each_chapter_mut(book, |chapter| {
243 chapter.content = regex
244 .replace_all(&chapter.content, |caps: ®ex::Captures| {
245 let name = caps[1].trim();
246 let Some(action) = find_action_by_name(name) else {
247 errors.insert(PreprocessorError::new_for_not_found_action(
248 name.to_string(),
249 ));
250 return String::new();
251 };
252 format!("<code class=\"hljs\">{}</code>", &action.human_name)
253 })
254 .into_owned()
255 });
256}
257
258fn find_action_by_name(name: &str) -> Option<&ActionDef> {
259 ALL_ACTIONS
260 .binary_search_by(|action| action.name.cmp(name))
261 .ok()
262 .map(|index| &ALL_ACTIONS[index])
263}
264
265fn find_binding(os: &str, action: &str) -> Option<String> {
266 let keymap = match os {
267 "macos" => &KEYMAP_MACOS,
268 "linux" | "freebsd" => &KEYMAP_LINUX,
269 "windows" => &KEYMAP_WINDOWS,
270 _ => unreachable!("Not a valid OS: {}", os),
271 };
272
273 // Find the binding in reverse order, as the last binding takes precedence.
274 keymap.sections().rev().find_map(|section| {
275 section.bindings().rev().find_map(|(keystroke, a)| {
276 if name_for_action(a.to_string()) == action {
277 Some(keystroke.to_string())
278 } else {
279 None
280 }
281 })
282 })
283}
284
285fn template_and_validate_json_snippets(book: &mut Book, errors: &mut HashSet<PreprocessorError>) {
286 fn for_each_labeled_code_block_mut(
287 book: &mut Book,
288 errors: &mut HashSet<PreprocessorError>,
289 f: impl Fn(&str, &str) -> anyhow::Result<()>,
290 ) {
291 const TAGGED_JSON_BLOCK_START: &'static str = "```json [";
292 const JSON_BLOCK_END: &'static str = "```";
293
294 for_each_chapter_mut(book, |chapter| {
295 let mut offset = 0;
296 while let Some(loc) = chapter.content[offset..].find(TAGGED_JSON_BLOCK_START) {
297 let loc = loc + offset;
298 let tag_start = loc + TAGGED_JSON_BLOCK_START.len();
299 offset = tag_start;
300 let Some(tag_end) = chapter.content[tag_start..].find(']') else {
301 errors.insert(PreprocessorError::new_for_invalid_settings_json(
302 chapter,
303 loc,
304 chapter.content[loc..tag_start].to_string(),
305 "Unclosed JSON block tag".to_string(),
306 ));
307 continue;
308 };
309 let tag_end = tag_end + tag_start;
310
311 let tag = &chapter.content[tag_start..tag_end];
312
313 if tag.contains('\n') {
314 errors.insert(PreprocessorError::new_for_invalid_settings_json(
315 chapter,
316 loc,
317 chapter.content[loc..tag_start].to_string(),
318 "Unclosed JSON block tag".to_string(),
319 ));
320 continue;
321 }
322
323 let snippet_start = tag_end + 1;
324 offset = snippet_start;
325
326 let Some(snippet_end) = chapter.content[snippet_start..].find(JSON_BLOCK_END)
327 else {
328 errors.insert(PreprocessorError::new_for_invalid_settings_json(
329 chapter,
330 loc,
331 chapter.content[loc..tag_end + 1].to_string(),
332 "Missing closing code block".to_string(),
333 ));
334 continue;
335 };
336 let snippet_end = snippet_start + snippet_end;
337 let snippet_json = &chapter.content[snippet_start..snippet_end];
338 offset = snippet_end + 3;
339
340 if let Err(err) = f(tag, snippet_json) {
341 errors.insert(PreprocessorError::new_for_invalid_settings_json(
342 chapter,
343 loc,
344 chapter.content[loc..snippet_end + 3].to_string(),
345 err.to_string(),
346 ));
347 continue;
348 };
349 let tag_range_complete = tag_start - 1..tag_end + 1;
350 offset -= tag_range_complete.len();
351 chapter.content.replace_range(tag_range_complete, "");
352 }
353 });
354 }
355
356 for_each_labeled_code_block_mut(book, errors, |label, snippet_json| {
357 let mut snippet_json_fixed = snippet_json
358 .to_string()
359 .replace("\n>", "\n")
360 .trim()
361 .to_string();
362 while snippet_json_fixed.starts_with("//") {
363 if let Some(line_end) = snippet_json_fixed.find('\n') {
364 snippet_json_fixed.replace_range(0..line_end, "");
365 snippet_json_fixed = snippet_json_fixed.trim().to_string();
366 }
367 }
368 match label {
369 "settings" => {
370 if !snippet_json_fixed.starts_with('{') || !snippet_json_fixed.ends_with('}') {
371 snippet_json_fixed.insert(0, '{');
372 snippet_json_fixed.push_str("\n}");
373 }
374 settings::parse_json_with_comments::<settings::SettingsContent>(
375 &snippet_json_fixed,
376 )?;
377 }
378 "keymap" => {
379 if !snippet_json_fixed.starts_with('[') || !snippet_json_fixed.ends_with(']') {
380 snippet_json_fixed.insert(0, '[');
381 snippet_json_fixed.push_str("\n]");
382 }
383
384 let keymap = settings::KeymapFile::parse(&snippet_json_fixed)
385 .context("Failed to parse keymap JSON")?;
386 for section in keymap.sections() {
387 for (keystrokes, action) in section.bindings() {
388 keystrokes
389 .split_whitespace()
390 .map(|source| gpui::Keystroke::parse(source))
391 .collect::<std::result::Result<Vec<_>, _>>()
392 .context("Failed to parse keystroke")?;
393 if let Some((action_name, _)) = settings::KeymapFile::parse_action(action)
394 .map_err(|err| anyhow::format_err!(err))
395 .context("Failed to parse action")?
396 {
397 anyhow::ensure!(
398 find_action_by_name(action_name).is_some(),
399 "Action not found: {}",
400 action_name
401 );
402 }
403 }
404 }
405 }
406 "debug" => {
407 if !snippet_json_fixed.starts_with('[') || !snippet_json_fixed.ends_with(']') {
408 snippet_json_fixed.insert(0, '[');
409 snippet_json_fixed.push_str("\n]");
410 }
411
412 settings::parse_json_with_comments::<task::DebugTaskFile>(&snippet_json_fixed)?;
413 }
414 "tasks" => {
415 if !snippet_json_fixed.starts_with('[') || !snippet_json_fixed.ends_with(']') {
416 snippet_json_fixed.insert(0, '[');
417 snippet_json_fixed.push_str("\n]");
418 }
419
420 settings::parse_json_with_comments::<task::TaskTemplates>(&snippet_json_fixed)?;
421 }
422 "icon-theme" => {
423 if !snippet_json_fixed.starts_with('{') || !snippet_json_fixed.ends_with('}') {
424 snippet_json_fixed.insert(0, '{');
425 snippet_json_fixed.push_str("\n}");
426 }
427
428 settings::parse_json_with_comments::<theme::IconThemeFamilyContent>(
429 &snippet_json_fixed,
430 )?;
431 }
432 label => {
433 anyhow::bail!("Unexpected JSON code block tag: {}", label)
434 }
435 };
436 Ok(())
437 });
438}
439
440/// Removes any configurable options from the stringified action if existing,
441/// ensuring that only the actual action name is returned. If the action consists
442/// only of a string and nothing else, the string is returned as-is.
443///
444/// Example:
445///
446/// This will return the action name unmodified.
447///
448/// ```
449/// let action_as_str = "assistant::Assist";
450/// let action_name = name_for_action(action_as_str);
451/// assert_eq!(action_name, "assistant::Assist");
452/// ```
453///
454/// This will return the action name with any trailing options removed.
455///
456///
457/// ```
458/// let action_as_str = "\"editor::ToggleComments\", {\"advance_downwards\":false}";
459/// let action_name = name_for_action(action_as_str);
460/// assert_eq!(action_name, "editor::ToggleComments");
461/// ```
462fn name_for_action(action_as_str: String) -> String {
463 action_as_str
464 .split(",")
465 .next()
466 .map(|name| name.trim_matches('"').to_string())
467 .unwrap_or(action_as_str)
468}
469
470fn chapter_breadcrumbs(chapter: &Chapter) -> String {
471 let mut breadcrumbs = Vec::with_capacity(chapter.parent_names.len() + 1);
472 breadcrumbs.extend(chapter.parent_names.iter().map(String::as_str));
473 breadcrumbs.push(chapter.name.as_str());
474 format!("[{:?}] {}", chapter.source_path, breadcrumbs.join(" > "))
475}
476
477fn load_keymap(asset_path: &str) -> Result<KeymapFile> {
478 let content = util::asset_str::<settings::SettingsAssets>(asset_path);
479 KeymapFile::parse(content.as_ref())
480}
481
482fn for_each_chapter_mut<F>(book: &mut Book, mut func: F)
483where
484 F: FnMut(&mut Chapter),
485{
486 book.for_each_mut(|item| {
487 let BookItem::Chapter(chapter) = item else {
488 return;
489 };
490 func(chapter);
491 });
492}
493
494#[derive(Debug, serde::Serialize)]
495struct ActionDef {
496 name: &'static str,
497 human_name: String,
498 deprecated_aliases: &'static [&'static str],
499 docs: Option<&'static str>,
500}
501
502fn dump_all_gpui_actions() -> Vec<ActionDef> {
503 let mut actions = gpui::generate_list_of_all_registered_actions()
504 .map(|action| ActionDef {
505 name: action.name,
506 human_name: command_palette::humanize_action_name(action.name),
507 deprecated_aliases: action.deprecated_aliases,
508 docs: action.documentation,
509 })
510 .collect::<Vec<ActionDef>>();
511
512 actions.sort_by_key(|a| a.name);
513
514 actions
515}
516
517fn handle_postprocessing() -> Result<()> {
518 let logger = zlog::scoped!("render");
519 let mut ctx = mdbook::renderer::RenderContext::from_json(io::stdin())?;
520 let output = ctx
521 .config
522 .get_mut("output")
523 .expect("has output")
524 .as_table_mut()
525 .expect("output is table");
526 let zed_html = output.remove("zed-html").expect("zed-html output defined");
527 let default_description = zed_html
528 .get("default-description")
529 .expect("Default description not found")
530 .as_str()
531 .expect("Default description not a string")
532 .to_string();
533 let default_title = zed_html
534 .get("default-title")
535 .expect("Default title not found")
536 .as_str()
537 .expect("Default title not a string")
538 .to_string();
539 let amplitude_key = std::env::var("DOCS_AMPLITUDE_API_KEY").unwrap_or_default();
540
541 output.insert("html".to_string(), zed_html);
542 mdbook::Renderer::render(&mdbook::renderer::HtmlHandlebars::new(), &ctx)?;
543 let ignore_list = ["toc.html"];
544
545 let root_dir = ctx.destination.clone();
546 let mut files = Vec::with_capacity(128);
547 let mut queue = Vec::with_capacity(64);
548 queue.push(root_dir.clone());
549 while let Some(dir) = queue.pop() {
550 for entry in std::fs::read_dir(&dir).context("failed to read docs dir")? {
551 let Ok(entry) = entry else {
552 continue;
553 };
554 let file_type = entry.file_type().context("Failed to determine file type")?;
555 if file_type.is_dir() {
556 queue.push(entry.path());
557 }
558 if file_type.is_file()
559 && matches!(
560 entry.path().extension().and_then(std::ffi::OsStr::to_str),
561 Some("html")
562 )
563 {
564 if ignore_list.contains(&&*entry.file_name().to_string_lossy()) {
565 zlog::info!(logger => "Ignoring {}", entry.path().to_string_lossy());
566 } else {
567 files.push(entry.path());
568 }
569 }
570 }
571 }
572
573 zlog::info!(logger => "Processing {} `.html` files", files.len());
574 let meta_regex = Regex::new(&FRONT_MATTER_COMMENT.replace("{}", "(.*)")).unwrap();
575 for file in files {
576 let contents = std::fs::read_to_string(&file)?;
577 let mut meta_description = None;
578 let mut meta_title = None;
579 let contents = meta_regex.replace(&contents, |caps: ®ex::Captures| {
580 let metadata: HashMap<String, String> = serde_json::from_str(&caps[1]).with_context(|| format!("JSON Metadata: {:?}", &caps[1])).expect("Failed to deserialize metadata");
581 for (kind, content) in metadata {
582 match kind.as_str() {
583 "description" => {
584 meta_description = Some(content);
585 }
586 "title" => {
587 meta_title = Some(content);
588 }
589 _ => {
590 zlog::warn!(logger => "Unrecognized frontmatter key: {} in {:?}", kind, pretty_path(&file, &root_dir));
591 }
592 }
593 }
594 String::new()
595 });
596 let meta_description = meta_description.as_ref().unwrap_or_else(|| {
597 zlog::warn!(logger => "No meta description found for {:?}", pretty_path(&file, &root_dir));
598 &default_description
599 });
600 let page_title = extract_title_from_page(&contents, pretty_path(&file, &root_dir));
601 let meta_title = meta_title.as_ref().unwrap_or_else(|| {
602 zlog::debug!(logger => "No meta title found for {:?}", pretty_path(&file, &root_dir));
603 &default_title
604 });
605 let meta_title = format!("{} | {}", page_title, meta_title);
606 zlog::trace!(logger => "Updating {:?}", pretty_path(&file, &root_dir));
607 let contents = contents.replace("#description#", meta_description);
608 let contents = contents.replace("#amplitude_key#", &litude_key);
609 let contents = title_regex()
610 .replace(&contents, |_: ®ex::Captures| {
611 format!("<title>{}</title>", meta_title)
612 })
613 .to_string();
614 // let contents = contents.replace("#title#", &meta_title);
615 std::fs::write(file, contents)?;
616 }
617 return Ok(());
618
619 fn pretty_path<'a>(
620 path: &'a std::path::PathBuf,
621 root: &'a std::path::PathBuf,
622 ) -> &'a std::path::Path {
623 path.strip_prefix(&root).unwrap_or(path)
624 }
625 fn extract_title_from_page(contents: &str, pretty_path: &std::path::Path) -> String {
626 let title_tag_contents = &title_regex()
627 .captures(contents)
628 .with_context(|| format!("Failed to find title in {:?}", pretty_path))
629 .expect("Page has <title> element")[1];
630
631 title_tag_contents
632 .trim()
633 .strip_suffix("- Zed")
634 .unwrap_or(title_tag_contents)
635 .trim()
636 .to_string()
637 }
638}
639
640fn title_regex() -> &'static Regex {
641 static TITLE_REGEX: OnceLock<Regex> = OnceLock::new();
642 TITLE_REGEX.get_or_init(|| Regex::new(r"<title>\s*(.*?)\s*</title>").unwrap())
643}
644
645fn generate_big_table_of_actions() -> String {
646 let actions = &*ALL_ACTIONS;
647 let mut output = String::new();
648
649 let mut actions_sorted = actions.iter().collect::<Vec<_>>();
650 actions_sorted.sort_by_key(|a| a.name);
651
652 // Start the definition list with custom styling for better spacing
653 output.push_str("<dl style=\"line-height: 1.8;\">\n");
654
655 for action in actions_sorted.into_iter() {
656 // Add the humanized action name as the term with margin
657 output.push_str(
658 "<dt style=\"margin-top: 1.5em; margin-bottom: 0.5em; font-weight: bold;\"><code>",
659 );
660 output.push_str(&action.human_name);
661 output.push_str("</code></dt>\n");
662
663 // Add the definition with keymap name and description
664 output.push_str("<dd style=\"margin-left: 2em; margin-bottom: 1em;\">\n");
665
666 // Add the description, escaping HTML if needed
667 if let Some(description) = action.docs {
668 output.push_str(
669 &description
670 .replace("&", "&")
671 .replace("<", "<")
672 .replace(">", ">"),
673 );
674 output.push_str("<br>\n");
675 }
676 output.push_str("Keymap Name: <code>");
677 output.push_str(action.name);
678 output.push_str("</code><br>\n");
679 if !action.deprecated_aliases.is_empty() {
680 output.push_str("Deprecated Alias(es): ");
681 for alias in action.deprecated_aliases.iter() {
682 output.push_str("<code>");
683 output.push_str(alias);
684 output.push_str("</code>, ");
685 }
686 }
687 output.push_str("\n</dd>\n");
688 }
689
690 // Close the definition list
691 output.push_str("</dl>\n");
692
693 output
694}