From 981d2363851da3fa81bd7ec333d237604b823235 Mon Sep 17 00:00:00 2001
From: Robin Stocker <robin@nibor.org>
Date: Tue, 6 Feb 2024 23:04:37 +1100
Subject: [PATCH] Add getSpecialCharacters for escaping of extension characters

---
 .../strikethrough/StrikethroughExtension.java |  8 +++++++
 .../StrikethroughMarkdownRendererTest.java    |  6 +++---
 .../ext/gfm/tables/TablesExtension.java       |  8 +++++++
 .../internal/util/AsciiMatcher.java           | 16 ++++++++++----
 .../markdown/CoreMarkdownNodeRenderer.java    |  9 ++++----
 .../markdown/MarkdownNodeRendererContext.java |  8 +++++++
 .../markdown/MarkdownNodeRendererFactory.java |  8 +++++++
 .../renderer/markdown/MarkdownRenderer.java   | 21 +++++++++++++++++--
 8 files changed, 71 insertions(+), 13 deletions(-)

diff --git a/commonmark-ext-gfm-strikethrough/src/main/java/org/commonmark/ext/gfm/strikethrough/StrikethroughExtension.java b/commonmark-ext-gfm-strikethrough/src/main/java/org/commonmark/ext/gfm/strikethrough/StrikethroughExtension.java
index aa7dff71..f87f3e9c 100644
--- a/commonmark-ext-gfm-strikethrough/src/main/java/org/commonmark/ext/gfm/strikethrough/StrikethroughExtension.java
+++ b/commonmark-ext-gfm-strikethrough/src/main/java/org/commonmark/ext/gfm/strikethrough/StrikethroughExtension.java
@@ -17,6 +17,9 @@ import org.commonmark.renderer.text.TextContentNodeRendererContext;
 import org.commonmark.renderer.text.TextContentNodeRendererFactory;
 import org.commonmark.renderer.text.TextContentRenderer;
 
+import java.util.Collections;
+import java.util.Set;
+
 /**
  * Extension for GFM strikethrough using {@code ~} or {@code ~~} (GitHub Flavored Markdown).
  * <p>Example input:</p>
@@ -100,6 +103,11 @@ public class StrikethroughExtension implements Parser.ParserExtension, HtmlRende
             public NodeRenderer create(MarkdownNodeRendererContext context) {
                 return new StrikethroughMarkdownNodeRenderer(context);
             }
+
+            @Override
+            public Set<Character> getSpecialCharacters() {
+                return Collections.singleton('~');
+            }
         });
     }
 
diff --git a/commonmark-ext-gfm-strikethrough/src/test/java/org/commonmark/ext/gfm/strikethrough/StrikethroughMarkdownRendererTest.java b/commonmark-ext-gfm-strikethrough/src/test/java/org/commonmark/ext/gfm/strikethrough/StrikethroughMarkdownRendererTest.java
index b722018b..96df48ce 100644
--- a/commonmark-ext-gfm-strikethrough/src/test/java/org/commonmark/ext/gfm/strikethrough/StrikethroughMarkdownRendererTest.java
+++ b/commonmark-ext-gfm-strikethrough/src/test/java/org/commonmark/ext/gfm/strikethrough/StrikethroughMarkdownRendererTest.java
@@ -19,10 +19,10 @@ public class StrikethroughMarkdownRendererTest {
     @Test
     public void testStrikethrough() {
         assertRoundTrip("~foo~ ~bar~\n");
-        assertRoundTrip("~~f~oo~~ ~~bar~~\n");
+        assertRoundTrip("~~foo~~ ~~bar~~\n");
+        assertRoundTrip("~~f\\~oo~~ ~~bar~~\n");
 
-        // TODO this new special character needs to be escaped:
-//        assertRoundTrip("\\~foo\\~\n");
+        assertRoundTrip("\\~foo\\~\n");
     }
 
     protected String render(String source) {
diff --git a/commonmark-ext-gfm-tables/src/main/java/org/commonmark/ext/gfm/tables/TablesExtension.java b/commonmark-ext-gfm-tables/src/main/java/org/commonmark/ext/gfm/tables/TablesExtension.java
index d23f6f5f..92e1f0ba 100644
--- a/commonmark-ext-gfm-tables/src/main/java/org/commonmark/ext/gfm/tables/TablesExtension.java
+++ b/commonmark-ext-gfm-tables/src/main/java/org/commonmark/ext/gfm/tables/TablesExtension.java
@@ -17,6 +17,9 @@ import org.commonmark.renderer.text.TextContentNodeRendererContext;
 import org.commonmark.renderer.text.TextContentNodeRendererFactory;
 import org.commonmark.renderer.text.TextContentRenderer;
 
+import java.util.Collections;
+import java.util.Set;
+
 /**
  * Extension for GFM tables using "|" pipes (GitHub Flavored Markdown).
  * <p>
@@ -72,6 +75,11 @@ public class TablesExtension implements Parser.ParserExtension, HtmlRenderer.Htm
             public NodeRenderer create(MarkdownNodeRendererContext context) {
                 return new TableMarkdownNodeRenderer(context);
             }
+
+            @Override
+            public Set<Character> getSpecialCharacters() {
+                return Collections.emptySet();
+            }
         });
     }
 }
diff --git a/commonmark/src/main/java/org/commonmark/internal/util/AsciiMatcher.java b/commonmark/src/main/java/org/commonmark/internal/util/AsciiMatcher.java
index d31020fa..dd7e8d5e 100644
--- a/commonmark/src/main/java/org/commonmark/internal/util/AsciiMatcher.java
+++ b/commonmark/src/main/java/org/commonmark/internal/util/AsciiMatcher.java
@@ -1,6 +1,7 @@
 package org.commonmark.internal.util;
 
 import java.util.BitSet;
+import java.util.Set;
 
 public class AsciiMatcher implements CharMatcher {
     private final BitSet set;
@@ -33,6 +34,14 @@ public class AsciiMatcher implements CharMatcher {
             this.set = set;
         }
 
+        public Builder c(char c) {
+            if (c > 127) {
+                throw new IllegalArgumentException("Can only match ASCII characters");
+            }
+            set.set(c);
+            return this;
+        }
+
         public Builder anyOf(String s) {
             for (int i = 0; i < s.length(); i++) {
                 c(s.charAt(i));
@@ -40,11 +49,10 @@ public class AsciiMatcher implements CharMatcher {
             return this;
         }
 
-        public Builder c(char c) {
-            if (c > 127) {
-                throw new IllegalArgumentException("Can only match ASCII characters");
+        public Builder anyOf(Set<Character> characters) {
+            for (Character c : characters) {
+                c(c);
             }
-            set.set(c);
             return this;
         }
 
diff --git a/commonmark/src/main/java/org/commonmark/renderer/markdown/CoreMarkdownNodeRenderer.java b/commonmark/src/main/java/org/commonmark/renderer/markdown/CoreMarkdownNodeRenderer.java
index c7fa9be7..2db7ef30 100644
--- a/commonmark/src/main/java/org/commonmark/renderer/markdown/CoreMarkdownNodeRenderer.java
+++ b/commonmark/src/main/java/org/commonmark/renderer/markdown/CoreMarkdownNodeRenderer.java
@@ -22,10 +22,8 @@ import java.util.regex.Pattern;
  */
 public class CoreMarkdownNodeRenderer extends AbstractVisitor implements NodeRenderer {
 
-    private final AsciiMatcher textEscape =
-            AsciiMatcher.builder().anyOf("[]<>`*&\n\\").build();
-    private final CharMatcher textEscapeInHeading =
-            AsciiMatcher.builder(textEscape).anyOf("#").build();
+    private final AsciiMatcher textEscape;
+    private final CharMatcher textEscapeInHeading;
     private final CharMatcher linkDestinationNeedsAngleBrackets =
             AsciiMatcher.builder().c(' ').c('(').c(')').c('<').c('>').c('\n').c('\\').build();
     private final CharMatcher linkDestinationEscapeInAngleBrackets =
@@ -46,6 +44,9 @@ public class CoreMarkdownNodeRenderer extends AbstractVisitor implements NodeRen
     public CoreMarkdownNodeRenderer(MarkdownNodeRendererContext context) {
         this.context = context;
         this.writer = context.getWriter();
+
+        textEscape = AsciiMatcher.builder().anyOf("[]<>`*&\n\\").anyOf(context.getSpecialCharacters()).build();
+        textEscapeInHeading = AsciiMatcher.builder(textEscape).anyOf("#").build();
     }
 
     @Override
diff --git a/commonmark/src/main/java/org/commonmark/renderer/markdown/MarkdownNodeRendererContext.java b/commonmark/src/main/java/org/commonmark/renderer/markdown/MarkdownNodeRendererContext.java
index 8fe0f73d..5805c458 100644
--- a/commonmark/src/main/java/org/commonmark/renderer/markdown/MarkdownNodeRendererContext.java
+++ b/commonmark/src/main/java/org/commonmark/renderer/markdown/MarkdownNodeRendererContext.java
@@ -2,6 +2,8 @@ package org.commonmark.renderer.markdown;
 
 import org.commonmark.node.Node;
 
+import java.util.Set;
+
 public interface MarkdownNodeRendererContext {
 
     /**
@@ -16,4 +18,10 @@ public interface MarkdownNodeRendererContext {
      * @param node the node to render
      */
     void render(Node node);
+
+    /**
+     * @return additional special characters that need to be escaped if they occur in normal text; currently only ASCII
+     * characters are allowed
+     */
+    Set<Character> getSpecialCharacters();
 }
diff --git a/commonmark/src/main/java/org/commonmark/renderer/markdown/MarkdownNodeRendererFactory.java b/commonmark/src/main/java/org/commonmark/renderer/markdown/MarkdownNodeRendererFactory.java
index 7b313427..adfe8a07 100644
--- a/commonmark/src/main/java/org/commonmark/renderer/markdown/MarkdownNodeRendererFactory.java
+++ b/commonmark/src/main/java/org/commonmark/renderer/markdown/MarkdownNodeRendererFactory.java
@@ -2,6 +2,8 @@ package org.commonmark.renderer.markdown;
 
 import org.commonmark.renderer.NodeRenderer;
 
+import java.util.Set;
+
 /**
  * Factory for instantiating new node renderers ƒor rendering.
  */
@@ -14,4 +16,10 @@ public interface MarkdownNodeRendererFactory {
      * @return a node renderer
      */
     NodeRenderer create(MarkdownNodeRendererContext context);
+
+    /**
+     * @return the additional special characters that this factory would like to have escaped in normal text; currently
+     * only ASCII characters are allowed
+     */
+    Set<Character> getSpecialCharacters();
 }
diff --git a/commonmark/src/main/java/org/commonmark/renderer/markdown/MarkdownRenderer.java b/commonmark/src/main/java/org/commonmark/renderer/markdown/MarkdownRenderer.java
index 4dee17ed..92610520 100644
--- a/commonmark/src/main/java/org/commonmark/renderer/markdown/MarkdownRenderer.java
+++ b/commonmark/src/main/java/org/commonmark/renderer/markdown/MarkdownRenderer.java
@@ -6,8 +6,7 @@ import org.commonmark.node.Node;
 import org.commonmark.renderer.NodeRenderer;
 import org.commonmark.renderer.Renderer;
 
-import java.util.ArrayList;
-import java.util.List;
+import java.util.*;
 
 /**
  * Renders nodes to CommonMark Markdown.
@@ -32,6 +31,11 @@ public class MarkdownRenderer implements Renderer {
             public NodeRenderer create(MarkdownNodeRendererContext context) {
                 return new CoreMarkdownNodeRenderer(context);
             }
+
+            @Override
+            public Set<Character> getSpecialCharacters() {
+                return Collections.emptySet();
+            }
         });
     }
 
@@ -111,13 +115,21 @@ public class MarkdownRenderer implements Renderer {
     private class RendererContext implements MarkdownNodeRendererContext {
         private final MarkdownWriter writer;
         private final NodeRendererMap nodeRendererMap = new NodeRendererMap();
+        private final Set<Character> additionalTextEscapes;
 
         private RendererContext(MarkdownWriter writer) {
+            // Set fields that are used by interface
             this.writer = writer;
+            Set<Character> escapes = new HashSet<Character>();
+            for (MarkdownNodeRendererFactory factory : nodeRendererFactories) {
+                escapes.addAll(factory.getSpecialCharacters());
+            }
+            additionalTextEscapes = Collections.unmodifiableSet(escapes);
 
             // The first node renderer for a node type "wins".
             for (int i = nodeRendererFactories.size() - 1; i >= 0; i--) {
                 MarkdownNodeRendererFactory nodeRendererFactory = nodeRendererFactories.get(i);
+                // Pass in this as context here, which uses the fields set above
                 NodeRenderer nodeRenderer = nodeRendererFactory.create(this);
                 nodeRendererMap.add(nodeRenderer);
             }
@@ -132,5 +144,10 @@ public class MarkdownRenderer implements Renderer {
         public void render(Node node) {
             nodeRendererMap.render(node);
         }
+
+        @Override
+        public Set<Character> getSpecialCharacters() {
+            return additionalTextEscapes;
+        }
     }
 }
-- 
GitLab