Skip to content

Commit

Permalink
[DOXIA-751] Linked inline code must be emitted in right order
Browse files Browse the repository at this point in the history
Introduce buffer stack to be able to buffer for each context separately.
Refactoring of buffer handling
  • Loading branch information
kwin committed Oct 19, 2024
1 parent b054e54 commit da4941e
Show file tree
Hide file tree
Showing 2 changed files with 118 additions and 75 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,11 @@ public class MarkdownSink extends AbstractTextSink implements MarkdownMarkup {
// Instance fields
// ----------------------------------------------------------------------

/** A buffer that holds the current text when headerFlag or bufferFlag set to <code>true</code>.
* The content of this buffer is already escaped. */
private StringBuilder buffer;
/**
* A buffer that holds the current text when the current context requires buffering.
* The content of this buffer is already escaped.
*/
private Queue<StringBuilder> bufferStack = Collections.asLifoQueue(new LinkedList<>());

/** author. */
private Collection<String> authors;
Expand Down Expand Up @@ -95,23 +97,21 @@ public class MarkdownSink extends AbstractTextSink implements MarkdownMarkup {

/** Most important contextual metadata (of the surrounding element) */
enum ElementContext {
HEAD("head", Type.GENERIC_CONTAINER, null, true),
BODY("body", Type.GENERIC_CONTAINER, MarkdownSink::escapeMarkdown),
HEAD(Type.GENERIC_CONTAINER, null, true),
BODY(Type.GENERIC_CONTAINER, MarkdownSink::escapeMarkdown),
// only the elements, which affect rendering of children and are different from BODY or HEAD are listed here
FIGURE("", Type.INLINE, MarkdownSink::escapeMarkdown, true),
CODE_BLOCK("code block", Type.LEAF_BLOCK, null, false),
CODE_SPAN("code span", Type.INLINE, null),
TABLE_CAPTION("table caption", Type.INLINE, MarkdownSink::escapeMarkdown),
FIGURE(Type.INLINE, MarkdownSink::escapeMarkdown, true),
CODE_BLOCK(Type.LEAF_BLOCK, null, false),
CODE_SPAN(Type.INLINE, null, true),
TABLE_CAPTION(Type.INLINE, MarkdownSink::escapeMarkdown),
TABLE_ROW(Type.CONTAINER_BLOCK, null, true),
TABLE_CELL(
"table cell",
Type.LEAF_BLOCK,
MarkdownSink::escapeForTableCell,
true), // special type, as allows containing inlines, but not starting on a separate line
false), // special type, as allows containing inlines, but not starting on a separate line
// same parameters as BODY but paragraphs inside list items are handled differently
LIST_ITEM("list item", Type.CONTAINER_BLOCK, MarkdownSink::escapeMarkdown, false, INDENT),
BLOCKQUOTE("blockquote", Type.CONTAINER_BLOCK, MarkdownSink::escapeMarkdown, false, BLOCKQUOTE_START_MARKUP);

final String name;
LIST_ITEM(Type.CONTAINER_BLOCK, MarkdownSink::escapeMarkdown, false, INDENT),
BLOCKQUOTE(Type.CONTAINER_BLOCK, MarkdownSink::escapeMarkdown, false, BLOCKQUOTE_START_MARKUP);

/**
* @see <a href="https://spec.commonmark.org/0.30/#blocks-and-inlines">CommonMark, 3 Blocks and inlines</a>
Expand Down Expand Up @@ -159,31 +159,24 @@ enum Type {
*/
final boolean requiresSurroundingByBlankLines;

ElementContext(String name, Type type, UnaryOperator<String> escapeFunction) {
this(name, type, escapeFunction, false);
ElementContext(Type type, UnaryOperator<String> escapeFunction) {
this(type, escapeFunction, false);
}

ElementContext(String name, Type type, UnaryOperator<String> escapeFunction, boolean requiresBuffering) {
this(name, type, escapeFunction, requiresBuffering, "");
ElementContext(Type type, UnaryOperator<String> escapeFunction, boolean requiresBuffering) {
this(type, escapeFunction, requiresBuffering, "");
}

ElementContext(
String name,
Type type,
UnaryOperator<String> escapeFunction,
boolean requiresBuffering,
String prefix) {
this(name, type, escapeFunction, requiresBuffering, prefix, false);
ElementContext(Type type, UnaryOperator<String> escapeFunction, boolean requiresBuffering, String prefix) {
this(type, escapeFunction, requiresBuffering, prefix, false);
}

ElementContext(
String name,
Type type,
UnaryOperator<String> escapeFunction,
boolean requiresBuffering,
String prefix,
boolean requiresSurroundingByBlankLines) {
this.name = name;
this.type = type;
this.escapeFunction = escapeFunction;
this.requiresBuffering = requiresBuffering;
Expand Down Expand Up @@ -248,9 +241,16 @@ private void endContext(ElementContext expectedContext) {
if (removedContext.isBlock()) {
endBlock(removedContext.requiresSurroundingByBlankLines);
}
if (removedContext.requiresBuffering) {
// remove buffer from stack (assume it has been evaluated already)
bufferStack.remove();
}
}

private void startContext(ElementContext newContext) {
if (newContext.requiresBuffering) {
bufferStack.add(new StringBuilder());
}
if (newContext.isBlock()) {
startBlock(newContext.requiresSurroundingByBlankLines);
}
Expand Down Expand Up @@ -307,20 +307,34 @@ private String getContainerLinePrefixes() {
}

/**
* Returns the buffer that holds the current text.
*
* @return A StringBuffer.
* Returns the buffer that holds the text of the current context (or the closest container context with a buffer).
* @return The StringBuilder representing the current buffer, never {@code null}
* @throws NoSuchElementException if no buffer is available
*/
protected StringBuilder getBuffer() {
return buffer;
protected StringBuilder getCurrentBuffer() {
return bufferStack.element();
}

/**
* Returns the content of the buffer of the current context (or the closest container context with a buffer).
* The buffer is reset to an empty string in this method.
* @return the content of the buffer as a string or {@code null} if no buffer is available
*/
protected String consumeBuffer() {
StringBuilder buffer = bufferStack.peek();
if (buffer == null) {
return null;
} else {
String content = buffer.toString();
buffer.setLength(0);
return content;
}
}

@Override
protected void init() {
super.init();

resetBuffer();

this.authors = new LinkedList<>();
this.title = null;
this.date = null;
Expand All @@ -334,19 +348,12 @@ protected void init() {
elementContextStack.add(ElementContext.BODY);
}

/**
* Reset the StringBuilder.
*/
protected void resetBuffer() {
buffer = new StringBuilder();
}

@Override
public void head(SinkEventAttributes attributes) {
init();
// remove default body context here
endContext(ElementContext.BODY);
elementContextStack.add(ElementContext.HEAD);
startContext(ElementContext.HEAD);
}

@Override
Expand Down Expand Up @@ -374,6 +381,7 @@ public void head_() {

@Override
public void body(SinkEventAttributes attributes) {
startContext(ElementContext.BODY);
elementContextStack.add(ElementContext.BODY);
}

Expand All @@ -384,25 +392,25 @@ public void body_() {

@Override
public void title_() {
if (buffer.length() > 0) {
title = buffer.toString();
resetBuffer();
String buffer = consumeBuffer();
if (buffer != null && !buffer.isEmpty()) {
this.title = buffer.toString();
}
}

@Override
public void author_() {
if (buffer.length() > 0) {
authors.add(buffer.toString());
resetBuffer();
String buffer = consumeBuffer();
if (buffer != null && !buffer.isEmpty()) {
authors.add(buffer);
}
}

@Override
public void date_() {
if (buffer.length() > 0) {
String buffer = consumeBuffer();
if (buffer != null && !buffer.isEmpty()) {
date = buffer.toString();
resetBuffer();
}
}

Expand Down Expand Up @@ -579,11 +587,14 @@ public void tableRows_() {

@Override
public void tableRow(SinkEventAttributes attributes) {
startContext(ElementContext.TABLE_ROW);
cellCount = 0;
}

@Override
public void tableRow_() {
String buffer = consumeBuffer();
endContext(ElementContext.TABLE_ROW);
if (isFirstTableRow && !tableHeaderCellFlag) {
// emit empty table header as this is mandatory for GFM table extension
// (https://stackoverflow.com/a/17543474/5155923)
Expand All @@ -595,11 +606,7 @@ public void tableRow_() {
}

writeUnescaped(TABLE_ROW_PREFIX);

writeUnescaped(buffer.toString());

resetBuffer();

writeUnescaped(buffer);
writeUnescaped(EOL);

if (isFirstTableRow) {
Expand Down Expand Up @@ -648,6 +655,7 @@ private void writeTableDelimiterRow() {

@Override
public void tableCell(SinkEventAttributes attributes) {
startContext(ElementContext.TABLE_CELL);
if (attributes != null) {
// evaluate alignment attributes
final int cellJustification;
Expand All @@ -674,7 +682,6 @@ public void tableCell(SinkEventAttributes attributes) {
}
}
}
elementContextStack.add(ElementContext.TABLE_CELL);
}

@Override
Expand All @@ -698,7 +705,7 @@ public void tableHeaderCell_() {
*/
private void endTableCell() {
endContext(ElementContext.TABLE_CELL);
buffer.append(TABLE_CELL_SEPARATOR_MARKUP);
writeUnescaped(TABLE_CELL_SEPARATOR_MARKUP);
cellCount++;
}

Expand All @@ -715,7 +722,7 @@ public void tableCaption_() {
@Override
public void figure(SinkEventAttributes attributes) {
figureSrc = null;
elementContextStack.add(ElementContext.FIGURE);
startContext(ElementContext.FIGURE);
}

@Override
Expand All @@ -733,8 +740,13 @@ public void figureGraphics(String name, SinkEventAttributes attributes) {

@Override
public void figure_() {
StringBuilder buffer = getCurrentBuffer();
String label = "";
if (buffer != null) {
label = buffer.toString();
}
endContext(ElementContext.FIGURE);
writeImage(buffer.toString(), figureSrc);
writeImage(label, figureSrc);
}

private void writeImage(String alt, String src) {
Expand All @@ -756,15 +768,36 @@ public void anchor_() {

/** {@inheritDoc} */
public void link(String name, SinkEventAttributes attributes) {
writeUnescaped(LINK_START_1_MARKUP);
linkName = name;
if (elementContextStack.element() == ElementContext.CODE_BLOCK) {
LOGGER.warn("{}Ignoring unsupported link inside code block", getLocationLogPrefix());
} else if (elementContextStack.element() == ElementContext.CODE_SPAN) {
// emit link outside the code span, i.e. insert at the beginning of the buffer
getCurrentBuffer().insert(0, LINK_START_1_MARKUP);
linkName = name;
} else {
writeUnescaped(LINK_START_1_MARKUP);
linkName = name;
}
}

@Override
public void link_() {
writeUnescaped(LINK_START_2_MARKUP);
text(linkName.startsWith("#") ? linkName.substring(1) : linkName);
writeUnescaped(LINK_END_MARKUP);
if (elementContextStack.element() == ElementContext.CODE_BLOCK) {
return;
} else if (elementContextStack.element() == ElementContext.CODE_SPAN) {
// defer emitting link end markup until inline_() is called
StringBuilder linkEndMarkup = new StringBuilder();
linkEndMarkup.append(LINK_START_2_MARKUP);
linkEndMarkup.append(escapeMarkdown(linkName.startsWith("#") ? linkName.substring(1) : linkName));
linkEndMarkup.append(LINK_END_MARKUP);
Queue<String> endMarkups = new LinkedList<>(inlineStack.poll());
endMarkups.add(linkEndMarkup.toString());
inlineStack.add(endMarkups);
} else {
writeUnescaped(LINK_START_2_MARKUP);
text(linkName.startsWith("#") ? linkName.substring(1) : linkName);
writeUnescaped(LINK_END_MARKUP);
}
linkName = null;
}

Expand All @@ -779,9 +812,9 @@ public void inline(SinkEventAttributes attributes) {
if (attributes.containsAttribute(SinkEventAttributes.SEMANTICS, "code")
|| attributes.containsAttribute(SinkEventAttributes.SEMANTICS, "monospaced")
|| attributes.containsAttribute(SinkEventAttributes.STYLE, "monospaced")) {
startContext(ElementContext.CODE_SPAN);
writeUnescaped(MONOSPACED_START_MARKUP);
endMarkups.add(MONOSPACED_END_MARKUP);
elementContextStack.add(ElementContext.CODE_SPAN);
} else {
// in XHTML "<em>" is used, but some tests still rely on the outdated "<italic>"
if (attributes.containsAttribute(SinkEventAttributes.SEMANTICS, "em")
Expand All @@ -806,7 +839,9 @@ public void inline(SinkEventAttributes attributes) {
public void inline_() {
for (String endMarkup : inlineStack.remove()) {
if (endMarkup.equals(MONOSPACED_END_MARKUP)) {
String buffer = getCurrentBuffer().toString();
endContext(ElementContext.CODE_SPAN);
writeUnescaped(buffer);
}
writeUnescaped(endMarkup);
}
Expand Down Expand Up @@ -896,16 +931,9 @@ public void unknown(String name, Object[] requiredParams, SinkEventAttributes at
LOGGER.warn("{}Unknown Sink event '" + name + "', ignoring!", getLocationLogPrefix());
}

/**
*
* @return {@code true} if any of the parent contexts require buffering
*/
private boolean requiresBuffering() {
return elementContextStack.stream().anyMatch(c -> c.requiresBuffering);
}

protected void writeUnescaped(String text) {
if (requiresBuffering()) {
StringBuilder buffer = bufferStack.peek();
if (buffer != null) {
buffer.append(text);
} else {
writer.write(text);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
import org.apache.maven.doxia.sink.Sink;
import org.apache.maven.doxia.sink.impl.AbstractSinkTest;
import org.apache.maven.doxia.sink.impl.SinkEventAttributeSet;
import org.apache.maven.doxia.sink.impl.SinkEventAttributeSet.Semantics;
import org.apache.maven.doxia.sink.impl.SinkEventTestingSink;
import org.apache.maven.doxia.util.HtmlTools;
import org.hamcrest.MatcherAssert;
Expand Down Expand Up @@ -513,4 +514,18 @@ public void testHeadingAfterInlineElement() {
String expected = "Text" + EOL + "# Section1" + EOL + EOL;
assertEquals(expected, getSinkContent(), "Wrong heading after inline element!");
}

@Test
public void testCodeLink() {
try (final Sink sink = getSink()) {
sink.inline(Semantics.CODE);
sink.link("http://example.com");
sink.text("label");
sink.link_();
sink.inline_();
}
// heading must be on a new line
String expected = "[`label`](http://example\\.com)";
assertEquals(expected, getSinkContent(), "Wrong link on code!");
}
}

0 comments on commit da4941e

Please sign in to comment.