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}