Refactor markdown formatting utilities to avoid building intermediate strings (#29511)

Michael Sloan created

These were nearly always used when using `format!` / `write!` etc, so it
makes sense to not have an intermediate `String`.

Release Notes:

- N/A

Change summary

crates/agent/src/active_thread.rs                   |   8 
crates/assistant_tools/src/code_symbols_tool.rs     |   4 
crates/assistant_tools/src/contents_tool.rs         |   4 
crates/assistant_tools/src/copy_path_tool.rs        |   6 
crates/assistant_tools/src/create_directory_tool.rs |   7 
crates/assistant_tools/src/create_file_tool.rs      |   4 
crates/assistant_tools/src/diagnostics_tool.rs      |   6 
crates/assistant_tools/src/fetch_tool.rs            |   4 
crates/assistant_tools/src/grep_tool.rs             |   4 
crates/assistant_tools/src/list_directory_tool.rs   |   4 
crates/assistant_tools/src/move_path_tool.rs        |   8 
crates/assistant_tools/src/open_tool.rs             |   4 
crates/assistant_tools/src/read_file_tool.rs        |   4 
crates/assistant_tools/src/symbol_info_tool.rs      |   4 
crates/assistant_tools/src/terminal_tool.rs         |  21 +-
crates/eval/src/instance.rs                         |  17 +
crates/repl/src/outputs/table.rs                    |   4 
crates/settings/src/keymap_file.rs                  |  49 ++---
crates/util/src/markdown.rs                         | 129 ++++++++------
19 files changed, 154 insertions(+), 137 deletions(-)

Detailed changes

crates/agent/src/active_thread.rs 🔗

@@ -43,7 +43,7 @@ use ui::{
     Disclosure, IconButton, KeyBinding, Scrollbar, ScrollbarState, TextSize, Tooltip, prelude::*,
 };
 use util::ResultExt as _;
-use util::markdown::MarkdownString;
+use util::markdown::MarkdownCodeBlock;
 use workspace::Workspace;
 use zed_actions::assistant::OpenRulesLibrary;
 
@@ -882,7 +882,11 @@ impl ActiveThread {
         });
         rendered.input.update(cx, |this, cx| {
             this.replace(
-                MarkdownString::code_block("json", tool_input).to_string(),
+                MarkdownCodeBlock {
+                    tag: "json",
+                    text: tool_input,
+                }
+                .to_string(),
                 cx,
             );
         });

crates/assistant_tools/src/code_symbols_tool.rs 🔗

@@ -14,7 +14,7 @@ use regex::{Regex, RegexBuilder};
 use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
 use ui::IconName;
-use util::markdown::MarkdownString;
+use util::markdown::MarkdownInlineCode;
 
 #[derive(Debug, Serialize, Deserialize, JsonSchema)]
 pub struct CodeSymbolsInput {
@@ -102,7 +102,7 @@ impl Tool for CodeSymbolsTool {
 
                 match &input.path {
                     Some(path) => {
-                        let path = MarkdownString::inline_code(path);
+                        let path = MarkdownInlineCode(path);
                         if page > 1 {
                             format!("List page {page} of code symbols for {path}")
                         } else {

crates/assistant_tools/src/contents_tool.rs 🔗

@@ -11,7 +11,7 @@ use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
 use std::{fmt::Write, path::Path};
 use ui::IconName;
-use util::markdown::MarkdownString;
+use util::markdown::MarkdownInlineCode;
 
 /// If the model requests to read a file whose size exceeds this, then
 /// the tool will return the file's symbol outline instead of its contents,
@@ -82,7 +82,7 @@ impl Tool for ContentsTool {
     fn ui_text(&self, input: &serde_json::Value) -> String {
         match serde_json::from_value::<ContentsToolInput>(input.clone()) {
             Ok(input) => {
-                let path = MarkdownString::inline_code(&input.path);
+                let path = MarkdownInlineCode(&input.path);
 
                 match (input.start, input.end) {
                     (Some(start), None) => format!("Read {path} (from line {start})"),

crates/assistant_tools/src/copy_path_tool.rs 🔗

@@ -10,7 +10,7 @@ use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
 use std::sync::Arc;
 use ui::IconName;
-use util::markdown::MarkdownString;
+use util::markdown::MarkdownInlineCode;
 
 #[derive(Debug, Serialize, Deserialize, JsonSchema)]
 pub struct CopyPathToolInput {
@@ -63,8 +63,8 @@ impl Tool for CopyPathTool {
     fn ui_text(&self, input: &serde_json::Value) -> String {
         match serde_json::from_value::<CopyPathToolInput>(input.clone()) {
             Ok(input) => {
-                let src = MarkdownString::inline_code(&input.source_path);
-                let dest = MarkdownString::inline_code(&input.destination_path);
+                let src = MarkdownInlineCode(&input.source_path);
+                let dest = MarkdownInlineCode(&input.destination_path);
                 format!("Copy {src} to {dest}")
             }
             Err(_) => "Copy path".to_string(),

crates/assistant_tools/src/create_directory_tool.rs 🔗

@@ -10,7 +10,7 @@ use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
 use std::sync::Arc;
 use ui::IconName;
-use util::markdown::MarkdownString;
+use util::markdown::MarkdownInlineCode;
 
 #[derive(Debug, Serialize, Deserialize, JsonSchema)]
 pub struct CreateDirectoryToolInput {
@@ -53,10 +53,7 @@ impl Tool for CreateDirectoryTool {
     fn ui_text(&self, input: &serde_json::Value) -> String {
         match serde_json::from_value::<CreateDirectoryToolInput>(input.clone()) {
             Ok(input) => {
-                format!(
-                    "Create directory {}",
-                    MarkdownString::inline_code(&input.path)
-                )
+                format!("Create directory {}", MarkdownInlineCode(&input.path))
             }
             Err(_) => "Create directory".to_string(),
         }

crates/assistant_tools/src/create_file_tool.rs 🔗

@@ -10,7 +10,7 @@ use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
 use std::sync::Arc;
 use ui::IconName;
-use util::markdown::MarkdownString;
+use util::markdown::MarkdownInlineCode;
 
 #[derive(Debug, Serialize, Deserialize, JsonSchema)]
 pub struct CreateFileToolInput {
@@ -73,7 +73,7 @@ impl Tool for CreateFileTool {
     fn ui_text(&self, input: &serde_json::Value) -> String {
         match serde_json::from_value::<CreateFileToolInput>(input.clone()) {
             Ok(input) => {
-                let path = MarkdownString::inline_code(&input.path);
+                let path = MarkdownInlineCode(&input.path);
                 format!("Create file {path}")
             }
             Err(_) => DEFAULT_UI_TEXT.to_string(),

crates/assistant_tools/src/diagnostics_tool.rs 🔗

@@ -9,7 +9,7 @@ use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
 use std::{fmt::Write, path::Path, sync::Arc};
 use ui::IconName;
-use util::markdown::MarkdownString;
+use util::markdown::MarkdownInlineCode;
 
 #[derive(Debug, Serialize, Deserialize, JsonSchema)]
 pub struct DiagnosticsToolInput {
@@ -66,11 +66,11 @@ impl Tool for DiagnosticsTool {
         if let Some(path) = serde_json::from_value::<DiagnosticsToolInput>(input.clone())
             .ok()
             .and_then(|input| match input.path {
-                Some(path) if !path.is_empty() => Some(MarkdownString::inline_code(&path)),
+                Some(path) if !path.is_empty() => Some(path),
                 _ => None,
             })
         {
-            format!("Check diagnostics for {path}")
+            format!("Check diagnostics for {}", MarkdownInlineCode(&path))
         } else {
             "Check project diagnostics".to_string()
         }

crates/assistant_tools/src/fetch_tool.rs 🔗

@@ -14,7 +14,7 @@ use project::Project;
 use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
 use ui::IconName;
-use util::markdown::MarkdownString;
+use util::markdown::MarkdownEscaped;
 
 #[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)]
 enum ContentType {
@@ -134,7 +134,7 @@ impl Tool for FetchTool {
 
     fn ui_text(&self, input: &serde_json::Value) -> String {
         match serde_json::from_value::<FetchToolInput>(input.clone()) {
-            Ok(input) => format!("Fetch {}", MarkdownString::escape(&input.url)),
+            Ok(input) => format!("Fetch {}", MarkdownEscaped(&input.url)),
             Err(_) => "Fetch URL".to_string(),
         }
     }

crates/assistant_tools/src/grep_tool.rs 🔗

@@ -13,7 +13,7 @@ use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
 use std::{cmp, fmt::Write, sync::Arc};
 use ui::IconName;
-use util::markdown::MarkdownString;
+use util::markdown::MarkdownInlineCode;
 use util::paths::PathMatcher;
 
 #[derive(Debug, Serialize, Deserialize, JsonSchema)]
@@ -75,7 +75,7 @@ impl Tool for GrepTool {
         match serde_json::from_value::<GrepToolInput>(input.clone()) {
             Ok(input) => {
                 let page = input.page();
-                let regex_str = MarkdownString::inline_code(&input.regex);
+                let regex_str = MarkdownInlineCode(&input.regex);
                 let case_info = if input.case_sensitive {
                     " (case-sensitive)"
                 } else {

crates/assistant_tools/src/list_directory_tool.rs 🔗

@@ -8,7 +8,7 @@ use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
 use std::{fmt::Write, path::Path, sync::Arc};
 use ui::IconName;
-use util::markdown::MarkdownString;
+use util::markdown::MarkdownInlineCode;
 
 #[derive(Debug, Serialize, Deserialize, JsonSchema)]
 pub struct ListDirectoryToolInput {
@@ -63,7 +63,7 @@ impl Tool for ListDirectoryTool {
     fn ui_text(&self, input: &serde_json::Value) -> String {
         match serde_json::from_value::<ListDirectoryToolInput>(input.clone()) {
             Ok(input) => {
-                let path = MarkdownString::inline_code(&input.path);
+                let path = MarkdownInlineCode(&input.path);
                 format!("List the {path} directory's contents")
             }
             Err(_) => "List directory".to_string(),

crates/assistant_tools/src/move_path_tool.rs 🔗

@@ -8,7 +8,7 @@ use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
 use std::{path::Path, sync::Arc};
 use ui::IconName;
-use util::markdown::MarkdownString;
+use util::markdown::MarkdownInlineCode;
 
 #[derive(Debug, Serialize, Deserialize, JsonSchema)]
 pub struct MovePathToolInput {
@@ -61,8 +61,8 @@ impl Tool for MovePathTool {
     fn ui_text(&self, input: &serde_json::Value) -> String {
         match serde_json::from_value::<MovePathToolInput>(input.clone()) {
             Ok(input) => {
-                let src = MarkdownString::inline_code(&input.source_path);
-                let dest = MarkdownString::inline_code(&input.destination_path);
+                let src = MarkdownInlineCode(&input.source_path);
+                let dest = MarkdownInlineCode(&input.destination_path);
                 let src_path = Path::new(&input.source_path);
                 let dest_path = Path::new(&input.destination_path);
 
@@ -71,7 +71,7 @@ impl Tool for MovePathTool {
                     .and_then(|os_str| os_str.to_os_string().into_string().ok())
                 {
                     Some(filename) if src_path.parent() == dest_path.parent() => {
-                        let filename = MarkdownString::inline_code(&filename);
+                        let filename = MarkdownInlineCode(&filename);
                         format!("Rename {src} to {filename}")
                     }
                     _ => {

crates/assistant_tools/src/open_tool.rs 🔗

@@ -8,7 +8,7 @@ use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
 use std::sync::Arc;
 use ui::IconName;
-use util::markdown::MarkdownString;
+use util::markdown::MarkdownEscaped;
 
 #[derive(Debug, Serialize, Deserialize, JsonSchema)]
 pub struct OpenToolInput {
@@ -41,7 +41,7 @@ impl Tool for OpenTool {
 
     fn ui_text(&self, input: &serde_json::Value) -> String {
         match serde_json::from_value::<OpenToolInput>(input.clone()) {
-            Ok(input) => format!("Open `{}`", MarkdownString::escape(&input.path_or_url)),
+            Ok(input) => format!("Open `{}`", MarkdownEscaped(&input.path_or_url)),
             Err(_) => "Open file or URL".to_string(),
         }
     }

crates/assistant_tools/src/read_file_tool.rs 🔗

@@ -11,7 +11,7 @@ use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
 use std::sync::Arc;
 use ui::IconName;
-use util::markdown::MarkdownString;
+use util::markdown::MarkdownInlineCode;
 
 /// If the model requests to read a file whose size exceeds this, then
 /// the tool will return an error along with the model's symbol outline,
@@ -71,7 +71,7 @@ impl Tool for ReadFileTool {
     fn ui_text(&self, input: &serde_json::Value) -> String {
         match serde_json::from_value::<ReadFileToolInput>(input.clone()) {
             Ok(input) => {
-                let path = MarkdownString::inline_code(&input.path);
+                let path = MarkdownInlineCode(&input.path);
                 match (input.start_line, input.end_line) {
                     (Some(start), None) => format!("Read file {path} (from line {start})"),
                     (Some(start), Some(end)) => format!("Read file {path} (lines {start}-{end})"),

crates/assistant_tools/src/symbol_info_tool.rs 🔗

@@ -8,7 +8,7 @@ use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
 use std::{fmt::Write, ops::Range, sync::Arc};
 use ui::IconName;
-use util::markdown::MarkdownString;
+use util::markdown::MarkdownInlineCode;
 
 use crate::schema::json_schema_for;
 
@@ -91,7 +91,7 @@ impl Tool for SymbolInfoTool {
     fn ui_text(&self, input: &serde_json::Value) -> String {
         match serde_json::from_value::<SymbolInfoToolInput>(input.clone()) {
             Ok(input) => {
-                let symbol = MarkdownString::inline_code(&input.symbol);
+                let symbol = MarkdownInlineCode(&input.symbol);
 
                 match input.command {
                     Info::Definition => {

crates/assistant_tools/src/terminal_tool.rs 🔗

@@ -15,7 +15,7 @@ use std::path::Path;
 use std::sync::Arc;
 use ui::IconName;
 use util::command::new_smol_command;
-use util::markdown::MarkdownString;
+use util::markdown::MarkdownInlineCode;
 
 #[derive(Debug, Serialize, Deserialize, JsonSchema)]
 pub struct TerminalToolInput {
@@ -55,17 +55,14 @@ impl Tool for TerminalTool {
                 let first_line = lines.next().unwrap_or_default();
                 let remaining_line_count = lines.count();
                 match remaining_line_count {
-                    0 => MarkdownString::inline_code(&first_line).0,
-                    1 => {
-                        MarkdownString::inline_code(&format!(
-                            "{} - {} more line",
-                            first_line, remaining_line_count
-                        ))
-                        .0
-                    }
-                    n => {
-                        MarkdownString::inline_code(&format!("{} - {} more lines", first_line, n)).0
-                    }
+                    0 => MarkdownInlineCode(&first_line).to_string(),
+                    1 => MarkdownInlineCode(&format!(
+                        "{} - {} more line",
+                        first_line, remaining_line_count
+                    ))
+                    .to_string(),
+                    n => MarkdownInlineCode(&format!("{} - {} more lines", first_line, n))
+                        .to_string(),
                 }
             }
             Err(_) => "Run terminal command".to_string(),

crates/eval/src/instance.rs 🔗

@@ -27,7 +27,7 @@ use std::time::Duration;
 use unindent::Unindent as _;
 use util::ResultExt as _;
 use util::command::new_smol_command;
-use util::markdown::MarkdownString;
+use util::markdown::MarkdownCodeBlock;
 
 use crate::assertions::{AssertionsReport, RanAssertion, RanAssertionResult};
 use crate::example::{Example, ExampleContext, FailedAssertion, JudgeAssertion};
@@ -863,7 +863,10 @@ impl RequestMarkdown {
                 write!(
                     &mut tools,
                     "{}\n",
-                    MarkdownString::code_block("json", &format!("{:#}", tool.input_schema))
+                    MarkdownCodeBlock {
+                        tag: "json",
+                        text: &format!("{:#}", tool.input_schema)
+                    }
                 )
                 .unwrap();
             }
@@ -910,7 +913,10 @@ impl RequestMarkdown {
                         ));
                         messages.push_str(&format!(
                             "{}\n",
-                            MarkdownString::code_block("json", &format!("{:#}", tool_use.input))
+                            MarkdownCodeBlock {
+                                tag: "json",
+                                text: &format!("{:#}", tool_use.input)
+                            }
                         ));
                     }
                     MessageContent::ToolResult(tool_result) => {
@@ -972,7 +978,10 @@ pub fn response_events_to_markdown(
                 ));
                 response.push_str(&format!(
                     "{}\n",
-                    MarkdownString::code_block("json", &format!("{:#}", tool_use.input))
+                    MarkdownCodeBlock {
+                        tag: "json",
+                        text: &format!("{:#}", tool_use.input)
+                    }
                 ));
             }
             Ok(

crates/repl/src/outputs/table.rs 🔗

@@ -61,7 +61,7 @@ use serde_json::Value;
 use settings::Settings;
 use theme::ThemeSettings;
 use ui::{IntoElement, Styled, div, prelude::*, v_flex};
-use util::markdown::MarkdownString;
+use util::markdown::MarkdownEscaped;
 
 use crate::outputs::OutputContent;
 
@@ -170,7 +170,7 @@ impl TableView {
                 let row_content = schema
                     .fields
                     .iter()
-                    .map(|field| MarkdownString::escape(&cell_content(record, &field.name)).0)
+                    .map(|field| MarkdownEscaped(&cell_content(record, &field.name)).to_string())
                     .collect::<Vec<_>>();
 
                 row_content.join(" | ")

crates/settings/src/keymap_file.rs 🔗

@@ -13,7 +13,10 @@ use schemars::{
 use serde::Deserialize;
 use serde_json::Value;
 use std::{any::TypeId, fmt::Write, rc::Rc, sync::Arc, sync::LazyLock};
-use util::{asset_str, markdown::MarkdownString};
+use util::{
+    asset_str,
+    markdown::{MarkdownEscaped, MarkdownInlineCode, MarkdownString},
+};
 
 use crate::{SettingsAssets, settings_store::parse_json_with_comments};
 
@@ -152,7 +155,7 @@ impl KeymapFile {
         match Self::load(asset_str::<SettingsAssets>(asset_path).as_ref(), cx) {
             KeymapFileLoadResult::Success { key_bindings } => Ok(key_bindings),
             KeymapFileLoadResult::SomeFailedToLoad { error_message, .. } => Err(anyhow!(
-                "Error loading built-in keymap \"{asset_path}\": {error_message}"
+                "Error loading built-in keymap \"{asset_path}\": {error_message}",
             )),
             KeymapFileLoadResult::JsonParseFailure { error } => Err(anyhow!(
                 "JSON parse error in built-in keymap \"{asset_path}\": {error}"
@@ -171,7 +174,7 @@ impl KeymapFile {
                 error_message,
                 ..
             } if key_bindings.is_empty() => Err(anyhow!(
-                "Error loading built-in keymap \"{asset_path}\": {error_message}"
+                "Error loading built-in keymap \"{asset_path}\": {error_message}",
             )),
             KeymapFileLoadResult::Success { key_bindings, .. }
             | KeymapFileLoadResult::SomeFailedToLoad { key_bindings, .. } => Ok(key_bindings),
@@ -251,7 +254,7 @@ impl KeymapFile {
                 write!(
                     section_errors,
                     "\n\n - Unrecognized fields: {}",
-                    MarkdownString::inline_code(&format!("{:?}", unrecognized_fields.keys()))
+                    MarkdownInlineCode(&format!("{:?}", unrecognized_fields.keys()))
                 )
                 .unwrap();
             }
@@ -280,7 +283,7 @@ impl KeymapFile {
                             write!(
                                 section_errors,
                                 "\n\n- In binding {}, {indented_err}",
-                                inline_code_string(keystrokes),
+                                MarkdownInlineCode(&format!("\"{}\"", keystrokes))
                             )
                             .unwrap();
                         }
@@ -299,16 +302,15 @@ impl KeymapFile {
             let mut error_message = "Errors in user keymap file.\n".to_owned();
             for (context, section_errors) in errors {
                 if context.is_empty() {
-                    write!(error_message, "\n\nIn section without context predicate:").unwrap()
+                    let _ = write!(error_message, "\n\nIn section without context predicate:");
                 } else {
-                    write!(
+                    let _ = write!(
                         error_message,
                         "\n\nIn section with {}:",
-                        MarkdownString::inline_code(&format!("context = \"{}\"", context))
-                    )
-                    .unwrap()
+                        MarkdownInlineCode(&format!("context = \"{}\"", context))
+                    );
                 }
-                write!(error_message, "{section_errors}").unwrap();
+                let _ = write!(error_message, "{section_errors}");
             }
             KeymapFileLoadResult::SomeFailedToLoad {
                 key_bindings,
@@ -330,14 +332,14 @@ impl KeymapFile {
                     return Err(format!(
                         "expected two-element array of `[name, input]`. \
                         Instead found {}.",
-                        MarkdownString::inline_code(&action.0.to_string())
+                        MarkdownInlineCode(&action.0.to_string())
                     ));
                 }
                 let serde_json::Value::String(ref name) = items[0] else {
                     return Err(format!(
                         "expected two-element array of `[name, input]`, \
                         but the first element is not a string in {}.",
-                        MarkdownString::inline_code(&action.0.to_string())
+                        MarkdownInlineCode(&action.0.to_string())
                     ));
                 };
                 let action_input = items[1].clone();
@@ -353,7 +355,7 @@ impl KeymapFile {
                 return Err(format!(
                     "expected two-element array of `[name, input]`. \
                     Instead found {}.",
-                    MarkdownString::inline_code(&action.0.to_string())
+                    MarkdownInlineCode(&action.0.to_string())
                 ));
             }
         };
@@ -363,23 +365,23 @@ impl KeymapFile {
             Err(ActionBuildError::NotFound { name }) => {
                 return Err(format!(
                     "didn't find an action named {}.",
-                    inline_code_string(&name)
+                    MarkdownInlineCode(&format!("\"{}\"", &name))
                 ));
             }
             Err(ActionBuildError::BuildError { name, error }) => match action_input_string {
                 Some(action_input_string) => {
                     return Err(format!(
                         "can't build {} action from input value {}: {}",
-                        inline_code_string(&name),
-                        MarkdownString::inline_code(&action_input_string),
-                        MarkdownString::escape(&error.to_string())
+                        MarkdownInlineCode(&format!("\"{}\"", &name)),
+                        MarkdownInlineCode(&action_input_string),
+                        MarkdownEscaped(&error.to_string())
                     ));
                 }
                 None => {
                     return Err(format!(
                         "can't build {} action - it requires input data via [name, input]: {}",
-                        inline_code_string(&name),
-                        MarkdownString::escape(&error.to_string())
+                        MarkdownInlineCode(&format!("\"{}\"", &name)),
+                        MarkdownEscaped(&error.to_string())
                     ));
                 }
             },
@@ -390,7 +392,7 @@ impl KeymapFile {
             Err(InvalidKeystrokeError { keystroke }) => {
                 return Err(format!(
                     "invalid keystroke {}. {}",
-                    inline_code_string(&keystroke),
+                    MarkdownInlineCode(&format!("\"{}\"", &keystroke)),
                     KEYSTROKE_PARSE_EXPECTED_MESSAGE
                 ));
             }
@@ -606,11 +608,6 @@ impl KeymapFile {
     }
 }
 
-// Double quotes a string and wraps it in backticks for markdown inline code..
-fn inline_code_string(text: &str) -> MarkdownString {
-    MarkdownString::inline_code(&format!("\"{}\"", text))
-}
-
 #[cfg(test)]
 mod tests {
     use crate::KeymapFile;

crates/util/src/markdown.rs 🔗

@@ -1,6 +1,6 @@
 use std::fmt::{Display, Formatter};
 
-/// Markdown text.
+/// Indicates that the wrapped `String` is markdown text.
 #[derive(Debug, Clone)]
 pub struct MarkdownString(pub String);
 
@@ -10,31 +10,45 @@ impl Display for MarkdownString {
     }
 }
 
-impl MarkdownString {
-    /// Escapes markdown special characters in markdown text blocks. Markdown code blocks follow
-    /// different rules and `MarkdownString::inline_code` or `MarkdownString::code_block` should be
-    /// used in that case.
-    ///
-    /// Also escapes the following markdown extensions:
-    ///
-    /// * `^` for superscripts
-    /// * `$` for inline math
-    /// * `~` for strikethrough
-    ///
-    /// Escape of some characters is unnecessary, because while they are involved in markdown syntax,
-    /// the other characters involved are escaped:
-    ///
-    /// * `!`, `]`, `(`, and `)` are used in link syntax, but `[` is escaped so these are parsed as
-    /// plaintext.
-    ///
-    /// * `;` is used in HTML entity syntax, but `&` is escaped, so they are parsed as plaintext.
-    ///
-    /// TODO: There is one escape this doesn't do currently. Period after numbers at the start of the
-    /// line (`[0-9]*\.`) should also be escaped to avoid it being interpreted as a list item.
-    pub fn escape(text: &str) -> Self {
-        let mut chunks = Vec::new();
+/// Escapes markdown special characters in markdown text blocks. Markdown code blocks follow
+/// different rules and `MarkdownInlineCode` or `MarkdownCodeBlock` should be used in that case.
+///
+/// Also escapes the following markdown extensions:
+///
+/// * `^` for superscripts
+/// * `$` for inline math
+/// * `~` for strikethrough
+///
+/// Escape of some characters is unnecessary, because while they are involved in markdown syntax,
+/// the other characters involved are escaped:
+///
+/// * `!`, `]`, `(`, and `)` are used in link syntax, but `[` is escaped so these are parsed as
+/// plaintext.
+///
+/// * `;` is used in HTML entity syntax, but `&` is escaped, so they are parsed as plaintext.
+///
+/// TODO: There is one escape this doesn't do currently. Period after numbers at the start of the
+/// line (`[0-9]*\.`) should also be escaped to avoid it being interpreted as a list item.
+pub struct MarkdownEscaped<'a>(pub &'a str);
+
+/// Implements `Display` to format markdown inline code (wrapped in backticks), handling code that
+/// contains backticks and spaces. All whitespace is treated as a single space character. For text
+/// that does not contain whitespace other than ' ', this escaping roundtrips through
+/// pulldown-cmark.
+///
+/// When used in tables, `|` should be escaped like `\|` in the text provided to this function.
+pub struct MarkdownInlineCode<'a>(pub &'a str);
+
+/// Implements `Display` to format markdown code blocks, wrapped in 3 or more backticks as needed.
+pub struct MarkdownCodeBlock<'a> {
+    pub tag: &'a str,
+    pub text: &'a str,
+}
+
+impl Display for MarkdownEscaped<'_> {
+    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
         let mut start_of_unescaped = None;
-        for (ix, c) in text.char_indices() {
+        for (ix, c) in self.0.char_indices() {
             match c {
                 // Always escaped.
                 '\\' | '`' | '*' | '_' | '[' | '^' | '$' | '~' | '&' |
@@ -45,10 +59,10 @@ impl MarkdownString {
                     match start_of_unescaped {
                         None => {}
                         Some(start_of_unescaped) => {
-                            chunks.push(&text[start_of_unescaped..ix]);
+                            write!(formatter, "{}", &self.0[start_of_unescaped..ix])?;
                         }
                     }
-                    chunks.push("\\");
+                    write!(formatter, "\\")?;
                     // Can include this char in the "unescaped" text since a
                     // backslash was just emitted.
                     start_of_unescaped = Some(ix);
@@ -59,10 +73,10 @@ impl MarkdownString {
                     match start_of_unescaped {
                         None => {}
                         Some(start_of_unescaped) => {
-                            chunks.push(&text[start_of_unescaped..ix]);
+                            write!(formatter, "{}", &self.0[start_of_unescaped..ix])?;
                         }
                     }
-                    chunks.push("&lt;");
+                    write!(formatter, "&lt;")?;
                     start_of_unescaped = None;
                 }
                 // Escaped since `>` is used for blockquotes. `&gt;` is used since Markdown supports
@@ -71,10 +85,10 @@ impl MarkdownString {
                     match start_of_unescaped {
                         None => {}
                         Some(start_of_unescaped) => {
-                            chunks.push(&text[start_of_unescaped..ix]);
+                            write!(formatter, "{}", &self.0[start_of_unescaped..ix])?;
                         }
                     }
-                    chunks.push("gt;");
+                    write!(formatter, "&gt;")?;
                     start_of_unescaped = None;
                 }
                 _ => {
@@ -85,17 +99,14 @@ impl MarkdownString {
             }
         }
         if let Some(start_of_unescaped) = start_of_unescaped {
-            chunks.push(&text[start_of_unescaped..])
+            write!(formatter, "{}", &self.0[start_of_unescaped..])?;
         }
-        Self(chunks.concat())
+        Ok(())
     }
+}
 
-    /// Returns markdown for inline code (wrapped in backticks), handling code that contains backticks
-    /// and spaces. All whitespace is treated as a single space character. For text that does not
-    /// contain whitespace other than ' ', this escaping roundtrips through pulldown-cmark.
-    ///
-    /// When used in tables, `|` should be escaped like `\|` in the text provided to this function.
-    pub fn inline_code(text: &str) -> Self {
+impl Display for MarkdownInlineCode<'_> {
+    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
         // Apache License 2.0, same as this crate.
         //
         // Copied from `pulldown-cmark-to-cmark-20.0.0` with modifications:
@@ -103,12 +114,11 @@ impl MarkdownString {
         // * Handling of all whitespace. pulldown-cmark-to-cmark is anticipating
         // `Code` events parsed by pulldown-cmark.
         //
-        // * Direct return of string.
-        //
         // https://github.com/Byron/pulldown-cmark-to-cmark/blob/3c850de2d3d1d79f19ca5f375e1089a653cf3ff7/src/lib.rs#L290
 
         let mut all_whitespace = true;
-        let text = text
+        let text = self
+            .0
             .chars()
             .map(|c| {
                 if c.is_whitespace() {
@@ -123,7 +133,7 @@ impl MarkdownString {
         // When inline code has leading and trailing ' ' characters, additional space is needed
         // to escape it, unless all characters are space.
         if all_whitespace {
-            Self(format!("`{text}`"))
+            write!(formatter, "`{text}`")
         } else {
             // More backticks are needed to delimit the inline code than the maximum number of
             // backticks in a consecutive run.
@@ -133,14 +143,17 @@ impl MarkdownString {
                 &[b' ', .., b' '] => " ",         // Space needed to escape inner space.
                 _ => "",                          // No space needed.
             };
-            Self(format!("{backticks}{space}{text}{space}{backticks}"))
+            write!(formatter, "{backticks}{space}{text}{space}{backticks}")
         }
     }
+}
 
-    /// Returns markdown for code blocks, wrapped in 3 or more backticks as needed.
-    pub fn code_block(tag: &str, text: &str) -> Self {
+impl Display for MarkdownCodeBlock<'_> {
+    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
+        let tag = self.tag;
+        let text = self.text;
         let backticks = "`".repeat(3.max(count_max_consecutive_chars(text, '`') + 1));
-        Self(format!("{backticks}{tag}\n{text}\n{backticks}\n"))
+        write!(formatter, "{backticks}{tag}\n{text}\n{backticks}\n")
     }
 }
 
@@ -170,7 +183,7 @@ mod tests {
     use super::*;
 
     #[test]
-    fn test_markdown_string_escape() {
+    fn test_markdown_escaped() {
         let input = r#"
         # Heading
 
@@ -221,20 +234,20 @@ mod tests {
         HTML entity: \&nbsp;
         "#;
 
-        assert_eq!(MarkdownString::escape(input).0, expected);
+        assert_eq!(MarkdownEscaped(input).to_string(), expected);
     }
 
     #[test]
-    fn test_markdown_string_inline_code() {
-        assert_eq!(MarkdownString::inline_code(" ").0, "` `");
-        assert_eq!(MarkdownString::inline_code("text").0, "`text`");
-        assert_eq!(MarkdownString::inline_code("text ").0, "`text `");
-        assert_eq!(MarkdownString::inline_code(" text ").0, "`  text  `");
-        assert_eq!(MarkdownString::inline_code("`").0, "`` ` ``");
-        assert_eq!(MarkdownString::inline_code("``").0, "``` `` ```");
-        assert_eq!(MarkdownString::inline_code("`text`").0, "`` `text` ``");
+    fn test_markdown_inline_code() {
+        assert_eq!(MarkdownInlineCode(" ").to_string(), "` `");
+        assert_eq!(MarkdownInlineCode("text").to_string(), "`text`");
+        assert_eq!(MarkdownInlineCode("text ").to_string(), "`text `");
+        assert_eq!(MarkdownInlineCode(" text ").to_string(), "`  text  `");
+        assert_eq!(MarkdownInlineCode("`").to_string(), "`` ` ``");
+        assert_eq!(MarkdownInlineCode("``").to_string(), "``` `` ```");
+        assert_eq!(MarkdownInlineCode("`text`").to_string(), "`` `text` ``");
         assert_eq!(
-            MarkdownString::inline_code("some `text` no leading or trailing backticks").0,
+            MarkdownInlineCode("some `text` no leading or trailing backticks").to_string(),
             "``some `text` no leading or trailing backticks``"
         );
     }