Skip to content

Commit

Permalink
AST now provides access to document catalog refs
Browse files Browse the repository at this point in the history
The RubyHash key for refs is of type String.
The RubyHashMapDecorator previously only supported RubyHashes with RubySymbol keys.
This change modifies RubyHashMapDecorator to also support RubyHashes with String keys.
  • Loading branch information
lread committed Oct 19, 2020
1 parent 9926d4e commit 36f01da
Show file tree
Hide file tree
Showing 5 changed files with 231 additions and 11 deletions.
2 changes: 1 addition & 1 deletion CHANGELOG.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ For a detailed view of what has changed, refer to the {uri-repo}/commits/master[

Improvement::

* AST now provides access to footnotes (@lread) (TBD)
* AST now provides access to document catalog footnotes and refs (@lread) (#968)

== 2.4.1 (2020-09-10)

Expand Down
20 changes: 19 additions & 1 deletion asciidoctorj-api/src/main/java/org/asciidoctor/ast/Catalog.java
Original file line number Diff line number Diff line change
@@ -1,14 +1,32 @@
package org.asciidoctor.ast;

import java.util.List;
import java.util.Map;


public interface Catalog {

/**
* Note that footnotes are only available after `Document.getContent()` has been called.
*
* A converter uses cataloged footnotes to render them, presumably, at the bottom of a document.
*
* @return footnotes occurring in document.
*/
List<Footnote> getFootnotes();
}

/**
* Refs is a map of asciidoctor ids to asciidoctor document elements.
*
* For example, by default, each section is automatically assigned an id.
* In this case the id would map to a {@link Section} element.
*
* Ids can also be explicitly assigned by document authors to any document element.
* See https://asciidoctor.org/docs/user-manual/#id
*
* A converter might use cataloged refs to lookup ids to support rendering inline anchors.
*
* @return a map of ids to elements that asciidoctor has collected from the document.
*/
Map<String, Object> getRefs();
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,13 @@

import org.asciidoctor.ast.Catalog;
import org.asciidoctor.ast.Footnote;
import org.asciidoctor.jruby.internal.RubyHashMapDecorator;
import org.jruby.RubyArray;
import org.jruby.RubyHash;
import org.jruby.RubyStruct;

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;

Expand All @@ -26,4 +29,10 @@ public List<Footnote> getFootnotes() {
}
return footnotes;
}
}

@Override
public Map<String, Object> getRefs() {
Map <String,Object> refs = new RubyHashMapDecorator((RubyHash) catalog.get("refs"), String.class);
return Collections.unmodifiableMap(refs);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,35 @@ public class RubyHashMapDecorator implements Map<String, Object> {

private RubyClass abstractNodeClass;

private Class rubyKeyType;

public RubyHashMapDecorator(RubyHash rubyHash) {
this(rubyHash, RubySymbol.class);
}

/**
* Wrap a RubyHash map that uses a key of type `rubyKeyType`.
*
* Regardless of `rubyKeyType`, the wrapper will always use Java String
* as the exposed key type and coerce internally as appropriate.
*
* @param rubyHash hash map to wrap
* @param rubyKeyType key type, valid values: RubySymbol.class or String.class
*/
public RubyHashMapDecorator(RubyHash rubyHash, Class rubyKeyType) {
this.rubyRuntime = rubyHash.getRuntime();
this.rubyHash = rubyHash;
if (rubyKeyType != RubySymbol.class && rubyKeyType != String.class) {
throw new UnsupportedOperationException("key type must be either RubySymbol or String");
}
this.rubyKeyType = rubyKeyType;
}

private Object coerceKey(Object key) {
if (rubyKeyType == RubySymbol.class) {
return rubyHash.getRuntime().getSymbolTable().getSymbol((String) key);
}
return key;
}

@Override
Expand All @@ -42,8 +68,7 @@ public boolean containsKey(Object key) {
if (!(key instanceof String)) {
return false;
}
RubySymbol symbol = rubyHash.getRuntime().getSymbolTable().getSymbol((String) key);
return rubyHash.containsKey(symbol);
return rubyHash.containsKey(coerceKey(key));
}

@Override
Expand All @@ -56,16 +81,14 @@ public Object get(Object key) {
if (!(key instanceof String)) {
return false;
}
RubySymbol symbol = rubyHash.getRuntime().getSymbolTable().getSymbol((String) key);
Object value = rubyHash.get(symbol);
Object value = rubyHash.get(coerceKey(key));
return convertRubyValue(value);
}

@Override
public Object put(String key, Object value) {
Object oldValue = get(key);
RubySymbol symbol = rubyHash.getRuntime().getSymbolTable().getSymbol(key);
rubyHash.put(symbol, convertJavaValue(value));
rubyHash.put(coerceKey(key), convertJavaValue(value));
return oldValue;
}

Expand All @@ -75,8 +98,7 @@ public Object remove(Object key) {
return null;
}
Object oldValue = get(key);
RubySymbol symbol = rubyHash.getRuntime().getSymbolTable().getSymbol((String) key);
rubyHash.remove(symbol);
rubyHash.remove(coerceKey(key));
return convertRubyValue(oldValue);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
package org.asciidoctor.converter

import org.asciidoctor.Asciidoctor
import org.asciidoctor.OptionsBuilder
import org.asciidoctor.ast.ContentNode
import org.asciidoctor.ast.Document
import org.asciidoctor.ast.StructuralNode
import org.hamcrest.BaseMatcher
import org.jboss.arquillian.spock.ArquillianSputnik
import org.jboss.arquillian.test.api.ArquillianResource
import org.junit.runner.RunWith
import spock.lang.Specification

import static org.hamcrest.Matchers.contains
import static org.hamcrest.Matchers.containsInAnyOrder
import static org.hamcrest.Matchers.empty
import static org.junit.Assert.assertThat

/**
* Tests that refs can be accessed from converter.
*/
@RunWith(ArquillianSputnik)
class CatalogOfRefsAreAvailable extends Specification {
static final String CONVERTER_BACKEND = 'refs'
static final String NODE_NAME_SECTION = 'section'
static final String NODE_NAME_PARAGRAPH = 'paragraph'
static final String NODE_NAME_ULIST = 'ulist'
static final String NODE_NAME_INLINE_ANCHOR = 'inline_anchor'

@ArquillianResource
private Asciidoctor asciidoctor
private static Map<String,Object> refs

static class Converter extends StringConverter {
Converter(String backend, Map<String, Object> opts) {
super(backend, opts)
}

/*
* For this conversion test we do not care about the conversion result,
* we simply want to to verify that refs are available and as expected.
*/
@Override
String convert(ContentNode node, String transform, Map<Object, Object> opts) {
if (node instanceof Document) {
def doc = (Document) node
refs = doc.catalog.refs
}
else if (node instanceof StructuralNode) {
((StructuralNode) node).content
}
}
}

def setup() {
refs = null
asciidoctor.javaConverterRegistry().register(Converter, CONVERTER_BACKEND)
}

def convert(String document) {
asciidoctor.convert(document, OptionsBuilder.options().backend(CONVERTER_BACKEND))
}

def 'when there are no refs in source doc, refs should be empty'() {
given:
String document = 'no refs here'

when:
convert(document)

then:
assertThat(refs.entrySet(), empty())
}

def 'when a ref is is in source doc, it should be accessible from converter'() {
given:
def idSectionA = '_a_section'
String document = '== A Section'

when:
convert(document)

then:
assertThat(refs.keySet(), contains(idSectionA))
assertThat(refs, hasIdPointingToNodeNamed(idSectionA, NODE_NAME_SECTION))
}

def 'when refs are in source doc, they should be accessible from the converter'() {
given:
def idSectionA = '_section_a'
def idSectionB = 'override-section-b'
def idSectionC = 'section-c-primary'
def idPara1 = 'para1'
def idPara2 = 'para2'
def idInline = 'inlineref'
def idList = 'list'
def idInList = 'in-list'
String document = """
This document has a variety of ref ids.
== Section A
A section, by default, is assigned an id.
[[${idSectionB},Override default section id]]
== Section B
== Section C[[section-c2]][[section-c3]] [[${idSectionC}]]
Section C heading has multiple anchors, note that the secondary
anchors do not seem to show up in refs.
[[${idPara1}]]
My paragraph
[#${idPara2}]
Shorthand syntax
An [#${idInline}]*quoted text*
[[${idList},some ref text]]
* item1
* [[${idInList}]] item2
* item3
// This ref will be excluded as it points to nothing
[[reftonada]]
"""
when:
convert(document)

then:
assertThat(refs.keySet(), containsInAnyOrder(idSectionA, idSectionB, idSectionC,
idPara1, idPara2, idList, idInList))
assertThat(refs, hasIdPointingToNodeNamed(idSectionA, NODE_NAME_SECTION))
assertThat(refs, hasIdPointingToNodeNamed(idSectionB, NODE_NAME_SECTION))
assertThat(refs, hasIdPointingToNodeNamed(idSectionC, NODE_NAME_SECTION))
assertThat(refs, hasIdPointingToNodeNamed(idPara1, NODE_NAME_PARAGRAPH))
assertThat(refs, hasIdPointingToNodeNamed(idPara2, NODE_NAME_PARAGRAPH))
assertThat(refs, hasIdPointingToNodeNamed(idList, NODE_NAME_ULIST))
assertThat(refs, hasIdPointingToNodeNamed(idInList, NODE_NAME_INLINE_ANCHOR))
}

private hasIdPointingToNodeNamed(final id, final expectedNodeName) {
[
matches: { refs ->
if (refs.get(id)) {
refs.get(id).nodeName == expectedNodeName
}
},
describeTo: { description ->
description.appendText('ref id ')
.appendValue(id)
.appendText(' pointing to node named ')
.appendValue(expectedNodeName)
},
describeMismatch: { refs, description ->
if (!refs.get(id)) {
description.appendText('did not find id ')
.appendValue(id)
.appendText(' in refs')
} else {
description.appendText('instead found id pointing to ')
.appendValue(refs.get(id).nodeName)
}
}
] as BaseMatcher<Map<String, Object>>
}


}

0 comments on commit 36f01da

Please sign in to comment.