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 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_and_validate_keybindings(&mut book, &mut errors);
103    template_and_validate_actions(&mut book, &mut errors);
104
105    if !errors.is_empty() {
106        const ANSI_RED: &str = "\x1b[31m";
107        const ANSI_RESET: &str = "\x1b[0m";
108        for error in &errors {
109            eprintln!("{ANSI_RED}ERROR{ANSI_RESET}: {}", error);
110        }
111        return Err(anyhow::anyhow!("Found {} errors in docs", errors.len()));
112    }
113
114    serde_json::to_writer(io::stdout(), &book)?;
115
116    Ok(())
117}
118
119fn handle_frontmatter(book: &mut Book, errors: &mut HashSet<PreprocessorError>) {
120    let frontmatter_regex = Regex::new(r"(?s)^\s*---(.*?)---").unwrap();
121    for_each_chapter_mut(book, |chapter| {
122        let new_content = frontmatter_regex.replace(&chapter.content, |caps: &regex::Captures| {
123            let frontmatter = caps[1].trim();
124            let frontmatter = frontmatter.trim_matches(&[' ', '-', '\n']);
125            let mut metadata = HashMap::<String, String>::default();
126            for line in frontmatter.lines() {
127                let Some((name, value)) = line.split_once(':') else {
128                    errors.insert(PreprocessorError::InvalidFrontmatterLine(format!(
129                        "{}: {}",
130                        chapter_breadcrumbs(chapter),
131                        line
132                    )));
133                    continue;
134                };
135                let name = name.trim();
136                let value = value.trim();
137                metadata.insert(name.to_string(), value.to_string());
138            }
139            FRONT_MATTER_COMMENT.replace(
140                "{}",
141                &serde_json::to_string(&metadata).expect("Failed to serialize metadata"),
142            )
143        });
144        if let Cow::Owned(content) = new_content {
145            chapter.content = content;
146        }
147    });
148}
149
150fn template_and_validate_keybindings(book: &mut Book, errors: &mut HashSet<PreprocessorError>) {
151    let regex = Regex::new(r"\{#kb (.*?)\}").unwrap();
152
153    for_each_chapter_mut(book, |chapter| {
154        chapter.content = regex
155            .replace_all(&chapter.content, |caps: &regex::Captures| {
156                let action = caps[1].trim();
157                if find_action_by_name(action).is_none() {
158                    errors.insert(PreprocessorError::new_for_not_found_action(
159                        action.to_string(),
160                    ));
161                    return String::new();
162                }
163                let macos_binding = find_binding("macos", action).unwrap_or_default();
164                let linux_binding = find_binding("linux", action).unwrap_or_default();
165
166                if macos_binding.is_empty() && linux_binding.is_empty() {
167                    return "<div>No default binding</div>".to_string();
168                }
169
170                format!("<kbd class=\"keybinding\">{macos_binding}|{linux_binding}</kbd>")
171            })
172            .into_owned()
173    });
174}
175
176fn template_and_validate_actions(book: &mut Book, errors: &mut HashSet<PreprocessorError>) {
177    let regex = Regex::new(r"\{#action (.*?)\}").unwrap();
178
179    for_each_chapter_mut(book, |chapter| {
180        chapter.content = regex
181            .replace_all(&chapter.content, |caps: &regex::Captures| {
182                let name = caps[1].trim();
183                let Some(action) = find_action_by_name(name) else {
184                    errors.insert(PreprocessorError::new_for_not_found_action(
185                        name.to_string(),
186                    ));
187                    return String::new();
188                };
189                format!("<code class=\"hljs\">{}</code>", &action.human_name)
190            })
191            .into_owned()
192    });
193}
194
195fn find_action_by_name(name: &str) -> Option<&ActionDef> {
196    ALL_ACTIONS
197        .binary_search_by(|action| action.name.cmp(name))
198        .ok()
199        .map(|index| &ALL_ACTIONS[index])
200}
201
202fn find_binding(os: &str, action: &str) -> Option<String> {
203    let keymap = match os {
204        "macos" => &KEYMAP_MACOS,
205        "linux" | "freebsd" => &KEYMAP_LINUX,
206        _ => unreachable!("Not a valid OS: {}", os),
207    };
208
209    // Find the binding in reverse order, as the last binding takes precedence.
210    keymap.sections().rev().find_map(|section| {
211        section.bindings().rev().find_map(|(keystroke, a)| {
212            if name_for_action(a.to_string()) == action {
213                Some(keystroke.to_string())
214            } else {
215                None
216            }
217        })
218    })
219}
220
221/// Removes any configurable options from the stringified action if existing,
222/// ensuring that only the actual action name is returned. If the action consists
223/// only of a string and nothing else, the string is returned as-is.
224///
225/// Example:
226///
227/// This will return the action name unmodified.
228///
229/// ```
230/// let action_as_str = "assistant::Assist";
231/// let action_name = name_for_action(action_as_str);
232/// assert_eq!(action_name, "assistant::Assist");
233/// ```
234///
235/// This will return the action name with any trailing options removed.
236///
237///
238/// ```
239/// let action_as_str = "\"editor::ToggleComments\", {\"advance_downwards\":false}";
240/// let action_name = name_for_action(action_as_str);
241/// assert_eq!(action_name, "editor::ToggleComments");
242/// ```
243fn name_for_action(action_as_str: String) -> String {
244    action_as_str
245        .split(",")
246        .next()
247        .map(|name| name.trim_matches('"').to_string())
248        .unwrap_or(action_as_str)
249}
250
251fn chapter_breadcrumbs(chapter: &Chapter) -> String {
252    let mut breadcrumbs = Vec::with_capacity(chapter.parent_names.len() + 1);
253    breadcrumbs.extend(chapter.parent_names.iter().map(String::as_str));
254    breadcrumbs.push(chapter.name.as_str());
255    format!("[{:?}] {}", chapter.source_path, breadcrumbs.join(" > "))
256}
257
258fn load_keymap(asset_path: &str) -> Result<KeymapFile> {
259    let content = util::asset_str::<settings::SettingsAssets>(asset_path);
260    KeymapFile::parse(content.as_ref())
261}
262
263fn for_each_chapter_mut<F>(book: &mut Book, mut func: F)
264where
265    F: FnMut(&mut Chapter),
266{
267    book.for_each_mut(|item| {
268        let BookItem::Chapter(chapter) = item else {
269            return;
270        };
271        func(chapter);
272    });
273}
274
275#[derive(Debug, serde::Serialize)]
276struct ActionDef {
277    name: &'static str,
278    human_name: String,
279    deprecated_aliases: &'static [&'static str],
280}
281
282fn dump_all_gpui_actions() -> Vec<ActionDef> {
283    let mut actions = gpui::generate_list_of_all_registered_actions()
284        .map(|action| ActionDef {
285            name: action.name,
286            human_name: command_palette::humanize_action_name(action.name),
287            deprecated_aliases: action.deprecated_aliases,
288        })
289        .collect::<Vec<ActionDef>>();
290
291    actions.sort_by_key(|a| a.name);
292
293    actions
294}
295
296fn handle_postprocessing() -> Result<()> {
297    let logger = zlog::scoped!("render");
298    let mut ctx = mdbook::renderer::RenderContext::from_json(io::stdin())?;
299    let output = ctx
300        .config
301        .get_mut("output")
302        .expect("has output")
303        .as_table_mut()
304        .expect("output is table");
305    let zed_html = output.remove("zed-html").expect("zed-html output defined");
306    let default_description = zed_html
307        .get("default-description")
308        .expect("Default description not found")
309        .as_str()
310        .expect("Default description not a string")
311        .to_string();
312    let default_title = zed_html
313        .get("default-title")
314        .expect("Default title not found")
315        .as_str()
316        .expect("Default title not a string")
317        .to_string();
318
319    output.insert("html".to_string(), zed_html);
320    mdbook::Renderer::render(&mdbook::renderer::HtmlHandlebars::new(), &ctx)?;
321    let ignore_list = ["toc.html"];
322
323    let root_dir = ctx.destination.clone();
324    let mut files = Vec::with_capacity(128);
325    let mut queue = Vec::with_capacity(64);
326    queue.push(root_dir.clone());
327    while let Some(dir) = queue.pop() {
328        for entry in std::fs::read_dir(&dir).context(dir.to_sanitized_string())? {
329            let Ok(entry) = entry else {
330                continue;
331            };
332            let file_type = entry.file_type().context("Failed to determine file type")?;
333            if file_type.is_dir() {
334                queue.push(entry.path());
335            }
336            if file_type.is_file()
337                && matches!(
338                    entry.path().extension().and_then(std::ffi::OsStr::to_str),
339                    Some("html")
340                )
341            {
342                if ignore_list.contains(&&*entry.file_name().to_string_lossy()) {
343                    zlog::info!(logger => "Ignoring {}", entry.path().to_string_lossy());
344                } else {
345                    files.push(entry.path());
346                }
347            }
348        }
349    }
350
351    zlog::info!(logger => "Processing {} `.html` files", files.len());
352    let meta_regex = Regex::new(&FRONT_MATTER_COMMENT.replace("{}", "(.*)")).unwrap();
353    for file in files {
354        let contents = std::fs::read_to_string(&file)?;
355        let mut meta_description = None;
356        let mut meta_title = None;
357        let contents = meta_regex.replace(&contents, |caps: &regex::Captures| {
358            let metadata: HashMap<String, String> = serde_json::from_str(&caps[1]).with_context(|| format!("JSON Metadata: {:?}", &caps[1])).expect("Failed to deserialize metadata");
359            for (kind, content) in metadata {
360                match kind.as_str() {
361                    "description" => {
362                        meta_description = Some(content);
363                    }
364                    "title" => {
365                        meta_title = Some(content);
366                    }
367                    _ => {
368                        zlog::warn!(logger => "Unrecognized frontmatter key: {} in {:?}", kind, pretty_path(&file, &root_dir));
369                    }
370                }
371            }
372            String::new()
373        });
374        let meta_description = meta_description.as_ref().unwrap_or_else(|| {
375            zlog::warn!(logger => "No meta description found for {:?}", pretty_path(&file, &root_dir));
376            &default_description
377        });
378        let page_title = extract_title_from_page(&contents, pretty_path(&file, &root_dir));
379        let meta_title = meta_title.as_ref().unwrap_or_else(|| {
380            zlog::debug!(logger => "No meta title found for {:?}", pretty_path(&file, &root_dir));
381            &default_title
382        });
383        let meta_title = format!("{} | {}", page_title, meta_title);
384        zlog::trace!(logger => "Updating {:?}", pretty_path(&file, &root_dir));
385        let contents = contents.replace("#description#", meta_description);
386        let contents = title_regex()
387            .replace(&contents, |_: &regex::Captures| {
388                format!("<title>{}</title>", meta_title)
389            })
390            .to_string();
391        // let contents = contents.replace("#title#", &meta_title);
392        std::fs::write(file, contents)?;
393    }
394    return Ok(());
395
396    fn pretty_path<'a>(
397        path: &'a std::path::PathBuf,
398        root: &'a std::path::PathBuf,
399    ) -> &'a std::path::Path {
400        path.strip_prefix(&root).unwrap_or(path)
401    }
402    fn extract_title_from_page(contents: &str, pretty_path: &std::path::Path) -> String {
403        let title_tag_contents = &title_regex()
404            .captures(contents)
405            .with_context(|| format!("Failed to find title in {:?}", pretty_path))
406            .expect("Page has <title> element")[1];
407
408        title_tag_contents
409            .trim()
410            .strip_suffix("- Zed")
411            .unwrap_or(title_tag_contents)
412            .trim()
413            .to_string()
414    }
415}
416
417fn title_regex() -> &'static Regex {
418    static TITLE_REGEX: OnceLock<Regex> = OnceLock::new();
419    TITLE_REGEX.get_or_init(|| Regex::new(r"<title>\s*(.*?)\s*</title>").unwrap())
420}