diff --git a/.gitignore b/.gitignore
index fd95a84d4acca7f2b894934f1481db93bfa9e028..9d4be9ee2db5696c31176d47d7f50f0d5eed5624 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,6 @@
 /.idea/
 /*.iml
+/out/
 
 /src/gen/
 /src/tmp/
diff --git a/build.gradle b/build.gradle
index e81f083b42ee9ae2a4f8c000e404113ee65a293d..0e8178ecd1b87224ca220866fe3a75a8c93f25b4 100644
--- a/build.gradle
+++ b/build.gradle
@@ -1,7 +1,8 @@
 plugins {
   id 'java'
   id 'maven'
-  id 'org.jastadd' version '1.12.0'
+  id 'groovy'
+  id 'org.jastadd' version '1.12.2'
 }
 
 if (!file('extendj/jastadd_modules').exists()) {
@@ -33,8 +34,6 @@ jastadd {
 
       jastadd {
         basedir "src/main/jastadd"
-        include "**/*.ast"
-        include "**/*.jadd"
         include "**/*.jrag"
       }
     }
@@ -57,8 +56,10 @@ repositories {
 }
 
 dependencies {
-  compile 'se.llbit:jo-json:1.3.0'
+  compile 'se.llbit:jo-json:1.3.1'
   compile 'org.extendj:trace:0.1'
+
+  testCompile 'org.spockframework:spock-core:1.1-groovy-2.4'
 }
 
 def mainClassName = 'org.extendj.ragdoc.RagDocBuilder'
diff --git a/src/main/java/org/extendj/ragdoc/AstDeclParser.java b/src/main/java/org/extendj/ragdoc/AstDeclParser.java
new file mode 100644
index 0000000000000000000000000000000000000000..538d79b0a6a703c3c615155fe5c223853513643f
--- /dev/null
+++ b/src/main/java/org/extendj/ragdoc/AstDeclParser.java
@@ -0,0 +1,194 @@
+/* Copyright (c) 2018, Jesper Öqvist <jesper.oqvist@cs.lth.se>
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice,
+ * this list of conditions and the following disclaimer.
+ *
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ *
+ * 3. Neither the name of the copyright holder nor the names of its
+ * contributors may be used to endorse or promote products derived from this
+ * software without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ */
+package org.extendj.ragdoc;
+
+import se.llbit.io.LookaheadReader;
+import se.llbit.json.JsonArray;
+import se.llbit.json.JsonObject;
+import se.llbit.json.JsonParser;
+
+import java.io.Closeable;
+import java.io.IOException;
+import java.io.StringReader;
+
+public class AstDeclParser implements AutoCloseable, Closeable {
+  private static final int EOF = -1;
+
+  private final LookaheadReader in;
+
+  public AstDeclParser(String astdecl) {
+    in = new LookaheadReader(new StringReader(astdecl), 2);
+  }
+
+  private void accept(char c) throws IOException, JsonParser.SyntaxError {
+    int next = in.pop();
+    if (next == EOF) {
+      throw new JsonParser.SyntaxError(String.format("unexpected end of input (expected '%c')", c));
+    }
+    if (next != c) {
+      throw new JsonParser.SyntaxError(
+          String.format("unexpected character (was '%c', expected '%c')", (char) next, c));
+    }
+  }
+
+  JsonObject parse() throws IOException, JsonParser.SyntaxError {
+    JsonObject decl = new JsonObject();
+    String name = parseName();
+    decl.add("n", name);
+    skipWhitespace();
+    if (in.peek() == ':' && in.peek(1) != ':') {
+      accept(':');
+      skipWhitespace();
+      String extendsName = parseName();
+      decl.add("e", extendsName);
+      skipWhitespace();
+    }
+    if (in.peek() != ';') {
+      accept(':');
+      accept(':');
+      accept('=');
+      skipWhitespace();
+      JsonArray components = new JsonArray();
+      while (in.peek() != ';') {
+        components.add(parseComponent());
+        skipWhitespace();
+      }
+      if (!components.isEmpty()) {
+        decl.add("c", components);
+      }
+    }
+    accept(';');
+    return decl;
+  }
+
+  private JsonObject parseComponent() throws IOException, JsonParser.SyntaxError {
+    JsonObject component = new JsonObject();
+    if (in.peek() == '[') {
+      accept('[');
+      skipWhitespace();
+      String name = parseName();
+
+      skipWhitespace();
+      if (in.peek() == ':') {
+        accept(':');
+        skipWhitespace();
+        String extendsName = parseName();
+        component.add("n", name);
+        component.add("e", extendsName);
+        skipWhitespace();
+      } else {
+        component.add("e", name);
+      }
+
+      component.add("k", "opt");
+
+      skipWhitespace();
+      accept(']');
+      return component;
+    } else if (in.peek() == '<') {
+      accept('<');
+      skipWhitespace();
+      String name = parseName();
+      component.add("n", name);
+
+      skipWhitespace();
+      if (in.peek() == ':') {
+        accept(':');
+        skipWhitespace();
+        String extendsName = parseName();
+        component.add("e", extendsName);
+        skipWhitespace();
+      } else {
+        component.add("e", "String");
+      }
+
+      component.add("k", "token");
+
+      skipWhitespace();
+      accept('>');
+      return component;
+    } else {
+      String name = parseName();
+
+      skipWhitespace();
+      if (in.peek() == ':') {
+        accept(':');
+        skipWhitespace();
+        String extendsName = parseName();
+        component.add("n", name);
+        component.add("e", extendsName);
+        skipWhitespace();
+      } else {
+        component.add("e", name);
+      }
+
+      if (in.peek() == '*') {
+        accept('*');
+        component.add("k", "list");
+      }
+      return component;
+    }
+  }
+
+  private void skipWhitespace() throws IOException {
+    while (isWhitespace(in.peek())) {
+      in.pop();
+    }
+  }
+
+  private boolean isWhitespace(int chr) {
+    return chr == 0x20 || chr == 0x09 || chr == 0x0A || chr == 0x0D;
+  }
+
+  private boolean isNameChar(int chr) {
+    return chr != EOF && (chr == '.' || Character.isLetterOrDigit(chr));
+  }
+
+  private String chrToStr(int chr) {
+    return chr != EOF ? String.format("%c", chr) : "EOF";
+  }
+
+  String parseName() throws IOException, JsonParser.SyntaxError {
+    int next = in.peek();
+    if (!isNameChar(next)) {
+      throw new JsonParser.SyntaxError(
+          String.format("Error: expected name or typename, found %s", chrToStr(next)));
+    }
+    StringBuilder name = new StringBuilder();
+    while (isNameChar(in.peek())) {
+      name.append((char) in.pop());
+    }
+    return name.toString();
+  }
+
+  @Override public void close() throws IOException {
+    in.close();
+  }
+}
diff --git a/src/main/java/org/extendj/ragdoc/JavaDocParser.java b/src/main/java/org/extendj/ragdoc/JavaDocParser.java
index a1bbc2b5ed807358d559ed71925fc42cd0524ee4..5111ce1ce418bb9787688f8d83a14c89ff29ad73 100644
--- a/src/main/java/org/extendj/ragdoc/JavaDocParser.java
+++ b/src/main/java/org/extendj/ragdoc/JavaDocParser.java
@@ -233,7 +233,7 @@ public class JavaDocParser extends Object {
 
   private String parseInlineTag() {
     if (doc[i] != '{' || doc[i + 1] != '@') {
-      // no opening bracket
+      // No opening bracket.
       return "";
     }
     int j = i + 2;
diff --git a/src/main/java/org/extendj/ragdoc/JsonBuilder.java b/src/main/java/org/extendj/ragdoc/JsonBuilder.java
index d5d8d8a68116a0f7392f731ddcff240c8044f7e6..34a16fe810a999c1d8ec0f56e2919048eec7fef2 100644
--- a/src/main/java/org/extendj/ragdoc/JsonBuilder.java
+++ b/src/main/java/org/extendj/ragdoc/JsonBuilder.java
@@ -46,9 +46,11 @@ import org.extendj.util.Sorting;
 import se.llbit.json.Json;
 import se.llbit.json.JsonArray;
 import se.llbit.json.JsonObject;
+import se.llbit.json.JsonParser;
 import se.llbit.json.JsonValue;
 
 import java.io.File;
+import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.HashMap;
@@ -145,6 +147,12 @@ public class JsonBuilder {
     }
   }
 
+  /**
+   * Build a type reference object.
+   *
+   * <p>Type references point to a single type. Disambiguation is handled
+   * by using unique ID suffixes.
+   */
   private JsonObject typeRef(TypeDecl type) {
     // TODO: handle wildcard types.
     JsonObject obj = new JsonObject();
@@ -307,11 +315,13 @@ public class JsonBuilder {
       if (!params.isEmpty()) {
         doc.add("params", params);
       }
-      doc.add("description", Json.of(javadoc));
+      if (!javadoc.isEmpty()) {
+        doc.add("description", Json.of(javadoc));
+      }
       if (aspectName != null) {
         aspects.add(aspectName);
       }
-      return doc;
+      return doc.isEmpty() ? null : doc;
     }
     return null;
   }
@@ -377,6 +387,30 @@ public class JsonBuilder {
     }
     JsonObject doc = type.jsonDocObject();
     if (doc != null) {
+      String astdecl = doc.get("astdecl").stringValue("");
+      if (!astdecl.isEmpty()) {
+        // Parse ast declaration.
+        try (AstDeclParser parser = new AstDeclParser(astdecl)){
+          // Create declaration structure.
+          JsonObject decl = parser.parse();
+          if (!decl.get("e").stringValue("").isEmpty()) {
+            decl.set("e", typeRef(type.lookupType(decl.get("e").stringValue("")).singletonValue()));
+          }
+          doc.set("astdecl", decl);
+          JsonArray array = decl.get("c").array();
+          for (JsonValue c : array) {
+            JsonObject comp = c.object();
+            if (!comp.get("e").stringValue("").isEmpty()) {
+              comp.set("e",
+                  typeRef(type.lookupType(comp.get("e").stringValue("")).singletonValue()));
+            }
+          }
+        } catch (IOException e) {
+          e.printStackTrace();
+        } catch (JsonParser.SyntaxError syntaxError) {
+          syntaxError.printStackTrace();
+        }
+      }
       obj.add("doc", doc);
     }
     addInheritedMembers(type, obj);
diff --git a/src/test/groovy/org/extendj/ragdoc/AstDeclParserSpec.groovy b/src/test/groovy/org/extendj/ragdoc/AstDeclParserSpec.groovy
new file mode 100644
index 0000000000000000000000000000000000000000..ec2694a7fe765f3f8f27faa19018499e01958b9d
--- /dev/null
+++ b/src/test/groovy/org/extendj/ragdoc/AstDeclParserSpec.groovy
@@ -0,0 +1,167 @@
+/* Copyright (c) 2018, Jesper Öqvist <jesper.oqvist@cs.lth.se>
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice,
+ * this list of conditions and the following disclaimer.
+ *
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ *
+ * 3. Neither the name of the copyright holder nor the names of its
+ * contributors may be used to endorse or promote products derived from this
+ * software without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ */
+package org.extendj.ragdoc
+
+import spock.lang.*
+
+class AstDeclParserSpec extends Specification {
+
+  def "No children (no extends)"() {
+    when:
+    def res
+    new AstDeclParser("Decl;").withCloseable { parser ->
+      res = parser.parse()
+    }
+
+    then:
+    res.toCompactString() == '{"n":"Decl"}'
+  }
+
+  def "No children (extends)"() {
+    when:
+    def res
+    new AstDeclParser("IdDecl:Decl;").withCloseable { parser ->
+      res = parser.parse()
+    }
+
+    then:
+    res.toCompactString() == '{"n":"IdDecl","e":"Decl"}'
+  }
+
+  def "No children (extends) + whitespace"() {
+    when:
+    def res
+    new AstDeclParser("IdDecl  :  Decl   ;").withCloseable { parser ->
+      res = parser.parse()
+    }
+
+    then:
+    res.toCompactString() == '{"n":"IdDecl","e":"Decl"}'
+  }
+
+  def "Binary declaration"() {
+    when:
+    def res
+    new AstDeclParser("Binary ::= Left:Expr Right:Expr;").withCloseable { parser ->
+      res = parser.parse()
+    }
+
+    then:
+    res.toCompactString() == '{"n":"Binary","c":[{"n":"Left","e":"Expr"},{"n":"Right","e":"Expr"}]}'
+  }
+
+  def "List child"() {
+    when:
+    def res
+    new AstDeclParser("Program ::= CompilationUnit*;").withCloseable { parser ->
+      res = parser.parse()
+    }
+
+    then:
+    res.toCompactString() == '{"n":"Program","c":[{"e":"CompilationUnit","k":"list"}]}'
+  }
+
+  def "Named List child"() {
+    when:
+    def res
+    new AstDeclParser("Program ::= Unit:CompilationUnit*;").withCloseable { parser ->
+      res = parser.parse()
+    }
+
+    then:
+    res.toCompactString() == '{"n":"Program","c":[{"n":"Unit","e":"CompilationUnit","k":"list"}]}'
+  }
+
+  def "Named List child + whitespace"() {
+    when:
+    def res
+    new AstDeclParser("Program ::= Unit : CompilationUnit *;").withCloseable { parser ->
+      res = parser.parse()
+    }
+
+    then:
+    res.toCompactString() == '{"n":"Program","c":[{"n":"Unit","e":"CompilationUnit","k":"list"}]}'
+  }
+
+  def "Opt child"() {
+    when:
+    def res
+    new AstDeclParser("Return ::= [Expr];").withCloseable { parser ->
+      res = parser.parse()
+    }
+
+    then:
+    res.toCompactString() == '{"n":"Return","c":[{"e":"Expr","k":"opt"}]}'
+  }
+
+  def "Named Opt child"() {
+    when:
+    def res
+    new AstDeclParser("Return ::= [Result:Expr];").withCloseable { parser ->
+      res = parser.parse()
+    }
+
+    then:
+    res.toCompactString() == '{"n":"Return","c":[{"n":"Result","e":"Expr","k":"opt"}]}'
+  }
+
+  def "Named Opt child + whitespace"() {
+    when:
+    def res
+    new AstDeclParser("Return ::= [ Result : Expr ] ;").withCloseable { parser ->
+      res = parser.parse()
+    }
+
+    then:
+    res.toCompactString() == '{"n":"Return","c":[{"n":"Result","e":"Expr","k":"opt"}]}'
+  }
+
+  def "Token child"() {
+    when:
+    def res
+    new AstDeclParser("Number ::= <NUM>;").withCloseable { parser ->
+      res = parser.parse()
+    }
+
+    then:
+    res.toCompactString() == '{"n":"Number","c":[{"n":"NUM","e":"String","k":"token"}]}'
+  }
+
+  def "Named Token child"() {
+    when:
+    def res
+    new AstDeclParser("Number ::= <NUM:Integer>;").withCloseable { parser ->
+      res = parser.parse()
+    }
+
+    then:
+    res.toCompactString() == '{"n":"Number","c":[{"n":"NUM","e":"Integer","k":"token"}]}'
+  }
+}