diff --git a/crates/agent/src/active_thread.rs b/crates/agent/src/active_thread.rs index 245a4801e6..ce5bbe3598 100644 --- a/crates/agent/src/active_thread.rs +++ b/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, ); }); diff --git a/crates/assistant_tools/src/code_symbols_tool.rs b/crates/assistant_tools/src/code_symbols_tool.rs index f5b1b8bb97..73d6684773 100644 --- a/crates/assistant_tools/src/code_symbols_tool.rs +++ b/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 { diff --git a/crates/assistant_tools/src/contents_tool.rs b/crates/assistant_tools/src/contents_tool.rs index 4cbeab1c41..a7494816f5 100644 --- a/crates/assistant_tools/src/contents_tool.rs +++ b/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::(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})"), diff --git a/crates/assistant_tools/src/copy_path_tool.rs b/crates/assistant_tools/src/copy_path_tool.rs index 4d94b82203..4ea11e999b 100644 --- a/crates/assistant_tools/src/copy_path_tool.rs +++ b/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::(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(), diff --git a/crates/assistant_tools/src/create_directory_tool.rs b/crates/assistant_tools/src/create_directory_tool.rs index bbf3ff346b..4ab8546cee 100644 --- a/crates/assistant_tools/src/create_directory_tool.rs +++ b/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::(input.clone()) { Ok(input) => { - format!( - "Create directory {}", - MarkdownString::inline_code(&input.path) - ) + format!("Create directory {}", MarkdownInlineCode(&input.path)) } Err(_) => "Create directory".to_string(), } diff --git a/crates/assistant_tools/src/create_file_tool.rs b/crates/assistant_tools/src/create_file_tool.rs index 7e434f0fc5..82250e7f97 100644 --- a/crates/assistant_tools/src/create_file_tool.rs +++ b/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::(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(), diff --git a/crates/assistant_tools/src/diagnostics_tool.rs b/crates/assistant_tools/src/diagnostics_tool.rs index 8fdfd58a59..17f96177c1 100644 --- a/crates/assistant_tools/src/diagnostics_tool.rs +++ b/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::(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() } diff --git a/crates/assistant_tools/src/fetch_tool.rs b/crates/assistant_tools/src/fetch_tool.rs index f37cd16748..413a4c5589 100644 --- a/crates/assistant_tools/src/fetch_tool.rs +++ b/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::(input.clone()) { - Ok(input) => format!("Fetch {}", MarkdownString::escape(&input.url)), + Ok(input) => format!("Fetch {}", MarkdownEscaped(&input.url)), Err(_) => "Fetch URL".to_string(), } } diff --git a/crates/assistant_tools/src/grep_tool.rs b/crates/assistant_tools/src/grep_tool.rs index 91bd78ca5a..439dca17d0 100644 --- a/crates/assistant_tools/src/grep_tool.rs +++ b/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::(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 { diff --git a/crates/assistant_tools/src/list_directory_tool.rs b/crates/assistant_tools/src/list_directory_tool.rs index 7b3e2a38ff..26665f311c 100644 --- a/crates/assistant_tools/src/list_directory_tool.rs +++ b/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::(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(), diff --git a/crates/assistant_tools/src/move_path_tool.rs b/crates/assistant_tools/src/move_path_tool.rs index b024ed3147..89691694a6 100644 --- a/crates/assistant_tools/src/move_path_tool.rs +++ b/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::(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}") } _ => { diff --git a/crates/assistant_tools/src/open_tool.rs b/crates/assistant_tools/src/open_tool.rs index 19d5cce023..88894ef79f 100644 --- a/crates/assistant_tools/src/open_tool.rs +++ b/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::(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(), } } diff --git a/crates/assistant_tools/src/read_file_tool.rs b/crates/assistant_tools/src/read_file_tool.rs index f81b4fe209..62260e0c90 100644 --- a/crates/assistant_tools/src/read_file_tool.rs +++ b/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::(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})"), diff --git a/crates/assistant_tools/src/symbol_info_tool.rs b/crates/assistant_tools/src/symbol_info_tool.rs index c59b3820fe..ade71dfbf1 100644 --- a/crates/assistant_tools/src/symbol_info_tool.rs +++ b/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::(input.clone()) { Ok(input) => { - let symbol = MarkdownString::inline_code(&input.symbol); + let symbol = MarkdownInlineCode(&input.symbol); match input.command { Info::Definition => { diff --git a/crates/assistant_tools/src/terminal_tool.rs b/crates/assistant_tools/src/terminal_tool.rs index d777d5cb0e..68f0a64d14 100644 --- a/crates/assistant_tools/src/terminal_tool.rs +++ b/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(), diff --git a/crates/eval/src/instance.rs b/crates/eval/src/instance.rs index e165506abf..b92bd1bd0f 100644 --- a/crates/eval/src/instance.rs +++ b/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( diff --git a/crates/repl/src/outputs/table.rs b/crates/repl/src/outputs/table.rs index cb626da895..5165168e9b 100644 --- a/crates/repl/src/outputs/table.rs +++ b/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::>(); row_content.join(" | ") diff --git a/crates/settings/src/keymap_file.rs b/crates/settings/src/keymap_file.rs index 957276d355..e7ba0ce8b8 100644 --- a/crates/settings/src/keymap_file.rs +++ b/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::(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; diff --git a/crates/util/src/markdown.rs b/crates/util/src/markdown.rs index 39a6ef6b1d..7e66ed7bae 100644 --- a/crates/util/src/markdown.rs +++ b/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("<"); + write!(formatter, "<")?; start_of_unescaped = None; } // Escaped since `>` is used for blockquotes. `>` 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, ">")?; 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: \  "#; - 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``" ); }