main.rs

  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: &regex::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: &regex::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: &regex::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: &regex::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, |_: &regex::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("&", "&amp;")
468                    .replace("<", "&lt;")
469                    .replace(">", "&gt;"),
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}