main.rs

  1use anyhow::{Context, Result};
  2use mdbook::BookItem;
  3use mdbook::book::{Book, Chapter};
  4use mdbook::preprocess::CmdPreprocessor;
  5use regex::Regex;
  6use settings::{KeymapFile, SettingsJsonSchemaParams, SettingsStore};
  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 KEYMAP_JETBRAINS_MACOS: LazyLock<KeymapFile> = LazyLock::new(|| {
 26    load_keymap("keymaps/macos/jetbrains.json").expect("Failed to load JetBrains macOS keymap")
 27});
 28
 29static KEYMAP_JETBRAINS_LINUX: LazyLock<KeymapFile> = LazyLock::new(|| {
 30    load_keymap("keymaps/linux/jetbrains.json").expect("Failed to load JetBrains Linux keymap")
 31});
 32
 33static ALL_ACTIONS: LazyLock<ActionManifest> = LazyLock::new(load_all_actions);
 34
 35#[derive(Clone, Copy)]
 36#[allow(dead_code)]
 37enum Os {
 38    MacOs,
 39    Linux,
 40    Windows,
 41}
 42
 43#[derive(Clone, Copy)]
 44enum KeymapOverlay {
 45    JetBrains,
 46}
 47
 48impl KeymapOverlay {
 49    fn parse(name: &str) -> Option<Self> {
 50        match name {
 51            "jetbrains" => Some(Self::JetBrains),
 52            _ => None,
 53        }
 54    }
 55
 56    fn keymap(self, os: Os) -> &'static KeymapFile {
 57        match (self, os) {
 58            (Self::JetBrains, Os::MacOs) => &KEYMAP_JETBRAINS_MACOS,
 59            (Self::JetBrains, Os::Linux | Os::Windows) => &KEYMAP_JETBRAINS_LINUX,
 60        }
 61    }
 62}
 63
 64const FRONT_MATTER_COMMENT: &str = "<!-- ZED_META {} -->";
 65
 66fn main() -> Result<()> {
 67    zlog::init();
 68    zlog::init_output_stderr();
 69    let args = std::env::args().skip(1).collect::<Vec<_>>();
 70
 71    match args.get(0).map(String::as_str) {
 72        Some("supports") => {
 73            let renderer = args.get(1).expect("Required argument");
 74            let supported = renderer != "not-supported";
 75            if supported {
 76                process::exit(0);
 77            } else {
 78                process::exit(1);
 79            }
 80        }
 81        Some("postprocess") => handle_postprocessing()?,
 82        _ => handle_preprocessing()?,
 83    }
 84
 85    Ok(())
 86}
 87
 88#[derive(Debug, Clone, PartialEq, Eq, Hash)]
 89enum PreprocessorError {
 90    ActionNotFound {
 91        action_name: String,
 92    },
 93    DeprecatedActionUsed {
 94        used: String,
 95        should_be: String,
 96    },
 97    InvalidFrontmatterLine(String),
 98    InvalidSettingsJson {
 99        file: std::path::PathBuf,
100        line: usize,
101        snippet: String,
102        error: String,
103    },
104    UnknownKeymapOverlay {
105        overlay_name: String,
106    },
107}
108
109impl PreprocessorError {
110    fn new_for_not_found_action(action_name: String) -> Self {
111        for action in &ALL_ACTIONS.actions {
112            for alias in &action.deprecated_aliases {
113                if alias == action_name.as_str() {
114                    return PreprocessorError::DeprecatedActionUsed {
115                        used: action_name,
116                        should_be: action.name.to_string(),
117                    };
118                }
119            }
120        }
121        PreprocessorError::ActionNotFound { action_name }
122    }
123
124    fn new_for_invalid_settings_json(
125        chapter: &Chapter,
126        location: usize,
127        snippet: String,
128        error: String,
129    ) -> Self {
130        PreprocessorError::InvalidSettingsJson {
131            file: chapter.path.clone().expect("chapter has path"),
132            line: chapter.content[..location].lines().count() + 1,
133            snippet,
134            error,
135        }
136    }
137}
138
139impl std::fmt::Display for PreprocessorError {
140    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
141        match self {
142            PreprocessorError::InvalidFrontmatterLine(line) => {
143                write!(f, "Invalid frontmatter line: {}", line)
144            }
145            PreprocessorError::ActionNotFound { action_name } => {
146                write!(f, "Action not found: {}", action_name)
147            }
148            PreprocessorError::DeprecatedActionUsed { used, should_be } => write!(
149                f,
150                "Deprecated action used: {} should be {}",
151                used, should_be
152            ),
153            PreprocessorError::InvalidSettingsJson {
154                file,
155                line,
156                snippet,
157                error,
158            } => {
159                write!(
160                    f,
161                    "Invalid settings JSON at {}:{}\nError: {}\n\n{}",
162                    file.display(),
163                    line,
164                    error,
165                    snippet
166                )
167            }
168            PreprocessorError::UnknownKeymapOverlay { overlay_name } => {
169                write!(
170                    f,
171                    "Unknown keymap overlay: '{}'. Supported overlays: jetbrains",
172                    overlay_name
173                )
174            }
175        }
176    }
177}
178
179fn handle_preprocessing() -> Result<()> {
180    let mut stdin = io::stdin();
181    let mut input = String::new();
182    stdin.read_to_string(&mut input)?;
183
184    let (_ctx, mut book) = CmdPreprocessor::parse_input(input.as_bytes())?;
185
186    let mut errors = HashSet::<PreprocessorError>::new();
187    handle_frontmatter(&mut book, &mut errors);
188    template_big_table_of_actions(&mut book);
189    template_and_validate_keybindings(&mut book, &mut errors);
190    template_and_validate_actions(&mut book, &mut errors);
191    template_and_validate_json_snippets(&mut book, &mut errors);
192
193    if !errors.is_empty() {
194        const ANSI_RED: &str = "\x1b[31m";
195        const ANSI_RESET: &str = "\x1b[0m";
196        for error in &errors {
197            eprintln!("{ANSI_RED}ERROR{ANSI_RESET}: {}", error);
198        }
199        return Err(anyhow::anyhow!("Found {} errors in docs", errors.len()));
200    }
201
202    serde_json::to_writer(io::stdout(), &book)?;
203
204    Ok(())
205}
206
207fn handle_frontmatter(book: &mut Book, errors: &mut HashSet<PreprocessorError>) {
208    let frontmatter_regex = Regex::new(r"(?s)^\s*---(.*?)---").unwrap();
209    for_each_chapter_mut(book, |chapter| {
210        let new_content = frontmatter_regex.replace(&chapter.content, |caps: &regex::Captures| {
211            let frontmatter = caps[1].trim();
212            let frontmatter = frontmatter.trim_matches(&[' ', '-', '\n']);
213            let mut metadata = HashMap::<String, String>::default();
214            for line in frontmatter.lines() {
215                let Some((name, value)) = line.split_once(':') else {
216                    errors.insert(PreprocessorError::InvalidFrontmatterLine(format!(
217                        "{}: {}",
218                        chapter_breadcrumbs(chapter),
219                        line
220                    )));
221                    continue;
222                };
223                let name = name.trim();
224                let value = value.trim();
225                metadata.insert(name.to_string(), value.to_string());
226            }
227            FRONT_MATTER_COMMENT.replace(
228                "{}",
229                &serde_json::to_string(&metadata).expect("Failed to serialize metadata"),
230            )
231        });
232        if let Cow::Owned(content) = new_content {
233            chapter.content = content;
234        }
235    });
236}
237
238fn template_big_table_of_actions(book: &mut Book) {
239    for_each_chapter_mut(book, |chapter| {
240        let needle = "{#ACTIONS_TABLE#}";
241        if let Some(start) = chapter.content.rfind(needle) {
242            chapter.content.replace_range(
243                start..start + needle.len(),
244                &generate_big_table_of_actions(),
245            );
246        }
247    });
248}
249
250fn format_binding(binding: String) -> String {
251    binding.replace("\\", "\\\\")
252}
253
254fn template_and_validate_keybindings(book: &mut Book, errors: &mut HashSet<PreprocessorError>) {
255    let regex = Regex::new(r"\{#kb(?::(\w+))?\s+(.*?)\}").unwrap();
256
257    for_each_chapter_mut(book, |chapter| {
258        chapter.content = regex
259            .replace_all(&chapter.content, |caps: &regex::Captures| {
260                let overlay_name = caps.get(1).map(|m| m.as_str());
261                let action = caps[2].trim();
262
263                if is_missing_action(action) {
264                    errors.insert(PreprocessorError::new_for_not_found_action(
265                        action.to_string(),
266                    ));
267                    return String::new();
268                }
269
270                let overlay = if let Some(name) = overlay_name {
271                    let Some(overlay) = KeymapOverlay::parse(name) else {
272                        errors.insert(PreprocessorError::UnknownKeymapOverlay {
273                            overlay_name: name.to_string(),
274                        });
275                        return String::new();
276                    };
277                    Some(overlay)
278                } else {
279                    None
280                };
281
282                let macos_binding =
283                    find_binding_with_overlay(Os::MacOs, action, overlay)
284                        .unwrap_or_default();
285                let linux_binding =
286                    find_binding_with_overlay(Os::Linux, action, overlay)
287                        .unwrap_or_default();
288
289                if macos_binding.is_empty() && linux_binding.is_empty() {
290                    return "<div>No default binding</div>".to_string();
291                }
292
293                let formatted_macos_binding = format_binding(macos_binding);
294                let formatted_linux_binding = format_binding(linux_binding);
295
296                format!("<kbd class=\"keybinding\">{formatted_macos_binding}&#124;{formatted_linux_binding}</kbd>")
297            })
298            .into_owned()
299    });
300}
301
302fn template_and_validate_actions(book: &mut Book, errors: &mut HashSet<PreprocessorError>) {
303    let regex = Regex::new(r"\{#action (.*?)\}").unwrap();
304
305    for_each_chapter_mut(book, |chapter| {
306        chapter.content = regex
307            .replace_all(&chapter.content, |caps: &regex::Captures| {
308                let name = caps[1].trim();
309                let Some(action) = find_action_by_name(name) else {
310                    if actions_available() {
311                        errors.insert(PreprocessorError::new_for_not_found_action(
312                            name.to_string(),
313                        ));
314                    }
315                    return format!("<code class=\"hljs\">{}</code>", name);
316                };
317                format!("<code class=\"hljs\">{}</code>", &action.human_name)
318            })
319            .into_owned()
320    });
321}
322
323fn find_action_by_name(name: &str) -> Option<&ActionDef> {
324    ALL_ACTIONS
325        .actions
326        .binary_search_by(|action| action.name.as_str().cmp(name))
327        .ok()
328        .map(|index| &ALL_ACTIONS.actions[index])
329}
330
331fn actions_available() -> bool {
332    !ALL_ACTIONS.actions.is_empty()
333}
334
335fn is_missing_action(name: &str) -> bool {
336    actions_available() && find_action_by_name(name).is_none()
337}
338
339// Find the last binding (in keymap order) for the given action.
340// Exact action matches are preferred over parameterized variants.
341fn find_binding_in_keymap(keymap: &KeymapFile, action: &str) -> Option<String> {
342    let find = |predicate: &dyn Fn(&str) -> bool| {
343        keymap.sections().rev().find_map(|section| {
344            section.bindings().rev().find_map(|(keystroke, a)| {
345                if predicate(&a.to_string()) {
346                    Some(keystroke.to_string())
347                } else {
348                    None
349                }
350            })
351        })
352    };
353
354    // Look for exact match
355    if let Some(binding) = find(&|a| a == action) {
356        return Some(binding);
357    }
358
359    // Look for parameterized match
360    find(&|a| name_for_action(a.to_string()) == action)
361}
362
363fn find_binding(os: Os, action: &str) -> Option<String> {
364    let keymap = match os {
365        Os::MacOs => &KEYMAP_MACOS,
366        Os::Linux => &KEYMAP_LINUX,
367        Os::Windows => &KEYMAP_WINDOWS,
368    };
369    find_binding_in_keymap(keymap, action)
370}
371
372fn find_binding_with_overlay(
373    os: Os,
374    action: &str,
375    overlay: Option<KeymapOverlay>,
376) -> Option<String> {
377    overlay
378        .and_then(|overlay| find_binding_in_keymap(overlay.keymap(os), action))
379        .or_else(|| find_binding(os, action))
380}
381
382fn template_and_validate_json_snippets(book: &mut Book, errors: &mut HashSet<PreprocessorError>) {
383    let params = SettingsJsonSchemaParams {
384        language_names: &[],
385        font_names: &[],
386        theme_names: &[],
387        icon_theme_names: &[],
388        lsp_adapter_names: &[],
389        action_names: &[],
390        action_documentation: &HashMap::default(),
391        deprecations: &HashMap::default(),
392        deprecation_messages: &HashMap::default(),
393    };
394    let settings_schema = SettingsStore::json_schema(&params);
395    let settings_validator = jsonschema::validator_for(&settings_schema)
396        .expect("failed to compile settings JSON schema");
397
398    let keymap_schema =
399        keymap_schema_for_actions(&ALL_ACTIONS.actions, &ALL_ACTIONS.schema_definitions);
400    let keymap_validator =
401        jsonschema::validator_for(&keymap_schema).expect("failed to compile keymap JSON schema");
402
403    fn for_each_labeled_code_block_mut(
404        book: &mut Book,
405        errors: &mut HashSet<PreprocessorError>,
406        f: &dyn Fn(&str, &str) -> anyhow::Result<()>,
407    ) {
408        const TAGGED_JSON_BLOCK_START: &'static str = "```json [";
409        const JSON_BLOCK_END: &'static str = "```";
410
411        for_each_chapter_mut(book, |chapter| {
412            let mut offset = 0;
413            while let Some(loc) = chapter.content[offset..].find(TAGGED_JSON_BLOCK_START) {
414                let loc = loc + offset;
415                let tag_start = loc + TAGGED_JSON_BLOCK_START.len();
416                offset = tag_start;
417                let Some(tag_end) = chapter.content[tag_start..].find(']') else {
418                    errors.insert(PreprocessorError::new_for_invalid_settings_json(
419                        chapter,
420                        loc,
421                        chapter.content[loc..tag_start].to_string(),
422                        "Unclosed JSON block tag".to_string(),
423                    ));
424                    continue;
425                };
426                let tag_end = tag_end + tag_start;
427
428                let tag = &chapter.content[tag_start..tag_end];
429
430                if tag.contains('\n') {
431                    errors.insert(PreprocessorError::new_for_invalid_settings_json(
432                        chapter,
433                        loc,
434                        chapter.content[loc..tag_start].to_string(),
435                        "Unclosed JSON block tag".to_string(),
436                    ));
437                    continue;
438                }
439
440                let snippet_start = tag_end + 1;
441                offset = snippet_start;
442
443                let Some(snippet_end) = chapter.content[snippet_start..].find(JSON_BLOCK_END)
444                else {
445                    errors.insert(PreprocessorError::new_for_invalid_settings_json(
446                        chapter,
447                        loc,
448                        chapter.content[loc..tag_end + 1].to_string(),
449                        "Missing closing code block".to_string(),
450                    ));
451                    continue;
452                };
453                let snippet_end = snippet_start + snippet_end;
454                let snippet_json = &chapter.content[snippet_start..snippet_end];
455                offset = snippet_end + 3;
456
457                if let Err(err) = f(tag, snippet_json) {
458                    errors.insert(PreprocessorError::new_for_invalid_settings_json(
459                        chapter,
460                        loc,
461                        chapter.content[loc..snippet_end + 3].to_string(),
462                        err.to_string(),
463                    ));
464                    continue;
465                };
466                let tag_range_complete = tag_start - 1..tag_end + 1;
467                offset -= tag_range_complete.len();
468                chapter.content.replace_range(tag_range_complete, "");
469            }
470        });
471    }
472
473    for_each_labeled_code_block_mut(book, errors, &|label, snippet_json| {
474        let mut snippet_json_fixed = snippet_json
475            .to_string()
476            .replace("\n>", "\n")
477            .trim()
478            .to_string();
479        while snippet_json_fixed.starts_with("//") {
480            if let Some(line_end) = snippet_json_fixed.find('\n') {
481                snippet_json_fixed.replace_range(0..line_end, "");
482                snippet_json_fixed = snippet_json_fixed.trim().to_string();
483            }
484        }
485        match label {
486            "settings" => {
487                if !snippet_json_fixed.starts_with('{') || !snippet_json_fixed.ends_with('}') {
488                    snippet_json_fixed.insert(0, '{');
489                    snippet_json_fixed.push_str("\n}");
490                }
491                let value =
492                    settings::parse_json_with_comments::<serde_json::Value>(&snippet_json_fixed)?;
493                let validation_errors: Vec<String> = settings_validator
494                    .iter_errors(&value)
495                    .map(|err| err.to_string())
496                    .collect();
497                if !validation_errors.is_empty() {
498                    anyhow::bail!("{}", validation_errors.join("\n"));
499                }
500            }
501            "keymap" => {
502                if !snippet_json_fixed.starts_with('[') || !snippet_json_fixed.ends_with(']') {
503                    snippet_json_fixed.insert(0, '[');
504                    snippet_json_fixed.push_str("\n]");
505                }
506
507                let value =
508                    settings::parse_json_with_comments::<serde_json::Value>(&snippet_json_fixed)?;
509                let validation_errors: Vec<String> = keymap_validator
510                    .iter_errors(&value)
511                    .map(|err| err.to_string())
512                    .collect();
513                if !validation_errors.is_empty() {
514                    anyhow::bail!("{}", validation_errors.join("\n"));
515                }
516            }
517            "debug" => {
518                if !snippet_json_fixed.starts_with('[') || !snippet_json_fixed.ends_with(']') {
519                    snippet_json_fixed.insert(0, '[');
520                    snippet_json_fixed.push_str("\n]");
521                }
522
523                settings::parse_json_with_comments::<task::DebugTaskFile>(&snippet_json_fixed)?;
524            }
525            "tasks" => {
526                if !snippet_json_fixed.starts_with('[') || !snippet_json_fixed.ends_with(']') {
527                    snippet_json_fixed.insert(0, '[');
528                    snippet_json_fixed.push_str("\n]");
529                }
530
531                settings::parse_json_with_comments::<task::TaskTemplates>(&snippet_json_fixed)?;
532            }
533            "icon-theme" => {
534                if !snippet_json_fixed.starts_with('{') || !snippet_json_fixed.ends_with('}') {
535                    snippet_json_fixed.insert(0, '{');
536                    snippet_json_fixed.push_str("\n}");
537                }
538
539                settings::parse_json_with_comments::<theme::IconThemeFamilyContent>(
540                    &snippet_json_fixed,
541                )?;
542            }
543            "semantic_token_rules" => {
544                if !snippet_json_fixed.starts_with('[') || !snippet_json_fixed.ends_with(']') {
545                    snippet_json_fixed.insert(0, '[');
546                    snippet_json_fixed.push_str("\n]");
547                }
548
549                settings::parse_json_with_comments::<settings::SemanticTokenRules>(
550                    &snippet_json_fixed,
551                )?;
552            }
553            label => anyhow::bail!("Unexpected JSON code block tag: {label}"),
554        };
555        Ok(())
556    });
557}
558
559/// Removes any configurable options from the stringified action if existing,
560/// ensuring that only the actual action name is returned. If the action consists
561/// only of a string and nothing else, the string is returned as-is.
562///
563/// Example:
564///
565/// This will return the action name unmodified.
566///
567/// ```
568/// let action_as_str = "workspace::Save";
569/// let action_name = name_for_action(action_as_str);
570/// assert_eq!(action_name, "workspace::Save");
571/// ```
572///
573/// This will return the action name with any trailing options removed.
574///
575///
576/// ```
577/// let action_as_str = "\"editor::ToggleComments\", {\"advance_downwards\":false}";
578/// let action_name = name_for_action(action_as_str);
579/// assert_eq!(action_name, "editor::ToggleComments");
580/// ```
581fn name_for_action(action_as_str: String) -> String {
582    action_as_str
583        .split(",")
584        .next()
585        .map(|name| name.trim_matches('"').to_string())
586        .unwrap_or(action_as_str)
587}
588
589fn chapter_breadcrumbs(chapter: &Chapter) -> String {
590    let mut breadcrumbs = Vec::with_capacity(chapter.parent_names.len() + 1);
591    breadcrumbs.extend(chapter.parent_names.iter().map(String::as_str));
592    breadcrumbs.push(chapter.name.as_str());
593    format!("[{:?}] {}", chapter.source_path, breadcrumbs.join(" > "))
594}
595
596fn load_keymap(asset_path: &str) -> Result<KeymapFile> {
597    let content = util::asset_str::<settings::SettingsAssets>(asset_path);
598    KeymapFile::parse(content.as_ref())
599}
600
601fn for_each_chapter_mut<F>(book: &mut Book, mut func: F)
602where
603    F: FnMut(&mut Chapter),
604{
605    book.for_each_mut(|item| {
606        let BookItem::Chapter(chapter) = item else {
607            return;
608        };
609        func(chapter);
610    });
611}
612
613#[derive(Debug, serde::Serialize, serde::Deserialize)]
614struct ActionDef {
615    name: String,
616    human_name: String,
617    #[serde(default)]
618    schema: Option<serde_json::Value>,
619    deprecated_aliases: Vec<String>,
620    #[serde(default)]
621    deprecation_message: Option<String>,
622    #[serde(rename = "documentation")]
623    docs: Option<String>,
624}
625
626#[derive(Debug, serde::Deserialize)]
627struct ActionManifest {
628    actions: Vec<ActionDef>,
629    #[serde(default)]
630    schema_definitions: serde_json::Map<String, serde_json::Value>,
631}
632
633fn load_all_actions() -> ActionManifest {
634    let asset_path = concat!(env!("CARGO_MANIFEST_DIR"), "/actions.json");
635    match std::fs::read_to_string(asset_path) {
636        Ok(content) => {
637            let mut manifest: ActionManifest =
638                serde_json::from_str(&content).expect("Failed to parse actions.json");
639            manifest.actions.sort_by(|a, b| a.name.cmp(&b.name));
640            manifest
641        }
642        Err(err) => {
643            if std::env::var("CI").is_ok() {
644                panic!("actions.json not found at {}: {}", asset_path, err);
645            }
646            eprintln!(
647                "Warning: actions.json not found, action validation will be skipped: {}",
648                err
649            );
650            ActionManifest {
651                actions: Vec::new(),
652                schema_definitions: serde_json::Map::new(),
653            }
654        }
655    }
656}
657
658fn handle_postprocessing() -> Result<()> {
659    let logger = zlog::scoped!("render");
660    let mut ctx = mdbook::renderer::RenderContext::from_json(io::stdin())?;
661    let output = ctx
662        .config
663        .get_mut("output")
664        .expect("has output")
665        .as_table_mut()
666        .expect("output is table");
667    let zed_html = output.remove("zed-html").expect("zed-html output defined");
668    let default_description = zed_html
669        .get("default-description")
670        .expect("Default description not found")
671        .as_str()
672        .expect("Default description not a string")
673        .to_string();
674    let default_title = zed_html
675        .get("default-title")
676        .expect("Default title not found")
677        .as_str()
678        .expect("Default title not a string")
679        .to_string();
680    let amplitude_key = std::env::var("DOCS_AMPLITUDE_API_KEY").unwrap_or_default();
681    let consent_io_instance = std::env::var("DOCS_CONSENT_IO_INSTANCE").unwrap_or_default();
682
683    output.insert("html".to_string(), zed_html);
684    mdbook::Renderer::render(&mdbook::renderer::HtmlHandlebars::new(), &ctx)?;
685    let ignore_list = ["toc.html"];
686
687    let root_dir = ctx.destination.clone();
688    let mut files = Vec::with_capacity(128);
689    let mut queue = Vec::with_capacity(64);
690    queue.push(root_dir.clone());
691    while let Some(dir) = queue.pop() {
692        for entry in std::fs::read_dir(&dir).context("failed to read docs dir")? {
693            let Ok(entry) = entry else {
694                continue;
695            };
696            let file_type = entry.file_type().context("Failed to determine file type")?;
697            if file_type.is_dir() {
698                queue.push(entry.path());
699            }
700            if file_type.is_file()
701                && matches!(
702                    entry.path().extension().and_then(std::ffi::OsStr::to_str),
703                    Some("html")
704                )
705            {
706                if ignore_list.contains(&&*entry.file_name().to_string_lossy()) {
707                    zlog::info!(logger => "Ignoring {}", entry.path().to_string_lossy());
708                } else {
709                    files.push(entry.path());
710                }
711            }
712        }
713    }
714
715    zlog::info!(logger => "Processing {} `.html` files", files.len());
716    let meta_regex = Regex::new(&FRONT_MATTER_COMMENT.replace("{}", "(.*)")).unwrap();
717    for file in files {
718        let contents = std::fs::read_to_string(&file)?;
719        let mut meta_description = None;
720        let mut meta_title = None;
721        let contents = meta_regex.replace(&contents, |caps: &regex::Captures| {
722            let metadata: HashMap<String, String> = serde_json::from_str(&caps[1]).with_context(|| format!("JSON Metadata: {:?}", &caps[1])).expect("Failed to deserialize metadata");
723            for (kind, content) in metadata {
724                match kind.as_str() {
725                    "description" => {
726                        meta_description = Some(content);
727                    }
728                    "title" => {
729                        meta_title = Some(content);
730                    }
731                    _ => {
732                        zlog::warn!(logger => "Unrecognized frontmatter key: {} in {:?}", kind, pretty_path(&file, &root_dir));
733                    }
734                }
735            }
736            String::new()
737        });
738        let meta_description = meta_description.as_ref().unwrap_or_else(|| {
739            zlog::warn!(logger => "No meta description found for {:?}", pretty_path(&file, &root_dir));
740            &default_description
741        });
742        let page_title = extract_title_from_page(&contents, pretty_path(&file, &root_dir));
743        let meta_title = meta_title.as_ref().unwrap_or_else(|| {
744            zlog::debug!(logger => "No meta title found for {:?}", pretty_path(&file, &root_dir));
745            &default_title
746        });
747        let meta_title = format!("{} | {}", page_title, meta_title);
748        zlog::trace!(logger => "Updating {:?}", pretty_path(&file, &root_dir));
749        let contents = contents.replace("#description#", meta_description);
750        let contents = contents.replace("#amplitude_key#", &amplitude_key);
751        let contents = contents.replace("#consent_io_instance#", &consent_io_instance);
752        let contents = title_regex()
753            .replace(&contents, |_: &regex::Captures| {
754                format!("<title>{}</title>", meta_title)
755            })
756            .to_string();
757        // let contents = contents.replace("#title#", &meta_title);
758        std::fs::write(file, contents)?;
759    }
760    return Ok(());
761
762    fn pretty_path<'a>(
763        path: &'a std::path::PathBuf,
764        root: &'a std::path::PathBuf,
765    ) -> &'a std::path::Path {
766        path.strip_prefix(&root).unwrap_or(path)
767    }
768    fn extract_title_from_page(contents: &str, pretty_path: &std::path::Path) -> String {
769        let title_tag_contents = &title_regex()
770            .captures(contents)
771            .with_context(|| format!("Failed to find title in {:?}", pretty_path))
772            .expect("Page has <title> element")[1];
773
774        title_tag_contents
775            .trim()
776            .strip_suffix("- Zed")
777            .unwrap_or(title_tag_contents)
778            .trim()
779            .to_string()
780    }
781}
782
783fn title_regex() -> &'static Regex {
784    static TITLE_REGEX: OnceLock<Regex> = OnceLock::new();
785    TITLE_REGEX.get_or_init(|| Regex::new(r"<title>\s*(.*?)\s*</title>").unwrap())
786}
787
788fn generate_big_table_of_actions() -> String {
789    let actions = &ALL_ACTIONS.actions;
790    let mut output = String::new();
791
792    let mut actions_sorted = actions.iter().collect::<Vec<_>>();
793    actions_sorted.sort_by_key(|a| a.name.as_str());
794
795    // Start the definition list with custom styling for better spacing
796    output.push_str("<dl style=\"line-height: 1.8;\">\n");
797
798    for action in actions_sorted.into_iter() {
799        // Add the humanized action name as the term with margin
800        output.push_str(
801            "<dt style=\"margin-top: 1.5em; margin-bottom: 0.5em; font-weight: bold;\"><code>",
802        );
803        output.push_str(&action.human_name);
804        output.push_str("</code></dt>\n");
805
806        // Add the definition with keymap name and description
807        output.push_str("<dd style=\"margin-left: 2em; margin-bottom: 1em;\">\n");
808
809        // Add the description, escaping HTML if needed
810        if let Some(description) = action.docs.as_ref() {
811            output.push_str(
812                &description
813                    .replace("&", "&amp;")
814                    .replace("<", "&lt;")
815                    .replace(">", "&gt;"),
816            );
817            output.push_str("<br>\n");
818        }
819        output.push_str("Keymap Name: <code>");
820        output.push_str(&action.name);
821        output.push_str("</code><br>\n");
822        if !action.deprecated_aliases.is_empty() {
823            output.push_str("Deprecated Alias(es): ");
824            for alias in action.deprecated_aliases.iter() {
825                output.push_str("<code>");
826                output.push_str(alias);
827                output.push_str("</code>, ");
828            }
829        }
830        output.push_str("\n</dd>\n");
831    }
832
833    // Close the definition list
834    output.push_str("</dl>\n");
835
836    output
837}
838
839fn keymap_schema_for_actions(
840    actions: &[ActionDef],
841    schema_definitions: &serde_json::Map<String, serde_json::Value>,
842) -> serde_json::Value {
843    let mut generator = KeymapFile::action_schema_generator();
844
845    for (name, definition) in schema_definitions {
846        generator
847            .definitions_mut()
848            .insert(name.clone(), definition.clone());
849    }
850
851    let mut action_schemas = Vec::new();
852    let mut documentation = collections::HashMap::<&str, &str>::default();
853    let mut deprecations = collections::HashMap::<&str, &str>::default();
854    let mut deprecation_messages = collections::HashMap::<&str, &str>::default();
855
856    for action in actions {
857        let schema = action
858            .schema
859            .as_ref()
860            .and_then(|v| serde_json::from_value::<schemars::Schema>(v.clone()).ok());
861        action_schemas.push((action.name.as_str(), schema));
862        if let Some(doc) = &action.docs {
863            documentation.insert(action.name.as_str(), doc.as_str());
864        }
865        if let Some(msg) = &action.deprecation_message {
866            deprecation_messages.insert(action.name.as_str(), msg.as_str());
867        }
868        for alias in &action.deprecated_aliases {
869            deprecations.insert(alias.as_str(), action.name.as_str());
870            let alias_schema = action
871                .schema
872                .as_ref()
873                .and_then(|v| serde_json::from_value::<schemars::Schema>(v.clone()).ok());
874            action_schemas.push((alias.as_str(), alias_schema));
875        }
876    }
877
878    KeymapFile::generate_json_schema(
879        generator,
880        action_schemas,
881        &documentation,
882        &deprecations,
883        &deprecation_messages,
884    )
885}
886
887#[cfg(test)]
888mod tests {
889    use super::*;
890    use serde_json::json;
891
892    #[test]
893    fn test_find_binding_prefers_exact_match_over_parameterized() {
894        let keymap: KeymapFile = serde_json::from_value(json!([
895            {
896                "bindings": {
897                    "ctrl-tab": "agents_sidebar::ToggleThreadSwitcher",
898                    "ctrl-shift-tab": ["agents_sidebar::ToggleThreadSwitcher", { "select_last": true }]
899                }
900            }
901        ]))
902        .unwrap();
903
904        let binding = find_binding_in_keymap(&keymap, "agents_sidebar::ToggleThreadSwitcher");
905        assert_eq!(binding.as_deref(), Some("ctrl-tab"));
906    }
907
908    #[test]
909    fn test_find_binding_falls_back_to_parameterized_match() {
910        let keymap: KeymapFile = serde_json::from_value(json!([
911            {
912                "bindings": {
913                    "ctrl-shift-tab": ["agents_sidebar::ToggleThreadSwitcher", { "select_last": true }]
914                }
915            }
916        ]))
917        .unwrap();
918
919        let binding = find_binding_in_keymap(&keymap, "agents_sidebar::ToggleThreadSwitcher");
920        assert_eq!(binding.as_deref(), Some("ctrl-shift-tab"));
921    }
922
923    #[test]
924    fn test_find_binding_prefers_exact_match_regardless_of_order() {
925        let keymap: KeymapFile = serde_json::from_value(json!([
926            {
927                "bindings": {
928                    "ctrl-shift-tab": ["agents_sidebar::ToggleThreadSwitcher", { "select_last": true }],
929                    "ctrl-tab": "agents_sidebar::ToggleThreadSwitcher"
930                }
931            }
932        ]))
933        .unwrap();
934
935        let binding = find_binding_in_keymap(&keymap, "agents_sidebar::ToggleThreadSwitcher");
936        assert_eq!(binding.as_deref(), Some("ctrl-tab"));
937    }
938
939    #[test]
940    fn test_find_binding_later_section_overrides_earlier() {
941        let keymap: KeymapFile = serde_json::from_value(json!([
942            { "bindings": { "ctrl-a": "some::Action" } },
943            { "bindings": { "ctrl-b": "some::Action" } }
944        ]))
945        .unwrap();
946
947        let binding = find_binding_in_keymap(&keymap, "some::Action");
948        assert_eq!(binding.as_deref(), Some("ctrl-b"));
949    }
950}