/******************************************************************************* * Copyright (c) 2014, 2015 Tasktop Technologies. * All rights reserved. This program and the accompanying materials * are made available under the terms of the Eclipse Public License v1.0 * which accompanies this distribution, and is available at * http://www.eclipse.org/legal/epl-v10.html * * Contributors: * Leo Dos Santos - initial API and implementation *******************************************************************************/ package org.eclipse.mylyn.wikitext.markdown.internal; import static com.google.common.base.Preconditions.checkNotNull; import java.io.IOException; import java.io.StringWriter; import java.io.Writer; import java.util.HashMap; import java.util.Map; import java.util.logging.Logger; import java.util.regex.Matcher; import java.util.regex.Pattern; import org.eclipse.mylyn.wikitext.markdown.MarkdownLanguage; import org.eclipse.mylyn.wikitext.parser.Attributes; import org.eclipse.mylyn.wikitext.parser.ImageAttributes; import org.eclipse.mylyn.wikitext.parser.LinkAttributes; import org.eclipse.mylyn.wikitext.parser.builder.AbstractMarkupDocumentBuilder; import com.google.common.base.CharMatcher; import com.google.common.base.Strings; /** * a document builder that emits Markdown markup * * @author Leo Dos Santos * @see MarkdownLanguage * @see MarkdownLanguage#createDocumentBuilder(Writer) */ public class MarkdownDocumentBuilder extends AbstractMarkupDocumentBuilder { private static final Pattern PATTERN_LINE_BREAK = Pattern.compile("(.*(\r\n|\r|\n)?)?"); //$NON-NLS-1$ private final Map<String, String> entityToLiteral = new HashMap<String, String>(); { entityToLiteral.put("amp", "&"); //$NON-NLS-1$ //$NON-NLS-2$ entityToLiteral.put("lt", "<"); //$NON-NLS-1$ //$NON-NLS-2$ entityToLiteral.put("gt", ">"); //$NON-NLS-1$ //$NON-NLS-2$ } private interface MarkdownBlock { void lineBreak() throws IOException; } private class ContentBlock extends NewlineDelimitedBlock implements MarkdownBlock { protected String prefix; protected String suffix; ContentBlock(BlockType blockType, String prefix, String suffix, int leadingNewlines, int trailingNewlines) { super(blockType, leadingNewlines, trailingNewlines); this.prefix = prefix; this.suffix = suffix; } ContentBlock(String prefix, String suffix, int leadingNewlines, int trailingNewlines) { this(null, prefix, suffix, leadingNewlines, trailingNewlines); } @Override public void write(int c) throws IOException { MarkdownDocumentBuilder.this.emitContent(c); } @Override public void write(String s) throws IOException { MarkdownDocumentBuilder.this.emitContent(s); } @Override public void lineBreak() throws IOException { write(" \n"); //$NON-NLS-1$ } @Override public void open() throws IOException { super.open(); pushWriter(new StringWriter()); } @Override public void close() throws IOException { Writer thisContent = popWriter(); String content = thisContent.toString(); if (content.length() > 0) { emitContent(content); } super.close(); } protected void emitContent(final String content) throws IOException { MarkdownDocumentBuilder.this.emitContent(prefix); MarkdownDocumentBuilder.this.emitContent(content); MarkdownDocumentBuilder.this.emitContent(suffix); } } private class ImplicitParagraphBlock extends ContentBlock { ImplicitParagraphBlock() { super(BlockType.PARAGRAPH, "", "", 2, 2); //$NON-NLS-1$ //$NON-NLS-2$ } @Override protected boolean isImplicitBlock() { return true; } } private class PrefixedLineContentBlock extends ContentBlock { PrefixedLineContentBlock(BlockType blockType, String prefix, String suffix, int leadingNewlines, int trailingNewlines) { super(blockType, prefix, suffix, leadingNewlines, trailingNewlines); } @Override protected void emitContent(String content) throws IOException { // break out the block onto its own line if the last character // was not a line break or null character literal char lastChar = getLastChar(); if (lastChar != '\n' && lastChar != '\r' && lastChar != '\u0000') { MarkdownDocumentBuilder.this.emitContent('\n'); } // split out content by line break Matcher matcher = PATTERN_LINE_BREAK.matcher(content); while (matcher.find()) { // if the line is empty, emit no prefix String line = matcher.group(0); if (!line.trim().isEmpty()) { MarkdownDocumentBuilder.this.emitContent(prefix); } MarkdownDocumentBuilder.this.emitContent(line); } // collapse suffix for nested blocks if (!content.endsWith(suffix)) { MarkdownDocumentBuilder.this.emitContent(suffix); } } } private class ListBlock extends ContentBlock { private int count = 0; ListBlock(BlockType blockType, int leadingNewlines) { super(blockType, "", "", leadingNewlines, 1); //$NON-NLS-1$//$NON-NLS-2$ } @Override protected void emitContent(String content) throws IOException { MarkdownDocumentBuilder.this.emitContent(prefix); MarkdownDocumentBuilder.this.emitContent(content); if (!content.endsWith("\n\n")) { //$NON-NLS-1$ MarkdownDocumentBuilder.this.emitContent(suffix); } } protected void addListItem(ListItemBlock item) { checkNotNull(item); count++; } protected int getCount() { return count; } } private class ListItemBlock extends ContentBlock { private int count; private ListItemBlock(String prefix) { super(BlockType.LIST_ITEM, prefix, "", 1, 1); //$NON-NLS-1$ } @Override public void open() throws IOException { super.open(); if (getPreviousBlock() instanceof ListBlock) { ListBlock list = (ListBlock) getPreviousBlock(); list.addListItem(this); count = list.getCount(); } } @Override protected void emitContent(String content) throws IOException { if (getPreviousBlock().getBlockType() == BlockType.NUMERIC_LIST) { prefix = count + ". "; //$NON-NLS-1$ } String indent = Strings.repeat(" ", prefix.length()); //$NON-NLS-1$ MarkdownDocumentBuilder.this.emitContent(prefix); // split out content by line Matcher matcher = PATTERN_LINE_BREAK.matcher(content); int lines = 0; while (matcher.find()) { // indent each line hanging past the initial line item String line = matcher.group(0); if (lines > 0 && !line.trim().isEmpty()) { int indexOfFirstNonSpace = CharMatcher.isNot(' ').indexIn(line); if (indexOfFirstNonSpace >= 4) { line = Strings.repeat(" ", 4) + line; //$NON-NLS-1$ } else { line = indent + line; } } MarkdownDocumentBuilder.this.emitContent(line); lines++; } // collapse suffix for nested blocks if (!content.endsWith(suffix)) { MarkdownDocumentBuilder.this.emitContent(suffix); } } } private class LinkBlock extends ContentBlock { private final LinkAttributes attributes; LinkBlock(LinkAttributes attributes) { super("", "", 0, 0); //$NON-NLS-1$ //$NON-NLS-2$ this.attributes = attributes; } @Override protected void emitContent(String content) throws IOException { // [label](http://url.com) or // [label](http://url.com "title") MarkdownDocumentBuilder.this.emitContent('['); MarkdownDocumentBuilder.this.emitContent(content); MarkdownDocumentBuilder.this.emitContent(']'); MarkdownDocumentBuilder.this.emitContent('('); MarkdownDocumentBuilder.this.emitContent(attributes.getHref()); if (!Strings.isNullOrEmpty(attributes.getTitle())) { MarkdownDocumentBuilder.this.emitContent(" \""); //$NON-NLS-1$ MarkdownDocumentBuilder.this.emitContent(attributes.getTitle()); MarkdownDocumentBuilder.this.emitContent('"'); } MarkdownDocumentBuilder.this.emitContent(')'); } } private class CodeSpan extends ContentBlock { private CodeSpan() { super("`", "`", 0, 0); //$NON-NLS-1$ //$NON-NLS-2$ } @Override protected void emitContent(String content) throws IOException { if (content.contains("`")) { //$NON-NLS-1$ prefix = "`` "; //$NON-NLS-1$ suffix = " ``"; //$NON-NLS-1$ } super.emitContent(content); } } public MarkdownDocumentBuilder(Writer out) { super(out); currentBlock = null; } @Override protected Block computeBlock(BlockType type, Attributes attributes) { switch (type) { case PARAGRAPH: return new ContentBlock(type, "", "", 2, 2); //$NON-NLS-1$ //$NON-NLS-2$ case QUOTE: return new PrefixedLineContentBlock(type, "> ", "", 1, 1); //$NON-NLS-1$ //$NON-NLS-2$ case BULLETED_LIST: case NUMERIC_LIST: if (currentBlock != null) { BlockType currentBlockType = currentBlock.getBlockType(); if (currentBlockType == BlockType.LIST_ITEM || currentBlockType == BlockType.DEFINITION_ITEM || currentBlockType == BlockType.DEFINITION_TERM) { return new ListBlock(type, 1); } } return new ListBlock(type, 2); case LIST_ITEM: if (computeCurrentListType() == BlockType.NUMERIC_LIST) { return new ListItemBlock("1. "); //$NON-NLS-1$ } return new ListItemBlock("* "); //$NON-NLS-1$ case CODE: return new PrefixedLineContentBlock(type, " ", "", 1, 2); //$NON-NLS-1$ //$NON-NLS-2$ default: Logger.getLogger(getClass().getName()).warning("Unexpected block type: " + type); //$NON-NLS-1$ return new ContentBlock(type, "", "", 2, 2); //$NON-NLS-1$ //$NON-NLS-2$ } } @Override protected Block computeSpan(SpanType type, Attributes attributes) { switch (type) { case LINK: if (attributes instanceof LinkAttributes) { return new LinkBlock((LinkAttributes) attributes); } return new ContentBlock("<", ">", 0, 0); //$NON-NLS-1$ //$NON-NLS-2$ case ITALIC: case EMPHASIS: return new ContentBlock("*", "*", 0, 0); //$NON-NLS-1$ //$NON-NLS-2$ case BOLD: case STRONG: return new ContentBlock("**", "**", 0, 0); //$NON-NLS-1$ //$NON-NLS-2$ case CODE: return new CodeSpan(); default: Logger.getLogger(getClass().getName()).warning("Unexpected block type: " + type); //$NON-NLS-1$ return new ContentBlock("", "", 0, 0); //$NON-NLS-1$ //$NON-NLS-2$ } } @Override protected Block computeHeading(int level, Attributes attributes) { return new ContentBlock(computePrefix('#', level) + " ", "", 1, 2); //$NON-NLS-1$ //$NON-NLS-2$ } @Override public void characters(String text) { text = escapeAmpersand(text); assertOpenBlock(); try { currentBlock.write(text); } catch (IOException e) { throw new RuntimeException(e); } } private String escapeAmpersand(String text) { return text.replace("&","&"); //$NON-NLS-1$ //$NON-NLS-2$ } @Override public void entityReference(String entity) { assertOpenBlock(); String literal = entityToLiteral.get(entity); if (literal == null) { literal = "&" + entity + ";"; //$NON-NLS-1$//$NON-NLS-2$ } try { currentBlock.write(literal); } catch (IOException e) { throw new RuntimeException(e); } } @Override public void image(Attributes attributes, String url) { assertOpenBlock(); try { currentBlock.write(computeImage(attributes, url)); } catch (IOException e) { throw new RuntimeException(e); } } private String computeImage(Attributes attributes, String url) { // ![](/path/to/img.jpg) or // ![alt text](path/to/img.jpg "title") String altText = ""; //$NON-NLS-1$ String title = ""; //$NON-NLS-1$ if (attributes instanceof ImageAttributes) { ImageAttributes imageAttr = (ImageAttributes) attributes; altText = Strings.nullToEmpty(imageAttr.getAlt()); } if (!Strings.isNullOrEmpty(attributes.getTitle())) { title = " \"" + attributes.getTitle() + '"'; //$NON-NLS-1$ } return "![" + altText + "](" + Strings.nullToEmpty(url) + title + ')'; //$NON-NLS-1$ //$NON-NLS-2$ } @Override public void link(Attributes attributes, String hrefOrHashName, String text) { assertOpenBlock(); LinkAttributes linkAttr = new LinkAttributes(); linkAttr.setTitle(attributes.getTitle()); linkAttr.setHref(hrefOrHashName); beginSpan(SpanType.LINK, linkAttr); characters(text); endSpan(); } @Override public void imageLink(Attributes linkAttributes, Attributes imageAttributes, String href, String imageUrl) { link(linkAttributes, href, computeImage(imageAttributes, imageUrl)); } @Override public void acronym(String text, String definition) { throw new UnsupportedOperationException(); } @Override public void lineBreak() { assertOpenBlock(); try { if (currentBlock instanceof MarkdownBlock) { ((MarkdownBlock) currentBlock).lineBreak(); } } catch (IOException e) { throw new RuntimeException(e); } } @Override protected Block createImplicitParagraphBlock() { return new ImplicitParagraphBlock(); } }