Repository: yshavit/mdq (rust)

Issue:
fmt_md needs better handling of backticks in inline code
We currently don't do anything if an inline code span contains backticks. This means they would break. For example, if the original markdown were:

> ```md
> Foo `` ` bar ``
> ```
> Foo `` ` bar ``

Then the inline code text is:
```md
` bar
```

(note: no leading/trailing spaces)

Today, we render inline code by just adding a backtick at the start and end:


> ```md
> Foo ``` bar`
> ```
> Foo ``` bar`

Instead, we should:
1. Count the longest stretch of backticks
2. Use that+1 as the code delimiter
3. Add a leading+trailing space if the string starts or ends with a backtick

Pull request description:
handle backticks within inline code blocks
An inline code block starts with a string of at least one backtick, but possibly more: `` ` ``, ` `` `, etc. It then continues until that same sting again.

The actual surrounding backticks don't matter, as long as they don't appear in the contents. The following are equivalent, and both produce `a``b`:

```markdown
`a``b`
```a``b```
```

The easiest way to handle this is to count the longest stretch of backticks in the contents, and then use a string of N+1 backticks for the inline code markers. So, that's what this PR does.

I can imagine nicer formatting, like finding the first available empty spot; or maybe just special-casing ` `` `. But for now, I'm keeping things simple.

If the contents start or end with backspace, we add a single space on either side of the contents (between it and the backticks). The CommonMark spec says these should be stripped on parse; they're currently not, because of newly-filed #171.

This PR resolves #157.

Patch:
diff --git a/Cargo.lock b/Cargo.lock
index f62349b..2e18b61 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -160,9 +160,9 @@ checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646"
 
 [[package]]
 name = "markdown"
-version = "1.0.0-alpha.17"
+version = "1.0.0-alpha.19"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "21e27d6220ce21f80ce5c4201f23a37c6f1ad037c72c9d1ff215c2919605a5d6"
+checksum = "8bf98a7fcfa423e67aafbaf1bfe7b689ce064434bfed02fa4d9d6db0fc8cc50b"
 dependencies = [
  "unicode-id",
 ]
diff --git a/Cargo.toml b/Cargo.toml
index eb5978e..ba938fa 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -10,7 +10,7 @@ repository = "https://github.com/yshavit/mdq"
 
 [dependencies]
 clap = { version = "4.5.7", features = ["derive"] }
-markdown = "1.0.0-alpha.16"
+markdown = "1.0.0-alpha.19"
 paste = "1.0"
 regex = "1.10.4"
 serde = { version = "1", features = ["derive"] }
diff --git a/src/fmt_md_inlines.rs b/src/fmt_md_inlines.rs
index ce6eeca..da61b17 100644
--- a/src/fmt_md_inlines.rs
+++ b/src/fmt_md_inlines.rs
@@ -6,6 +6,7 @@ use crate::tree::{
 };
 use serde::Serialize;
 use std::borrow::Cow;
+use std::cmp::max;
 use std::collections::{HashMap, HashSet};
 
 #[derive(Debug, Copy, Clone)]
@@ -128,15 +129,29 @@ impl<'md> MdInlinesWriter<'md> {
                 out.write_str(surround);
             }
             Inline::Text(Text { variant, value }) => {
-                let surround = match variant {
-                    TextVariant::Plain => "",
-                    TextVariant::Code => "`",
-                    TextVariant::Math => "$",
-                    TextVariant::Html => "",
+                let (surround_ch, surround_space) = match variant {
+                    TextVariant::Plain => (Cow::Borrowed(""), false),
+                    TextVariant::Math => (Cow::Borrowed("$"), false),
+                    TextVariant::Html => (Cow::Borrowed(""), false),
+                    TextVariant::Code => {
+                        let backticks_info = BackticksInfo::from(value);
+                        let surround_ch = if backticks_info.count == 0 {
+                            Cow::Borrowed("`")
+                        } else {
+                            Cow::Owned("`".repeat(backticks_info.count + 1))
+                        };
+                        (surround_ch, backticks_info.at_either_end)
+                    }
                 };
-                out.write_str(surround);
+                out.write_str(&surround_ch);
+                if surround_space {
+                    out.write_char(' ');
+                }
                 out.write_str(value);
-                out.write_str(surround);
+                if surround_space {
+                    out.write_char(' ');
+                }
+                out.write_str(&surround_ch);
             }
             Inline::Link(link) => self.write_linklike(out, link),
             Inline::Image(image) => self.write_linklike(out, image),
@@ -225,6 +240,32 @@ impl<'md> MdInlinesWriter<'md> {
     }
 }
 
+struct BackticksInfo {
+    count: usize,
+    at_either_end: bool,
+}
+
+impl From<&String> for BackticksInfo {
+    fn from(s: &String) -> Self {
+        let mut overall_max = 0;
+        let mut current_stretch = 0;
+        for c in s.chars() {
+            match c {
+                '`' => current_stretch += 1,
+                _ => {
+                    if current_stretch > 0 {
+                        overall_max = max(current_stretch, overall_max);
+                        current_stretch = 0;
+                    }
+                }
+            }
+        }
+        let count = max(current_stretch, overall_max);
+        let at_either_end = s.starts_with('`') || s.ends_with('`');
+        Self { count, at_either_end }
+    }
+}
+
 enum TitleQuote {
     Double,
     Single,
@@ -276,6 +317,9 @@ impl TitleQuote {
 mod tests {
     use super::*;
     use crate::output::Output;
+    use crate::tree::ReadOptions;
+    use crate::unwrap;
+    use crate::utils_for_test::get_only;
 
     mod title_quoting {
         use super::*;
@@ -318,4 +362,82 @@ mod tests {
             assert_eq!(&actual, expected);
         }
     }
+
+    mod inline_code {
+        use super::*;
+
+        #[test]
+        fn round_trip_no_backticks() {
+            round_trip_inline("hello world");
+        }
+
+        #[test]
+        fn round_trip_one_backtick() {
+            round_trip_inline("hello ` world");
+        }
+
+        #[test]
+        #[ignore] // #171
+        fn round_trip_one_backtick_at_start() {
+            round_trip_inline("`hello");
+        }
+
+        #[test]
+        #[ignore] // #171
+        fn round_trip_one_backtick_at_end() {
+            round_trip_inline("hello `");
+        }
+
+        #[test]
+        fn round_trip_three_backticks() {
+            round_trip_inline("hello ``` world");
+        }
+
+        #[test]
+        #[ignore] // #171
+        fn round_trip_three_backticks_at_end() {
+            round_trip_inline("hello `");
+        }
+
+        #[test]
+        #[ignore] // #171
+        fn round_trip_three_backticks_at_start() {
+            round_trip_inline("`hello");
+        }
+
+        #[test]
+        fn round_trip_surrounding_whitespace() {
+            round_trip_inline(" hello ");
+        }
+
+        #[test]
+        fn round_trip_backtick_and_surrounding_whitespace() {
+            round_trip_inline(" hello`world ");
+        }
+
+        fn round_trip_inline(inline_str: &str) {
+            round_trip(&Inline::Text(Text {
+                variant: TextVariant::Code,
+                value: inline_str.to_string(),
+            }));
+        }
+    }
+
+    /// Not a pure unit test; semi-integ. Checks that writing an inline to markdown and then parsing
+    /// that markdown results in the original inline.
+    fn round_trip(orig: &Inline) {
+        let mut output = Output::new(String::new());
+        let mut writer = MdInlinesWriter::new(MdInlinesWriterOptions {
+            link_format: LinkTransform::Keep,
+        });
+        writer.write_inline_element(&mut output, &orig);
+        let md_str = output.take_underlying().unwrap();
+
+        let ast = markdown::to_mdast(&md_str, &markdown::ParseOptions::gfm()).unwrap();
+        let md_tree = MdElem::read(ast, &ReadOptions::default()).unwrap();
+
+        unwrap!(&md_tree[0], MdElem::Paragraph(p));
+        let parsed = get_only(&p.body);
+        assert_eq!(parsed, orig);
+    }
 }


Test patch:
diff --git a/src/utils_for_test.rs b/src/utils_for_test.rs
index 38b6230..8192555 100644
--- a/src/utils_for_test.rs
+++ b/src/utils_for_test.rs
@@ -1,7 +1,29 @@
+#[cfg(test)]
+pub use test_utils::*;
+
 // We this file's contents from prod by putting them in a submodule guarded by cfg(test), but then "pub use" it to
 // export its contents.
 #[cfg(test)]
 mod test_utils {
+    use std::fmt::Debug;
+
+    pub fn get_only<T: Debug, C: IntoIterator<Item = T>>(col: C) -> T {
+        let mut iter = col.into_iter();
+        let Some(result) = iter.next() else {
+            panic!("expected an element, but was empty");
+        };
+        match iter.next() {
+            None => result,
+            Some(extra) => {
+                let mut all = Vec::new();
+                all.push(result);
+                all.push(extra);
+                all.extend(iter);
+                panic!("expected exactly one element, but found {}: {all:?}", all.len());
+            }
+        }
+    }
+
     /// Turn a pattern match into an `if let ... { else panic! }`.
     #[macro_export]
     macro_rules! unwrap {