Repository: yshavit/mdq (rust)

Issue:
handle case where link text contains a footnote
link text can't contain a link (by definition), but it could contain a footnote.

I should check for that. I bet it's wrong right now.

- [x] check for md output
- [x] check for json output
- [x] check for footnotes that contain other footnotes

Pull request:
handle footnotes that contain footnotes or links
Links can't contain other links or footnotes -- but footnotes can contain either. Add tests and logic to handle it.

Resolves #98.

I found #188 (footnotes that cycle back to themselves) while writing tests for this, but that's a special case, so I'm going to handle it separately.

Patch:
diff --git a/src/fmt_md_inlines.rs b/src/fmt_md_inlines.rs
index 2cf5aeb..99aa19b 100644
--- a/src/fmt_md_inlines.rs
+++ b/src/fmt_md_inlines.rs
@@ -170,8 +170,80 @@ impl<'md> MdInlinesWriter<'md> {
                 out.write_str("[^");
                 self.footnote_transformer.write(out, label);
                 out.write_char(']');
-                if self.seen_footnotes.insert(label) {
-                    self.pending_references.footnotes.insert(label, text);
+                self.add_footnote(label, text);
+            }
+        }
+    }
+
+    fn add_footnote(&mut self, label: &'md String, text: &'md Vec<MdElem>) {
+        if self.seen_footnotes.insert(label) {
+            self.pending_references.footnotes.insert(label, text);
+            self.find_references_in_footnote_elems(text);
+        }
+    }
+
+    /// Searches the footnote's text to find any link references and additional footnotes.
+    /// Otherwise, by the time we see them it'll be too late to add them to their respective
+    /// collections.
+    fn find_references_in_footnote_elems(&mut self, text: &'md Vec<MdElem>) {
+        for elem in text {
+            match elem {
+                MdElem::BlockQuote(block) => {
+                    self.find_references_in_footnote_elems(&block.body);
+                }
+                MdElem::List(list) => {
+                    for li in &list.items {
+                        self.find_references_in_footnote_elems(&li.item);
+                    }
+                }
+                MdElem::Section(section) => {
+                    self.find_references_in_footnote_inlines(&section.title);
+                    self.find_references_in_footnote_elems(&section.body);
+                }
+                MdElem::Paragraph(para) => {
+                    self.find_references_in_footnote_inlines(&para.body);
+                }
+                MdElem::Table(table) => {
+                    for row in &table.rows {
+                        for cell in row {
+                            self.find_references_in_footnote_inlines(cell);
+                        }
+                    }
+                }
+                MdElem::Inline(inline) => {
+                    self.find_references_in_footnote_inlines([inline]); // TODO do I need the array?
+                }
+                MdElem::CodeBlock(_) | MdElem::Html(_) | MdElem::ThematicBreak => {
+                    // nothing
+                }
+            }
+        }
+    }
+
+    fn find_references_in_footnote_inlines<I>(&mut self, text: I)
+    where
+        I: IntoIterator<Item = &'md Inline>,
+    {
+        for inline in text.into_iter() {
+            match inline {
+                Inline::Footnote(footnote) => {
+                    self.add_footnote(&footnote.label, &footnote.text);
+                }
+                Inline::Formatting(item) => {
+                    self.find_references_in_footnote_inlines(&item.children);
+                }
+                Inline::Link(link) => {
+                    let link_label = match &link.link_definition.reference {
+                        LinkReference::Inline => None,
+                        LinkReference::Full(reference) => Some(LinkLabel::Text(Cow::Borrowed(reference))),
+                        LinkReference::Collapsed | LinkReference::Shortcut => Some(LinkLabel::Inline(&link.text)),
+                    };
+                    if let Some(label) = link_label {
+                        self.add_link_reference(label, &link.link_definition);
+                    }
+                }
+                Inline::Image(_) | Inline::Text(_) => {
+                    // nothing
                 }
             }
         }
@@ -242,16 +314,20 @@ impl<'md> MdInlinesWriter<'md> {
         };
 
         if let Some(reference_label) = reference_to_add {
-            if self.seen_links.insert(reference_label.clone()) {
-                self.pending_references.links.insert(
-                    reference_label,
-                    UrlAndTitle {
-                        url: &link.url,
-                        title: &link.title,
-                    },
-                );
-                // else warn?
-            }
+            self.add_link_reference(reference_label, link);
+        }
+    }
+
+    fn add_link_reference(&mut self, reference_label: LinkLabel<'md>, link: &'md LinkDefinition) {
+        if self.seen_links.insert(reference_label.clone()) {
+            self.pending_references.links.insert(
+                reference_label,
+                UrlAndTitle {
+                    url: &link.url,
+                    title: &link.title,
+                },
+            );
+            // else warn?
         }
     }
 


Test patch:
diff --git a/tests/integ_test.rs b/tests/integ_test.rs
index 145d797..055902d 100644
--- a/tests/integ_test.rs
+++ b/tests/integ_test.rs
@@ -14,14 +14,17 @@ impl<const N: usize> Case<N> {
         let all_cli_args = ["cmd"].iter().chain(&self.cli_args);
         let cli = mdq::cli::Cli::try_parse_from(all_cli_args).unwrap();
         let (actual_success, actual_out) = mdq::run_in_memory(&cli, self.md);
-        if self.expect_output_json {
-            assert_eq!(
-                serde_json::from_str::<serde_json::Value>(&actual_out).unwrap(),
-                serde_json::from_str::<serde_json::Value>(self.expect_output).unwrap()
-            );
+        let (actual_out, expect_out) = if self.expect_output_json {
+            let actual_obj = serde_json::from_str::<serde_json::Value>(&actual_out).unwrap();
+            let expect_obj = serde_json::from_str::<serde_json::Value>(self.expect_output).unwrap();
+            (
+                serde_json::to_string_pretty(&actual_obj).unwrap(),
+                serde_json::to_string_pretty(&expect_obj).unwrap(),
+            )
         } else {
-            assert_eq!(actual_out, self.expect_output);
-        }
+            (actual_out, self.expect_output.to_string())
+        };
+        assert_eq!(actual_out, expect_out);
         assert_eq!(actual_success, self.expect_success);
     }
 }
diff --git a/tests/md_cases/footnotes_in_footnotes.toml b/tests/md_cases/footnotes_in_footnotes.toml
new file mode 100644
index 0000000..69002d4
--- /dev/null
+++ b/tests/md_cases/footnotes_in_footnotes.toml
@@ -0,0 +1,104 @@
+[given]
+md = '''
+- AAA: (footnotes in links don't work: see https://gist.github.com/yshavit/6af0a784e338dc32e66717aa6f495ffe )
+- BBB: footnote contains footnote[^2]
+- CCC: footnote contains link[^3]
+
+[^1]: the link's footnote text
+[^2]: this footnote contains[^a] a footnote
+[^3]: this footnote contains a [link][3a]
+[^a]: this is the footnote's footnote
+
+[3a]: https://example.com/3a
+'''
+
+[chained]
+needed = false
+
+
+[expect."just footnote contains footnote"]
+cli_args = ['- BBB']
+output = '''
+- BBB: footnote contains footnote[^1]
+
+[^1]: this footnote contains[^2] a footnote
+[^2]: this is the footnote's footnote
+'''
+
+
+[expect."just footnote contains footnote: json"]
+cli_args = ['- BBB | P: *', '--output', 'json']
+output_json = true
+output = '''
+{
+  "items": [
+    {
+      "paragraph": "BBB: footnote contains footnote[^1]"
+    }
+  ],
+  "footnotes": {
+    "1": [
+      {
+        "paragraph": "this footnote contains[^2] a footnote"
+      }
+    ],
+    "2": [
+      {
+        "paragraph": "this is the footnote's footnote"
+      }
+    ]
+  }
+}
+'''
+
+
+[expect."just footnote contains link"]
+cli_args = ['- CCC']
+output = '''
+- CCC: footnote contains link[^1]
+
+[3a]: https://example.com/3a
+[^1]: this footnote contains a [link][3a]
+'''
+
+
+[expect."just footnote contains link: json"]
+cli_args = ['- CCC | P: *', '--output', 'json']
+output_json = true
+output = '''
+{
+  "items": [
+    {
+      "paragraph": "CCC: footnote contains link[^1]"
+    }
+  ],
+  "footnotes": {
+    "1": [
+      {
+        "paragraph": "this footnote contains a [link][3a]"
+      }
+    ]
+  },
+  "links": {
+    "3a": {
+      "url": "https://example.com/3a"
+    }
+  }
+}
+'''
+
+
+[expect."cyclic reference does't cause infinite loop"]
+ignore = '#188'
+# When ready to add this back in, add the following to the input markdown:
+#
+#     - DDD: footnote contains cycle[^cycle]
+#
+#     [^cycle]: this footnote references itself[^cycle]
+#
+cli_args = ['- DDD | P: *']
+output = '''
+- DDD: footnote contains cycle[^cycle]
+
+[^cycle]: this footnote references itself[^cycle]
+'''