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