From e7daa8af2de7881f3b5854df8ec950cd95809e98 Mon Sep 17 00:00:00 2001 From: tallison Date: Wed, 2 Sep 2026 20:28:17 -0400 Subject: [PATCH] MarkdownWriter: append unescaped spans in bulk --- .../renderer/markdown/MarkdownWriter.java | 37 +++++++++--- .../renderer/markdown/MarkdownWriterTest.java | 59 +++++++++++++++++++ .../org/commonmark/test/SpecBenchmark.java | 15 +++++ 3 files changed, 102 insertions(+), 9 deletions(-) create mode 100644 commonmark/src/test/java/org/commonmark/renderer/markdown/MarkdownWriterTest.java diff --git a/commonmark/src/main/java/org/commonmark/renderer/markdown/MarkdownWriter.java b/commonmark/src/main/java/org/commonmark/renderer/markdown/MarkdownWriter.java index 9c3aba483..e483d55c6 100644 --- a/commonmark/src/main/java/org/commonmark/renderer/markdown/MarkdownWriter.java +++ b/commonmark/src/main/java/org/commonmark/renderer/markdown/MarkdownWriter.java @@ -158,8 +158,23 @@ private void write(String s, CharMatcher escape) { // Normal fast path buffer.append(s); } else { - for (int i = 0; i < s.length(); i++) { - append(s.charAt(i), escape); + // Append the characters that don't need escaping in bulk, a span at a time. + // Appending one character at a time is a lot slower for most Appendable + // implementations (e.g. Writer, where each char is a synchronized call). + int end = s.length(); + int start = 0; + for (int i = 0; i < end; i++) { + char c = s.charAt(i); + if (needsEscaping(c, escape)) { + if (start < i) { + buffer.append(s, start, i); + } + appendEscaped(c); + start = i + 1; + } + } + if (start < end) { + buffer.append(s, start, end); } } } catch (IOException e) { @@ -221,14 +236,18 @@ private void flushBlockSeparator() { private void append(char c, CharMatcher escape) throws IOException { if (needsEscaping(c, escape)) { - if (c == '\n') { - // Can't escape this with \, use numeric character reference - buffer.append(" "); - } else { - buffer.append('\\'); - buffer.append(c); - } + appendEscaped(c); + } else { + buffer.append(c); + } + } + + private void appendEscaped(char c) throws IOException { + if (c == '\n') { + // Can't escape this with \, use numeric character reference + buffer.append(" "); } else { + buffer.append('\\'); buffer.append(c); } } diff --git a/commonmark/src/test/java/org/commonmark/renderer/markdown/MarkdownWriterTest.java b/commonmark/src/test/java/org/commonmark/renderer/markdown/MarkdownWriterTest.java new file mode 100644 index 000000000..349711da3 --- /dev/null +++ b/commonmark/src/test/java/org/commonmark/renderer/markdown/MarkdownWriterTest.java @@ -0,0 +1,59 @@ +package org.commonmark.renderer.markdown; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.commonmark.text.CharMatcher; +import org.junit.jupiter.api.Test; + +public class MarkdownWriterTest { + + private static final CharMatcher STAR = c -> c == '*'; + private static final CharMatcher NEWLINE = c -> c == '\n'; + private static final CharMatcher PIPE = c -> c == '|'; + + @Test + public void testTextEscaping() { + assertThat(text("abc", STAR)).isEqualTo("abc"); + assertThat(text("*abc", STAR)).isEqualTo("\\*abc"); + assertThat(text("abc*", STAR)).isEqualTo("abc\\*"); + assertThat(text("a*b*c", STAR)).isEqualTo("a\\*b\\*c"); + assertThat(text("a**b", STAR)).isEqualTo("a\\*\\*b"); + assertThat(text("***", STAR)).isEqualTo("\\*\\*\\*"); + assertThat(text("*", STAR)).isEqualTo("\\*"); + } + + @Test + public void testTextEscapingNewline() { + assertThat(text("a\nb\nc", NEWLINE)).isEqualTo("a b c"); + assertThat(text("\n", NEWLINE)).isEqualTo(" "); + } + + @Test + public void testRawEscaping() { + StringBuilder sb = new StringBuilder(); + MarkdownWriter writer = new MarkdownWriter(sb); + writer.pushRawEscape(PIPE); + writer.raw("a|b|c"); + writer.popRawEscape(); + writer.raw("d|e"); + assertThat(sb.toString()).isEqualTo("a\\|b\\|cd|e"); + } + + @Test + public void testLastCharAndLineStart() { + StringBuilder sb = new StringBuilder(); + MarkdownWriter writer = new MarkdownWriter(sb); + writer.text("a*", STAR); + assertThat(writer.getLastChar()).isEqualTo('*'); + assertThat(writer.isAtLineStart()).isFalse(); + writer.line(); + assertThat(writer.isAtLineStart()).isTrue(); + } + + private static String text(String s, CharMatcher escape) { + StringBuilder sb = new StringBuilder(); + MarkdownWriter writer = new MarkdownWriter(sb); + writer.text(s, escape); + return sb.toString(); + } +} diff --git a/commonmark/src/test/java/org/commonmark/test/SpecBenchmark.java b/commonmark/src/test/java/org/commonmark/test/SpecBenchmark.java index f116404f9..953e9f28d 100644 --- a/commonmark/src/test/java/org/commonmark/test/SpecBenchmark.java +++ b/commonmark/src/test/java/org/commonmark/test/SpecBenchmark.java @@ -1,9 +1,11 @@ package org.commonmark.test; +import java.io.StringWriter; import java.util.List; import org.commonmark.node.Node; import org.commonmark.parser.Parser; import org.commonmark.renderer.html.HtmlRenderer; +import org.commonmark.renderer.markdown.MarkdownRenderer; import org.commonmark.testutil.TestResources; import org.commonmark.testutil.example.ExampleReader; import org.openjdk.jmh.annotations.*; @@ -23,6 +25,7 @@ public class SpecBenchmark { ExampleReader.readExampleSources(TestResources.getSpec()); private static final Parser PARSER = Parser.builder().build(); private static final HtmlRenderer RENDERER = HtmlRenderer.builder().build(); + private static final MarkdownRenderer MARKDOWN_RENDERER = MarkdownRenderer.builder().build(); private static final Node SPEC_NODE = PARSER.parse(SPEC); @@ -60,6 +63,18 @@ public long renderWholeSpec() { return RENDERER.render(SPEC_NODE).length(); } + @Benchmark + public long renderMarkdownWholeSpec() { + return MARKDOWN_RENDERER.render(SPEC_NODE).length(); + } + + @Benchmark + public long renderMarkdownWholeSpecToWriter() { + StringWriter writer = new StringWriter(); + MARKDOWN_RENDERER.render(SPEC_NODE, writer); + return writer.getBuffer().length(); + } + private static long parseAndRender(List examples) { long length = 0; for (String example : examples) {