AST now provides access to document catalog refs
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.
MODIFY
CHANGELOG.adoc
CHANGELOG.adoc
@@ -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)

MODIFY
asciidoctorj-api/src/main/java/org/asciidoctor/ast/Catalog.java
asciidoctorj-api/src/main/java/org/asciidoctor/ast/Catalog.java
package org.asciidoctor.ast;
import java.util.List;
+import java.util.Map;
public interface Catalog {
@@ -8,7 +9,24 @@ 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();
}

MODIFY
asciidoctorj-core/src/main/java/org/asciidoctor/jruby/ast/impl/CatalogImpl.java
asciidoctorj-core/src/main/java/org/asciidoctor/jruby/ast/impl/CatalogImpl.java
@@ -2,10 +2,13 @@ package org.asciidoctor.jruby.ast.impl;
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;
@@ -26,4 +29,10 @@ public class CatalogImpl implements Catalog {
}
return footnotes;
}
+
+ @Override
+ public Map<String, Object> getRefs() {
+ Map <String,Object> refs = new RubyHashMapDecorator((RubyHash) catalog.get("refs"), String.class);
+ return Collections.unmodifiableMap(refs);
+ }
}
\ No newline at end of file

MODIFY
asciidoctorj-core/src/main/java/org/asciidoctor/jruby/internal/RubyHashMapDecorator.java
asciidoctorj-core/src/main/java/org/asciidoctor/jruby/internal/RubyHashMapDecorator.java
@@ -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
@@ -42,8 +68,7 @@ public class RubyHashMapDecorator implements Map<String, Object> {
if (!(key instanceof String)) {
return false;
}
- RubySymbol symbol = rubyHash.getRuntime().getSymbolTable().getSymbol((String) key);
- return rubyHash.containsKey(symbol);
+ return rubyHash.containsKey(coerceKey(key));
}
@Override
@@ -56,16 +81,14 @@ public class RubyHashMapDecorator implements Map<String, Object> {
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;
}
@@ -75,8 +98,7 @@ public class RubyHashMapDecorator implements Map<String, Object> {
return null;
}
Object oldValue = get(key);
- RubySymbol symbol = rubyHash.getRuntime().getSymbolTable().getSymbol((String) key);
- rubyHash.remove(symbol);
+ rubyHash.remove(coerceKey(key));
return convertRubyValue(oldValue);
}

ADD
asciidoctorj-core/src/test/groovy/org/asciidoctor/converter/CatalogOfRefsAreAvailable.groovy
+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>>
+ }
+
+
+}

AST now provides access to document catalog refs

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.