Rewrite documentatation stubs to be fully qualified

When running doclava to generate javadocs from the
documentation stubs, it generates surprising unresolved
link errors, even for symbols that are clearly imported
in the same compilation unit.

This CL changes metalava such that it rewrites all
javadocs in the documentation stubs to be fully
qualified instead, to work around this issue.

For now, metalava does *not* flag unresolved symbols;
it was generating thousands of these, and in various
spot checks it looks like the documentation is actually
wrong, so we'll need to clean that up first.

Test: Unit tests included
Change-Id: I6dce30595b38aee8fc285e45aa4925fe6dbd186d
diff --git a/src/main/java/com/android/tools/metalava/ApiAnalyzer.kt b/src/main/java/com/android/tools/metalava/ApiAnalyzer.kt
index 86e564a..306af76 100644
--- a/src/main/java/com/android/tools/metalava/ApiAnalyzer.kt
+++ b/src/main/java/com/android/tools/metalava/ApiAnalyzer.kt
@@ -29,6 +29,7 @@
 import com.android.tools.metalava.model.PackageList
 import com.android.tools.metalava.model.ParameterItem
 import com.android.tools.metalava.model.TypeItem
+import com.android.tools.metalava.model.psi.EXPAND_DOCUMENTATION
 import com.android.tools.metalava.model.visitors.ApiVisitor
 import com.android.tools.metalava.model.visitors.ItemVisitor
 import java.util.ArrayList
@@ -494,8 +495,10 @@
             method.inheritedFrom = it.containingClass()
 
             // The documentation may use relative references to classes in import statements
-            // in the original class, so expand the documentation to be fully qualified
-            method.documentation = it.fullyQualifiedDocumentation()
+            // in the original class, so expand the documentation to be fully qualified.
+            if (!EXPAND_DOCUMENTATION) {
+                method.documentation = it.fullyQualifiedDocumentation()
+            }
             cls.addMethod(method)
         }
     }
diff --git a/src/main/java/com/android/tools/metalava/DocAnalyzer.kt b/src/main/java/com/android/tools/metalava/DocAnalyzer.kt
index 9cae0ff..89c15c0 100644
--- a/src/main/java/com/android/tools/metalava/DocAnalyzer.kt
+++ b/src/main/java/com/android/tools/metalava/DocAnalyzer.kt
@@ -16,7 +16,7 @@
 import com.android.tools.metalava.model.MemberItem
 import com.android.tools.metalava.model.MethodItem
 import com.android.tools.metalava.model.ParameterItem
-import com.android.tools.metalava.model.psi.PsiItem.Companion.containsLinkTags
+import com.android.tools.metalava.model.psi.containsLinkTags
 import com.android.tools.metalava.model.visitors.ApiVisitor
 import com.android.tools.metalava.model.visitors.VisibleItemVisitor
 import com.google.common.io.Files
diff --git a/src/main/java/com/android/tools/metalava/StubWriter.kt b/src/main/java/com/android/tools/metalava/StubWriter.kt
index 62f9ed3..a4588fc 100644
--- a/src/main/java/com/android/tools/metalava/StubWriter.kt
+++ b/src/main/java/com/android/tools/metalava/StubWriter.kt
@@ -30,6 +30,7 @@
 import com.android.tools.metalava.model.ModifierList
 import com.android.tools.metalava.model.PackageItem
 import com.android.tools.metalava.model.TypeParameterList
+import com.android.tools.metalava.model.psi.EXPAND_DOCUMENTATION
 import com.android.tools.metalava.model.psi.PsiClassItem
 import com.android.tools.metalava.model.psi.trimDocIndent
 import com.android.tools.metalava.model.visitors.ApiVisitor
@@ -206,18 +207,20 @@
                 writer.println()
             }
 
-            compilationUnit?.getImportStatements(filterReference)?.let {
-                for (item in it) {
-                    when (item) {
-                        is PackageItem ->
-                            writer.println("import ${item.qualifiedName()}.*;")
-                        is ClassItem ->
-                            writer.println("import ${item.qualifiedName()};")
-                        is MemberItem ->
-                            writer.println("import static ${item.containingClass().qualifiedName()}.${item.name()};")
+            if (EXPAND_DOCUMENTATION) {
+                compilationUnit?.getImportStatements(filterReference)?.let {
+                    for (item in it) {
+                        when (item) {
+                            is PackageItem ->
+                                writer.println("import ${item.qualifiedName()}.*;")
+                            is ClassItem ->
+                                writer.println("import ${item.qualifiedName()};")
+                            is MemberItem ->
+                                writer.println("import static ${item.containingClass().qualifiedName()}.${item.name()};")
+                        }
                     }
+                    writer.println()
                 }
-                writer.println()
             }
         }
 
@@ -285,7 +288,11 @@
 
     private fun appendDocumentation(item: Item, writer: PrintWriter) {
         if (options.includeDocumentationInStubs || docStubs) {
-            val documentation = item.documentation
+            val documentation = if (docStubs && EXPAND_DOCUMENTATION) {
+                item.fullyQualifiedDocumentation()
+            } else {
+                item.documentation
+            }
             if (documentation.isNotBlank()) {
                 val trimmed = trimDocIndent(documentation)
                 writer.println(trimmed)
diff --git a/src/main/java/com/android/tools/metalava/model/psi/Javadoc.kt b/src/main/java/com/android/tools/metalava/model/psi/Javadoc.kt
index e66c2f6..b61f0d6 100644
--- a/src/main/java/com/android/tools/metalava/model/psi/Javadoc.kt
+++ b/src/main/java/com/android/tools/metalava/model/psi/Javadoc.kt
@@ -16,22 +16,72 @@
 
 package com.android.tools.metalava.model.psi
 
+import com.android.tools.metalava.doclava1.Errors
+import com.android.tools.metalava.model.ClassItem
+import com.android.tools.metalava.model.Item
+import com.android.tools.metalava.model.PackageItem
+import com.android.tools.metalava.reporter
 import com.intellij.psi.JavaDocTokenType
 import com.intellij.psi.JavaPsiFacade
+import com.intellij.psi.PsiClass
 import com.intellij.psi.PsiElement
+import com.intellij.psi.PsiJavaCodeReferenceElement
+import com.intellij.psi.PsiMember
 import com.intellij.psi.PsiMethod
+import com.intellij.psi.PsiReference
+import com.intellij.psi.PsiTypeParameter
+import com.intellij.psi.PsiWhiteSpace
+import com.intellij.psi.impl.source.SourceTreeToPsiMap
+import com.intellij.psi.impl.source.javadoc.PsiDocMethodOrFieldRef
+import com.intellij.psi.impl.source.tree.CompositePsiElement
+import com.intellij.psi.impl.source.tree.JavaDocElementType
 import com.intellij.psi.javadoc.PsiDocComment
 import com.intellij.psi.javadoc.PsiDocTag
 import com.intellij.psi.javadoc.PsiDocToken
+import com.intellij.psi.javadoc.PsiInlineDocTag
 import org.intellij.lang.annotations.Language
 
 /*
- * Various utilities for merging comments into existing javadoc sections.
+ * Various utilities for handling javadoc, such as
+ * merging comments into existing javadoc sections,
+ * rewriting javadocs into fully qualified references, etc.
  *
  * TODO: Handle KDoc
  */
 
 /**
+ * If true, we'll rewrite all the javadoc documentation in doc stubs
+ * to include fully qualified names
+ */
+const val EXPAND_DOCUMENTATION = true
+
+/**
+ * If the reference is to a class in the same package, include the package prefix?
+ * This should not be necessary, but doclava has problems finding classes without
+ * it. Consider turning this off when we switch to Dokka.
+ */
+const val INCLUDE_SAME_PACKAGE = true
+
+/** If documentation starts with hash, insert the implicit class? */
+const val PREPEND_LOCAL_CLASS = false
+
+/**
+ * Whether we should report unresolved symbols. This is typically
+ * a bug in the documentation. It looks like there are a LOT
+ * of mistakes right now, so I'm worried about turning this on
+ * since doclava didn't seem to abort on this.
+ *
+ * Here are some examples I've spot checked:
+ * (1) "Unresolved SQLExceptionif": In java.sql.CallableStatement the
+ * getBigDecimal method contains this, presumably missing a space
+ * before the if suffix: "@exception SQLExceptionif parameterName does not..."
+ * (2) In android.nfc.tech.IsoDep there is "@throws TagLostException if ..."
+ * but TagLostException is not imported anywhere and is not in the same
+ * package (it's in the parent package).
+ */
+const val REPORT_UNRESOLVED_SYMBOLS = false
+
+/**
  * Merges the given [newText] into the existing documentation block [existingDoc]
  * (which should be a full documentation node, including the surrounding comment
  * start and end tokens.)
@@ -234,6 +284,7 @@
     // Combine into comment lines prefixed by asterisk, ,and make sure we don't
     // have end-comment markers in the HTML that will escape out of the javadoc comment
     val comment = body.lines().joinToString(separator = "\n") { " * $it" }.replace("*/", "*/")
+    @Suppress("DanglingJavadoc")
     return "/**\n$comment\n */\n"
 }
 
@@ -406,10 +457,10 @@
                 if (c == '>') {
                     state = STATE_TEXT
                 } else if (c == '/') {
-                        // we expect an '>' next to close the tag
-                    } else if (!Character.isWhitespace(c)) {
-                        state = STATE_ATTRIBUTE_NAME
-                    }
+                    // we expect an '>' next to close the tag
+                } else if (!Character.isWhitespace(c)) {
+                    state = STATE_ATTRIBUTE_NAME
+                }
                 offset++
             }
             STATE_ATTRIBUTE_NAME -> {
@@ -474,3 +525,513 @@
 
     return html
 }
+
+fun containsLinkTags(documentation: String): Boolean {
+    var index = 0
+    while (true) {
+        index = documentation.indexOf('@', index)
+        if (index == -1) {
+            return false
+        }
+        if (!documentation.startsWith("@code", index) &&
+            !documentation.startsWith("@literal", index) &&
+            !documentation.startsWith("@param", index) &&
+            !documentation.startsWith("@deprecated", index) &&
+            !documentation.startsWith("@inheritDoc", index) &&
+            !documentation.startsWith("@return", index)) {
+            return true
+        }
+
+        index++
+    }
+}
+
+// ------------------------------------------------------------------------------------
+// Expanding javadocs into fully qualified documentation
+// ------------------------------------------------------------------------------------
+
+fun toFullyQualifiedDocumentation(owner: PsiItem, documentation: String): String {
+    if (documentation.isBlank() || !containsLinkTags(documentation)) {
+        return documentation
+    }
+
+    val codebase = owner.codebase
+    val comment =
+        try {
+            codebase.getComment(documentation, owner.psi())
+        } catch (throwable: Throwable) {
+            // TODO: Get rid of line comments as documentation
+            // Invalid comment
+            if (documentation.startsWith("//") && documentation.contains("/**")) {
+                return toFullyQualifiedDocumentation(owner, documentation.substring(documentation.indexOf("/**")))
+            }
+            codebase.getComment(documentation, owner.psi())
+        }
+    val sb = StringBuilder(documentation.length)
+    expand(owner, comment, sb)
+
+    return sb.toString()
+}
+
+private fun reportUnresolvedDocReference(owner: Item, unresolved: String) {
+    @Suppress("ConstantConditionIf")
+    if (!REPORT_UNRESOLVED_SYMBOLS) {
+        return
+    }
+
+    if (unresolved.startsWith("{@") && !unresolved.startsWith("{@link")) {
+        return
+    }
+
+    // References are sometimes split across lines and therefore have newlines, leading asterisks
+    // etc in the middle: clean this up before emitting reference into error message
+    val cleaned = unresolved.replace("\n", "").replace("*", "")
+        .replace("  ", " ")
+
+    reporter.report(Errors.UNRESOLVED_LINK, owner, "Unresolved documentation reference: $cleaned")
+}
+
+private fun expand(owner: PsiItem, element: PsiElement, sb: StringBuilder) {
+    when {
+        element is PsiWhiteSpace -> {
+            sb.append(element.text)
+        }
+        element is PsiDocToken -> {
+            assert(element.firstChild == null)
+            val text = element.text
+            // Auto-fix some docs in the framework which starts with R.styleable in @attr
+            if (text.startsWith("R.styleable#") && owner.documentation.contains("@attr")) {
+                sb.append("android.")
+            }
+
+            sb.append(text)
+        }
+        element is PsiDocMethodOrFieldRef -> {
+            val text = element.text
+            var resolved = element.reference?.resolve()
+
+            // Workaround: relative references doesn't work from a class item to its members
+            if (resolved == null && owner is ClassItem) {
+                // For some reason, resolving relative methods and field references at the root
+                // level isn't working right.
+                if (PREPEND_LOCAL_CLASS && text.startsWith("#")) {
+                    var end = text.indexOf('(')
+                    if (end == -1) {
+                        // definitely a field
+                        end = text.length
+                        val fieldName = text.substring(1, end)
+                        val field = owner.findField(fieldName)
+                        if (field != null) {
+                            resolved = field.psi()
+                        }
+                    }
+                    if (resolved == null) {
+                        val methodName = text.substring(1, end)
+                        resolved = (owner.psi() as PsiClass).findMethodsByName(methodName, true).firstOrNull()
+                    }
+                }
+            }
+
+            if (resolved is PsiMember) {
+                val containingClass = resolved.containingClass
+                if (containingClass != null && !samePackage(owner, containingClass)) {
+                    val referenceText = element.reference?.element?.text ?: text
+                    if (!PREPEND_LOCAL_CLASS && referenceText.startsWith("#")) {
+                        sb.append(text)
+                        return
+                    }
+
+                    var className = containingClass.qualifiedName
+
+                    if (element.firstChildNode.elementType === JavaDocElementType.DOC_REFERENCE_HOLDER) {
+                        val firstChildPsi =
+                            SourceTreeToPsiMap.treeElementToPsi(element.firstChildNode.firstChildNode)
+                        if (firstChildPsi is PsiJavaCodeReferenceElement) {
+                            val referenceElement = firstChildPsi as PsiJavaCodeReferenceElement?
+                            val referencedElement = referenceElement!!.resolve()
+                            if (referencedElement is PsiClass) {
+                                className = referencedElement.qualifiedName
+                            }
+                        }
+                    }
+
+                    sb.append(className)
+                    sb.append('#')
+                    sb.append(resolved.name)
+                    val index = text.indexOf('(')
+                    if (index != -1) {
+                        sb.append(text.substring(index))
+                    }
+                } else {
+                    sb.append(text)
+                }
+            } else {
+                if (resolved == null) {
+                    val referenceText = element.reference?.element?.text ?: text
+                    if (text.startsWith("#") && owner is ClassItem) {
+                        // Unfortunately resolving references is broken from class javadocs
+                        // to members using just a relative reference, #.
+                    } else {
+                        reportUnresolvedDocReference(owner, referenceText)
+                    }
+                }
+                sb.append(text)
+            }
+        }
+        element is PsiJavaCodeReferenceElement -> {
+            val resolved = element.resolve()
+            if (resolved is PsiClass) {
+                if (samePackage(owner, resolved) || resolved is PsiTypeParameter) {
+                    sb.append(element.text)
+                } else {
+                    sb.append(resolved.qualifiedName)
+                }
+            } else if (resolved is PsiMember) {
+                val text = element.text
+                sb.append(resolved.containingClass?.qualifiedName)
+                sb.append('#')
+                sb.append(resolved.name)
+                val index = text.indexOf('(')
+                if (index != -1) {
+                    sb.append(text.substring(index))
+                }
+            } else {
+                val text = element.text
+                if (resolved == null) {
+                    reportUnresolvedDocReference(owner, text)
+                }
+                sb.append(text)
+            }
+        }
+        element is PsiInlineDocTag -> {
+            val handled = handleTag(element, owner, sb)
+            if (!handled) {
+                sb.append(element.text)
+            }
+        }
+        element.firstChild != null -> {
+            var curr = element.firstChild
+            while (curr != null) {
+                expand(owner, curr, sb)
+                curr = curr.nextSibling
+            }
+        }
+        else -> {
+            val text = element.text
+            sb.append(text)
+        }
+    }
+}
+
+fun handleTag(
+    element: PsiInlineDocTag,
+    owner: PsiItem,
+    sb: StringBuilder
+): Boolean {
+    val name = element.name
+    if (name == "code" || name == "literal") {
+        // @code: don't attempt to rewrite this
+        sb.append(element.text)
+        return true
+    }
+
+    val reference = extractReference(element)
+    val referenceText = reference?.element?.text ?: element.text
+    if (!PREPEND_LOCAL_CLASS && referenceText.startsWith("#")) {
+        val suffix = element.text
+        if (suffix.contains("(") && suffix.contains(")")) {
+            expandArgumentList(element, suffix, sb)
+        } else {
+            sb.append(suffix)
+        }
+        return true
+    }
+
+    // TODO: If referenceText is already absolute, e.g. android.Manifest.permission#BIND_CARRIER_SERVICES,
+    // try to short circuit this?
+
+    val valueElement = element.valueElement
+    if (valueElement is CompositePsiElement) {
+        if (valueElement.firstChildNode.elementType === JavaDocElementType.DOC_REFERENCE_HOLDER) {
+            val firstChildPsi =
+                SourceTreeToPsiMap.treeElementToPsi(valueElement.firstChildNode.firstChildNode)
+            if (firstChildPsi is PsiJavaCodeReferenceElement) {
+                val referenceElement = firstChildPsi as PsiJavaCodeReferenceElement?
+                val referencedElement = referenceElement!!.resolve()
+                if (referencedElement is PsiClass) {
+                    var className = PsiClassItem.computeFullClassName(referencedElement)
+                    if (className.indexOf('.') != -1 && !referenceText.startsWith(className)) {
+                        val simpleName = referencedElement.name
+                        if (simpleName != null && referenceText.startsWith(simpleName)) {
+                            className = simpleName
+                        }
+                    }
+                    if (referenceText.startsWith(className)) {
+                        sb.append("{@")
+                        sb.append(element.name)
+                        sb.append(' ')
+                        sb.append(referencedElement.qualifiedName)
+                        val suffix = referenceText.substring(className.length)
+                        if (suffix.contains("(") && suffix.contains(")")) {
+                            expandArgumentList(element, suffix, sb)
+                        } else {
+                            sb.append(suffix)
+                        }
+                        sb.append(' ')
+                        sb.append(referenceText)
+                        sb.append("}")
+                        return true
+                    }
+                }
+            }
+        }
+    }
+
+    var resolved = reference?.resolve()
+    if (resolved == null && owner is ClassItem) {
+        // For some reason, resolving relative methods and field references at the root
+        // level isn't working right.
+        if (PREPEND_LOCAL_CLASS && referenceText.startsWith("#")) {
+            var end = referenceText.indexOf('(')
+            if (end == -1) {
+                // definitely a field
+                end = referenceText.length
+                val fieldName = referenceText.substring(1, end)
+                val field = owner.findField(fieldName)
+                if (field != null) {
+                    resolved = field.psi()
+                }
+            }
+            if (resolved == null) {
+                val methodName = referenceText.substring(1, end)
+                resolved = (owner.psi() as PsiClass).findMethodsByName(methodName, true).firstOrNull()
+            }
+        }
+    }
+
+    if (resolved != null) {
+        when (resolved) {
+            is PsiClass -> {
+                val text = element.text
+                if (samePackage(owner, resolved)) {
+                    sb.append(text)
+                    return true
+                }
+                val qualifiedName = resolved.qualifiedName ?: run {
+                    sb.append(text)
+                    return true
+                }
+                if (referenceText == qualifiedName) {
+                    // Already absolute
+                    sb.append(text)
+                    return true
+                }
+                val append = when {
+                    valueElement != null -> {
+                        val start = valueElement.startOffsetInParent
+                        val end = start + valueElement.textLength
+                        text.substring(0, start) + qualifiedName + text.substring(end)
+                    }
+                    name == "see" -> {
+                        val suffix = text.substring(text.indexOf(referenceText) + referenceText.length)
+                        "@see $qualifiedName$suffix"
+                    }
+                    text.startsWith("{") -> "{@$name $qualifiedName $referenceText}"
+                    else -> "@$name $qualifiedName $referenceText"
+                }
+                sb.append(append)
+                return true
+            }
+            is PsiMember -> {
+                val text = element.text
+                val containing = resolved.containingClass ?: run {
+                    sb.append(text)
+                    return true
+                }
+                if (samePackage(owner, containing)) {
+                    sb.append(text)
+                    return true
+                }
+                val qualifiedName = containing.qualifiedName ?: run {
+                    sb.append(text)
+                    return true
+                }
+                if (referenceText.startsWith(qualifiedName)) {
+                    // Already absolute
+                    sb.append(text)
+                    return true
+                }
+
+                // It may also be the case that the reference is already fully qualified
+                // but to some different class. For example, the link may be to
+                // android.os.Bundle#getInt, but the resolved method actually points to
+                // an inherited method into android.os.Bundle from android.os.BaseBundle.
+                // In that case we don't want to rewrite the link.
+                for (index in 0 until referenceText.length) {
+                    val c = referenceText[index]
+                    if (c == '.') {
+                        // Already qualified
+                        sb.append(text)
+                        return true
+                    } else if (!Character.isJavaIdentifierPart(c)) {
+                        break
+                    }
+                }
+
+                if (valueElement != null) {
+                    val start = valueElement.startOffsetInParent
+
+                    var nameEnd = -1
+                    var close = start
+                    var balance = 0
+                    while (close < text.length) {
+                        val c = text[close]
+                        if (c == '(') {
+                            if (nameEnd == -1) {
+                                nameEnd = close
+                            }
+                            balance++
+                        } else if (c == ')') {
+                            balance--
+                            if (balance == 0) {
+                                close++
+                                break
+                            }
+                        } else if (c == '}') {
+                            if (nameEnd == -1) {
+                                nameEnd = close
+                            }
+                            break
+                        } else if (balance == 0 && c == '#') {
+                            if (nameEnd == -1) {
+                                nameEnd = close
+                            }
+                        } else if (balance == 0 && !Character.isJavaIdentifierPart(c)) {
+                            break
+                        }
+                        close++
+                    }
+                    val memberPart = text.substring(nameEnd, close)
+                    val append = "${text.substring(0, start)}$qualifiedName$memberPart $referenceText}"
+                    sb.append(append)
+                    return true
+                }
+            }
+        }
+    } else {
+        reportUnresolvedDocReference(owner, referenceText)
+    }
+
+    return false
+}
+
+private fun expandArgumentList(
+    element: PsiInlineDocTag,
+    suffix: String,
+    sb: StringBuilder
+) {
+    val elementFactory = JavaPsiFacade.getElementFactory(element.project)
+    // Try to rewrite the types to fully qualified names as well
+    val begin = suffix.indexOf('(')
+    sb.append(suffix.substring(0, begin + 1))
+    var index = begin + 1
+    var balance = 0
+    var argBegin = index
+    while (index < suffix.length) {
+        val c = suffix[index++]
+        if (c == '<' || c == '(') {
+            balance++
+        } else if (c == '>') {
+            balance--
+        } else if (c == ')' && balance == 0 || c == ',') {
+            // Strip off javadoc header
+            while (argBegin < index) {
+                val p = suffix[argBegin]
+                if (p != '*' && !p.isWhitespace()) {
+                    break
+                }
+                argBegin++
+            }
+            if (index > argBegin + 1) {
+                val arg = suffix.substring(argBegin, index - 1).trim()
+                val space = arg.indexOf(' ')
+                // Strip off parameter name (shouldn't be there but happens
+                // in some Android sources sine tools didn't use to complain
+                val typeString = if (space == -1) {
+                    arg
+                } else {
+                    if (space < arg.length - 1 && !arg[space + 1].isJavaIdentifierStart()) {
+                        // Example: "String []"
+                        arg
+                    } else {
+                        // Example "String name"
+                        arg.substring(0, space)
+                    }
+                }
+                var insert = arg
+                if (typeString[0].isUpperCase()) {
+                    try {
+                        val type = elementFactory.createTypeFromText(typeString, element)
+                        insert = type.canonicalText
+                    } catch (ignore: com.intellij.util.IncorrectOperationException) {
+                        // Not a valid type - just leave what was in the parameter text
+                    }
+                }
+                sb.append(insert)
+                sb.append(c)
+                if (c == ')') {
+                    break
+                }
+            } else if (c == ')') {
+                sb.append(')')
+                break
+            }
+            argBegin = index
+        } else if (c == ')') {
+            balance--
+        }
+    }
+    while (index < suffix.length) {
+        sb.append(suffix[index++])
+    }
+}
+
+private fun samePackage(owner: PsiItem, cls: PsiClass): Boolean {
+    @Suppress("ConstantConditionIf")
+    if (INCLUDE_SAME_PACKAGE) {
+        // doclava seems to have REAL problems with this
+        return false
+    }
+    val pkg = packageName(owner) ?: return false
+    return cls.qualifiedName == "$pkg.${cls.name}"
+}
+
+private fun packageName(owner: PsiItem): String? {
+    var curr: Item? = owner
+    while (curr != null) {
+        if (curr is PackageItem) {
+            return curr.qualifiedName()
+        }
+        curr = curr.parent()
+    }
+
+    return null
+}
+
+// Copied from UnnecessaryJavaDocLinkInspection and tweaked a bit
+private fun extractReference(tag: PsiDocTag): PsiReference? {
+    val valueElement = tag.valueElement
+    if (valueElement != null) {
+        return valueElement.reference
+    }
+    // hack around the fact that a reference to a class is apparently
+    // not a PsiDocTagValue
+    val dataElements = tag.dataElements
+    if (dataElements.isEmpty()) {
+        return null
+    }
+    val salientElement: PsiElement =
+        dataElements.firstOrNull { it !is PsiWhiteSpace && it !is PsiDocToken } ?: return null
+    val child = salientElement.firstChild
+    return if (child !is PsiReference) null else child
+}
\ No newline at end of file
diff --git a/src/main/java/com/android/tools/metalava/model/psi/PsiClassItem.kt b/src/main/java/com/android/tools/metalava/model/psi/PsiClassItem.kt
index 93da31c..2c2b15a 100644
--- a/src/main/java/com/android/tools/metalava/model/psi/PsiClassItem.kt
+++ b/src/main/java/com/android/tools/metalava/model/psi/PsiClassItem.kt
@@ -553,7 +553,7 @@
          * Computes the "full" class name; this is not the qualified class name (e.g. with package)
          * but for an inner class it includes all the outer classes
          */
-        private fun computeFullClassName(cls: PsiClass): String {
+        fun computeFullClassName(cls: PsiClass): String {
             if (cls.containingClass == null) {
                 val name = cls.name
                 return name!!
diff --git a/src/main/java/com/android/tools/metalava/model/psi/PsiItem.kt b/src/main/java/com/android/tools/metalava/model/psi/PsiItem.kt
index 40a1d7d..3ac1d13 100644
--- a/src/main/java/com/android/tools/metalava/model/psi/PsiItem.kt
+++ b/src/main/java/com/android/tools/metalava/model/psi/PsiItem.kt
@@ -17,20 +17,12 @@
 package com.android.tools.metalava.model.psi
 
 import com.android.tools.metalava.model.DefaultItem
-import com.android.tools.metalava.model.Item
 import com.android.tools.metalava.model.MutableModifierList
-import com.android.tools.metalava.model.PackageItem
 import com.android.tools.metalava.model.ParameterItem
-import com.intellij.psi.PsiClass
 import com.intellij.psi.PsiCompiledElement
 import com.intellij.psi.PsiDocCommentOwner
 import com.intellij.psi.PsiElement
-import com.intellij.psi.PsiMember
 import com.intellij.psi.PsiModifierListOwner
-import com.intellij.psi.PsiReference
-import com.intellij.psi.PsiWhiteSpace
-import com.intellij.psi.javadoc.PsiDocTag
-import com.intellij.psi.javadoc.PsiInlineDocTag
 import org.jetbrains.kotlin.kdoc.psi.api.KDoc
 import org.jetbrains.uast.UElement
 import org.jetbrains.uast.sourcePsiElement
@@ -130,178 +122,12 @@
         documentation = mergeDocumentation(documentation, element, comment.trim(), tagSection, append)
     }
 
-    private fun packageName(): String? {
-        var curr: Item? = this
-        while (curr != null) {
-            if (curr is PackageItem) {
-                return curr.qualifiedName()
-            }
-            curr = curr.parent()
-        }
-
-        return null
-    }
-
     override fun fullyQualifiedDocumentation(): String {
         return fullyQualifiedDocumentation(documentation)
     }
 
     override fun fullyQualifiedDocumentation(documentation: String): String {
-        if (documentation.isBlank() || !containsLinkTags(documentation)) {
-            return documentation
-        }
-
-        if (!(documentation.contains("@link") || // includes @linkplain
-                documentation.contains("@see") ||
-                documentation.contains("@throws"))
-        ) {
-            // No relevant tags that need to be expanded/rewritten
-            return documentation
-        }
-
-        val comment =
-            try {
-                codebase.getComment(documentation, psi())
-            } catch (throwable: Throwable) {
-                // TODO: Get rid of line comments as documentation
-                // Invalid comment
-                if (documentation.startsWith("//") && documentation.contains("/**")) {
-                    return fullyQualifiedDocumentation(documentation.substring(documentation.indexOf("/**")))
-                }
-                codebase.getComment(documentation, psi())
-            }
-        val sb = StringBuilder(documentation.length)
-        var curr = comment.firstChild
-        while (curr != null) {
-            if (curr is PsiDocTag) {
-                sb.append(getExpanded(curr))
-            } else {
-                sb.append(curr.text)
-            }
-            curr = curr.nextSibling
-        }
-
-        return sb.toString()
-    }
-
-    private fun getExpanded(tag: PsiDocTag): String {
-        val text = tag.text
-        var valueElement = tag.valueElement
-        val reference = extractReference(tag)
-        var resolved = reference?.resolve()
-        var referenceText = reference?.element?.text
-        if (resolved == null && tag.name == "throws") {
-            // Workaround: @throws does not provide a valid reference to the class
-            val dataElements = tag.dataElements
-            if (dataElements.isNotEmpty()) {
-                if (dataElements[0] is PsiInlineDocTag) {
-                    val innerReference = extractReference(dataElements[0] as PsiInlineDocTag)
-                    resolved = innerReference?.resolve()
-                    if (innerReference != null && resolved == null) {
-                        referenceText = innerReference.canonicalText
-                        resolved = codebase.createReferenceFromText(referenceText, psi()).resolve()
-                    } else {
-                        referenceText = innerReference?.element?.text
-                    }
-                }
-                if (resolved == null || referenceText == null) {
-                    val exceptionName = dataElements[0].text
-                    val exceptionReference = codebase.createReferenceFromText(exceptionName, psi())
-                    resolved = exceptionReference.resolve()
-                    referenceText = exceptionName
-                } else {
-                    // Create a placeholder value since the inline tag
-                    // wipes it out
-                    val t = dataElements[0].text
-                    val index = text.indexOf(t) + t.length
-                    val suffix = text.substring(index)
-                    val dummyTag = codebase.createDocTagFromText("@${tag.name} $suffix")
-                    valueElement = dummyTag.valueElement
-                }
-            } else {
-                return text
-            }
-        }
-
-        if (resolved != null && referenceText != null) {
-            if (referenceText.startsWith("#")) {
-                // Already a local/relative reference
-                return text
-            }
-
-            when (resolved) {
-                // TODO: If not absolute, preserve syntax
-                is PsiClass -> {
-                    if (samePackage(resolved)) {
-                        return text
-                    }
-                    val qualifiedName = resolved.qualifiedName ?: return text
-                    if (referenceText == qualifiedName) {
-                        // Already absolute
-                        return text
-                    }
-                    return when {
-                        valueElement != null -> {
-                            val start = valueElement.startOffsetInParent
-                            val end = start + valueElement.textLength
-                            text.substring(0, start) + qualifiedName + text.substring(end)
-                        }
-                        tag.name == "see" -> {
-                            val suffix = text.substring(text.indexOf(referenceText) + referenceText.length)
-                            "@see $qualifiedName$suffix"
-                        }
-                        text.startsWith("{") -> "{@${tag.name} $qualifiedName $referenceText}"
-                        else -> "@${tag.name} $qualifiedName $referenceText"
-                    }
-                }
-                is PsiMember -> {
-                    val containing = resolved.containingClass ?: return text
-                    if (samePackage(containing)) {
-                        return text
-                    }
-                    val qualifiedName = containing.qualifiedName ?: return text
-                    if (referenceText.startsWith(qualifiedName)) {
-                        // Already absolute
-                        return text
-                    }
-
-                    val name = containing.name ?: return text
-                    if (valueElement != null) {
-                        val start = valueElement.startOffsetInParent
-                        val close = text.lastIndexOf('}')
-                        if (close == -1) {
-                            return text // invalid javadoc
-                        }
-                        val memberPart = text.substring(text.indexOf(name, start) + name.length, close)
-                        return "${text.substring(0, start)}$qualifiedName$memberPart $referenceText}"
-                    }
-                }
-            }
-        }
-
-        return text
-    }
-
-    private fun samePackage(cls: PsiClass): Boolean {
-        val pkg = packageName() ?: return false
-        return cls.qualifiedName == "$pkg.${cls.name}"
-    }
-
-    // Copied from UnnecessaryJavaDocLinkInspection
-    private fun extractReference(tag: PsiDocTag): PsiReference? {
-        val valueElement = tag.valueElement
-        if (valueElement != null) {
-            return valueElement.reference
-        }
-        // hack around the fact that a reference to a class is apparently
-        // not a PsiDocTagValue
-        val dataElements = tag.dataElements
-        if (dataElements.isEmpty()) {
-            return null
-        }
-        val salientElement: PsiElement = dataElements.firstOrNull { it !is PsiWhiteSpace } ?: return null
-        val child = salientElement.firstChild
-        return if (child !is PsiReference) null else child
+        return toFullyQualifiedDocumentation(this, documentation)
     }
 
     /** Finish initialization of the item */
@@ -342,20 +168,6 @@
             return ""
         }
 
-        fun containsLinkTags(documentation: String): Boolean {
-            var index = 0
-            while (true) {
-                index = documentation.indexOf('@', index)
-                if (index == -1) {
-                    return false
-                }
-                if (!documentation.startsWith("@code", index)) {
-                    return true
-                }
-                index++
-            }
-        }
-
         fun modifiers(
             codebase: PsiBasedCodebase,
             element: PsiModifierListOwner,
diff --git a/src/test/java/com/android/tools/metalava/DocAnalyzerTest.kt b/src/test/java/com/android/tools/metalava/DocAnalyzerTest.kt
index 6bcdbcc..dc25ca4 100644
--- a/src/test/java/com/android/tools/metalava/DocAnalyzerTest.kt
+++ b/src/test/java/com/android/tools/metalava/DocAnalyzerTest.kt
@@ -1226,6 +1226,7 @@
                     package android.content.pm;
                     public abstract class PackageManager {
                         public static final String FEATURE_LOCATION = "android.hardware.location";
+                        public boolean hasSystemFeature(String feature) { return false; }
                     }
                     """
                 ),
diff --git a/src/test/java/com/android/tools/metalava/DriverTest.kt b/src/test/java/com/android/tools/metalava/DriverTest.kt
index ccc834a..3dbc1c1 100644
--- a/src/test/java/com/android/tools/metalava/DriverTest.kt
+++ b/src/test/java/com/android/tools/metalava/DriverTest.kt
@@ -1723,7 +1723,7 @@
     """
 ).indented()
 
-val libcoreNonNullSource: TestFile = DriverTest.java(
+val libcoreNonNullSource: TestFile = java(
     """
     package libcore.util;
     import static java.lang.annotation.ElementType.*;
@@ -1739,7 +1739,7 @@
     """
 ).indented()
 
-val libcoreNullableSource: TestFile = DriverTest.java(
+val libcoreNullableSource: TestFile = java(
     """
     package libcore.util;
     import static java.lang.annotation.ElementType.*;
diff --git a/src/test/java/com/android/tools/metalava/StubsTest.kt b/src/test/java/com/android/tools/metalava/StubsTest.kt
index 0f61e0b..3837b71 100644
--- a/src/test/java/com/android/tools/metalava/StubsTest.kt
+++ b/src/test/java/com/android/tools/metalava/StubsTest.kt
@@ -2996,107 +2996,6 @@
     }
 
     @Test
-    fun `Rewrite relative documentation links`() {
-        // When generating casts in super constructor calls, use raw types
-        checkStubs(
-            checkDoclava1 = false,
-            sourceFiles =
-            *arrayOf(
-                java(
-                    """
-                    package test.pkg1;
-                    import java.io.IOException;
-                    import test.pkg2.OtherClass;
-
-                    /**
-                     *  Blah blah {@link OtherClass} blah blah.
-                     *  Referencing <b>field</b> {@link OtherClass#foo},
-                     *  and referencing method {@link OtherClass#bar(int,
-                     *   boolean)}.
-                     *  And relative method reference {@link #baz()}.
-                     *  And relative field reference {@link #importance}.
-                     *  Here's an already fully qualified reference: {@link test.pkg2.OtherClass}.
-                     *  And here's one in the same package: {@link LocalClass}.
-                     *
-                     *  @deprecated For some reason
-                     *  @see OtherClass
-                     *  @see OtherClass#bar(int, boolean)
-                     */
-                    @SuppressWarnings("all")
-                    public class SomeClass {
-                       /**
-                       * My method.
-                       * @param focus The focus to find. One of {@link OtherClass#FOCUS_INPUT} or
-                       *         {@link OtherClass#FOCUS_ACCESSIBILITY}.
-                       * @throws IOException when blah blah blah
-                       * @throws {@link RuntimeException} when blah blah blah
-                       */
-                       public void baz(int focus) throws IOException;
-                       public boolean importance;
-                    }
-                    """
-                ),
-                java(
-                    """
-                    package test.pkg2;
-
-                    @SuppressWarnings("all")
-                    public class OtherClass {
-                        public static final int FOCUS_INPUT = 1;
-                        public static final int FOCUS_ACCESSIBILITY = 2;
-                        public int foo;
-                        public void bar(int baz, boolean bar);
-                    }
-                    """
-                ),
-                java(
-                    """
-                    package test.pkg1;
-
-                    @SuppressWarnings("all")
-                    public class LocalClass {
-                    }
-                    """
-                )
-            ),
-            warnings = "",
-            source = """
-                    package test.pkg1;
-                    import test.pkg2.OtherClass;
-                    import java.io.IOException;
-                    /**
-                     *  Blah blah {@link OtherClass} blah blah.
-                     *  Referencing <b>field</b> {@link OtherClass#foo},
-                     *  and referencing method {@link OtherClass#bar(int,
-                     *   boolean)}.
-                     *  And relative method reference {@link #baz()}.
-                     *  And relative field reference {@link #importance}.
-                     *  Here's an already fully qualified reference: {@link test.pkg2.OtherClass}.
-                     *  And here's one in the same package: {@link LocalClass}.
-                     *
-                     *  @deprecated For some reason
-                     *  @see OtherClass
-                     *  @see OtherClass#bar(int, boolean)
-                     */
-                    @SuppressWarnings({"unchecked", "deprecation", "all"})
-                    @Deprecated
-                    public class SomeClass {
-                    public SomeClass() { throw new RuntimeException("Stub!"); }
-                    /**
-                     * My method.
-                     * @param focus The focus to find. One of {@link OtherClass#FOCUS_INPUT} or
-                     *         {@link OtherClass#FOCUS_ACCESSIBILITY}.
-                     * @throws IOException when blah blah blah
-                     * @throws {@link RuntimeException} when blah blah blah
-                     */
-                    public void baz(int focus) throws java.io.IOException { throw new RuntimeException("Stub!"); }
-                    public boolean importance;
-                    }
-                    """
-        )
-    }
-
-    @Test
     fun `Annotation default values`() {
         checkStubs(
             compatibilityMode = false,
diff --git a/src/test/java/com/android/tools/metalava/model/psi/JavadocTest.kt b/src/test/java/com/android/tools/metalava/model/psi/JavadocTest.kt
index e7a0db7..8642575 100644
--- a/src/test/java/com/android/tools/metalava/model/psi/JavadocTest.kt
+++ b/src/test/java/com/android/tools/metalava/model/psi/JavadocTest.kt
@@ -16,11 +16,42 @@
 
 package com.android.tools.metalava.model.psi
 
+import com.android.tools.lint.checks.infrastructure.TestFile
+import com.android.tools.metalava.DriverTest
 import org.intellij.lang.annotations.Language
 import org.junit.Assert.assertEquals
 import org.junit.Test
 
-class JavadocTest {
+class JavadocTest : DriverTest() {
+    private fun checkStubs(
+        @Language("JAVA") source: String,
+        compatibilityMode: Boolean = true,
+        warnings: String? = "",
+        checkDoclava1: Boolean = false,
+        api: String? = null,
+        extraArguments: Array<String> = emptyArray(),
+        docStubs: Boolean = false,
+        showAnnotations: Array<String> = emptyArray(),
+        includeSourceRetentionAnnotations: Boolean = true,
+        skipEmitPackages: List<String> = listOf("java.lang", "java.util", "java.io"),
+        vararg sourceFiles: TestFile
+    ) {
+        check(
+            *sourceFiles,
+            showAnnotations = showAnnotations,
+            stubs = arrayOf(source),
+            compatibilityMode = compatibilityMode,
+            warnings = warnings,
+            checkDoclava1 = checkDoclava1,
+            checkCompilation = true,
+            api = api,
+            extraArguments = extraArguments,
+            docStubs = docStubs,
+            includeSourceRetentionAnnotations = includeSourceRetentionAnnotations,
+            skipEmitPackages = skipEmitPackages
+        )
+    }
+
     @Test
     fun `Test package to package info`() {
         @Language("HTML")
@@ -37,6 +68,7 @@
             </html>
             """
 
+        @Suppress("DanglingJavadoc")
         @Language("JAVA")
         val java = """
             /**
@@ -49,4 +81,842 @@
 
         assertEquals(java.trimIndent() + "\n", packageHtmlToJavadoc(html.trimIndent()))
     }
+
+    @Test
+    fun `Relative documentation links in stubs`() {
+        checkStubs(
+            docStubs = false,
+            checkDoclava1 = false,
+            sourceFiles =
+            *arrayOf(
+                java(
+                    """
+                    package test.pkg1;
+                    import java.io.IOException;
+                    import test.pkg2.OtherClass;
+
+                    /**
+                     *  Blah blah {@link OtherClass} blah blah.
+                     *  Referencing <b>field</b> {@link OtherClass#foo},
+                     *  and referencing method {@link OtherClass#bar(int,
+                     *   boolean)}.
+                     *  And relative method reference {@link #baz()}.
+                     *  And relative field reference {@link #importance}.
+                     *  Here's an already fully qualified reference: {@link test.pkg2.OtherClass}.
+                     *  And here's one in the same package: {@link LocalClass}.
+                     *
+                     *  @deprecated For some reason
+                     *  @see OtherClass
+                     *  @see OtherClass#bar(int, boolean)
+                     */
+                    @SuppressWarnings("all")
+                    public class SomeClass {
+                       /**
+                       * My method.
+                       * @param focus The focus to find. One of {@link OtherClass#FOCUS_INPUT} or
+                       *         {@link OtherClass#FOCUS_ACCESSIBILITY}.
+                       * @throws IOException when blah blah blah
+                       * @throws {@link RuntimeException} when blah blah blah
+                       */
+                       public void baz(int focus) throws IOException;
+                       public boolean importance;
+                    }
+                    """
+                ),
+                java(
+                    """
+                    package test.pkg2;
+
+                    @SuppressWarnings("all")
+                    public class OtherClass {
+                        public static final int FOCUS_INPUT = 1;
+                        public static final int FOCUS_ACCESSIBILITY = 2;
+                        public int foo;
+                        public void bar(int baz, boolean bar);
+                    }
+                    """
+                ),
+                java(
+                    """
+                    package test.pkg1;
+
+                    @SuppressWarnings("all")
+                    public class LocalClass {
+                    }
+                    """
+                )
+            ),
+            warnings = "",
+            source = """
+                    package test.pkg1;
+                    import test.pkg2.OtherClass;
+                    import java.io.IOException;
+                    /**
+                     *  Blah blah {@link OtherClass} blah blah.
+                     *  Referencing <b>field</b> {@link OtherClass#foo},
+                     *  and referencing method {@link OtherClass#bar(int,
+                     *   boolean)}.
+                     *  And relative method reference {@link #baz()}.
+                     *  And relative field reference {@link #importance}.
+                     *  Here's an already fully qualified reference: {@link test.pkg2.OtherClass}.
+                     *  And here's one in the same package: {@link LocalClass}.
+                     *
+                     *  @deprecated For some reason
+                     *  @see OtherClass
+                     *  @see OtherClass#bar(int, boolean)
+                     */
+                    @SuppressWarnings({"unchecked", "deprecation", "all"})
+                    @Deprecated
+                    public class SomeClass {
+                    public SomeClass() { throw new RuntimeException("Stub!"); }
+                    /**
+                     * My method.
+                     * @param focus The focus to find. One of {@link OtherClass#FOCUS_INPUT} or
+                     *         {@link OtherClass#FOCUS_ACCESSIBILITY}.
+                     * @throws IOException when blah blah blah
+                     * @throws {@link RuntimeException} when blah blah blah
+                     */
+                    public void baz(int focus) throws java.io.IOException { throw new RuntimeException("Stub!"); }
+                    public boolean importance;
+                    }
+                    """
+        )
+    }
+
+    @Test
+    fun `Rewrite relative documentation links in doc-stubs`() {
+        checkStubs(
+            docStubs = true,
+            checkDoclava1 = false,
+            sourceFiles =
+            *arrayOf(
+                java(
+                    """
+                    package test.pkg1;
+                    import java.io.IOException;
+                    import test.pkg2.OtherClass;
+
+                    /**
+                     *  Blah blah {@link OtherClass} blah blah.
+                     *  Referencing <b>field</b> {@link OtherClass#foo},
+                     *  and referencing method {@link OtherClass#bar(int,
+                     *   boolean)}.
+                     *  And relative method reference {@link #baz()}.
+                     *  And relative field reference {@link #importance}.
+                     *  Here's an already fully qualified reference: {@link test.pkg2.OtherClass}.
+                     *  And here's one in the same package: {@link LocalClass}.
+                     *
+                     *  @deprecated For some reason
+                     *  @see OtherClass
+                     *  @see OtherClass#bar(int, boolean)
+                     */
+                    @SuppressWarnings("all")
+                    public class SomeClass {
+                       /**
+                       * My method.
+                       * @param focus The focus to find. One of {@link OtherClass#FOCUS_INPUT} or
+                       *         {@link OtherClass#FOCUS_ACCESSIBILITY}.
+                       * @throws IOException when blah blah blah
+                       * @throws {@link RuntimeException} when blah blah blah
+                       */
+                       public void baz(int focus) throws IOException;
+                       public boolean importance;
+                    }
+                    """
+                ),
+                java(
+                    """
+                    package test.pkg2;
+
+                    @SuppressWarnings("all")
+                    public class OtherClass {
+                        public static final int FOCUS_INPUT = 1;
+                        public static final int FOCUS_ACCESSIBILITY = 2;
+                        public int foo;
+                        public void bar(int baz, boolean bar);
+                    }
+                    """
+                ),
+                java(
+                    """
+                    package test.pkg1;
+
+                    @SuppressWarnings("all")
+                    public class LocalClass {
+                    }
+                    """
+                )
+            ),
+            warnings = "",
+            source = """
+                package test.pkg1;
+                import test.pkg2.OtherClass;
+                import java.io.IOException;
+                /**
+                 *  Blah blah {@link test.pkg2.OtherClass OtherClass} blah blah.
+                 *  Referencing <b>field</b> {@link test.pkg2.OtherClass#foo OtherClass#foo},
+                 *  and referencing method {@link test.pkg2.OtherClass#bar(int,boolean) OtherClass#bar(int,
+                 *   boolean)}.
+                 *  And relative method reference {@link #baz()}.
+                 *  And relative field reference {@link #importance}.
+                 *  Here's an already fully qualified reference: {@link test.pkg2.OtherClass}.
+                 *  And here's one in the same package: {@link test.pkg1.LocalClass LocalClass}.
+                 *
+                 *  @deprecated For some reason
+                 *  @see test.pkg2.OtherClass
+                 *  @see test.pkg2.OtherClass#bar(int, boolean)
+                 */
+                @SuppressWarnings({"unchecked", "deprecation", "all"})
+                @Deprecated
+                public class SomeClass {
+                public SomeClass() { throw new RuntimeException("Stub!"); }
+                /**
+                 * My method.
+                 * @param focus The focus to find. One of {@link test.pkg2.OtherClass#FOCUS_INPUT OtherClass#FOCUS_INPUT} or
+                 *         {@link test.pkg2.OtherClass#FOCUS_ACCESSIBILITY OtherClass#FOCUS_ACCESSIBILITY}.
+                 * @throws java.io.IOException when blah blah blah
+                 * @throws {@link java.lang.RuntimeException RuntimeException} when blah blah blah
+                 */
+                public void baz(int focus) throws java.io.IOException { throw new RuntimeException("Stub!"); }
+                public boolean importance;
+                }
+                """
+        )
+    }
+
+    @Test
+    fun `Rewrite relative documentation links in doc-stubs 2`() {
+        // Properly handle links to inherited methods
+        checkStubs(
+            docStubs = true,
+            checkDoclava1 = false,
+            sourceFiles =
+            *arrayOf(
+                java(
+                    """
+                    package test.pkg1;
+                    import java.io.IOException;
+                    import test.pkg2.OtherClass;
+
+                    @SuppressWarnings("all")
+                    public class R {
+                        public static class attr {
+                            /**
+                             * Resource identifier to assign to this piece of named meta-data.
+                             * The resource identifier can later be retrieved from the meta data
+                             * Bundle through {@link android.os.Bundle#getInt Bundle.getInt}.
+                             * <p>May be a reference to another resource, in the form
+                             * "<code>@[+][<i>package</i>:]<i>type</i>/<i>name</i></code>" or a theme
+                             * attribute in the form
+                             * "<code>?[<i>package</i>:]<i>type</i>/<i>name</i></code>".
+                             */
+                            public static final int resource=0x01010025;
+                        }
+                    }
+                    """
+                ),
+                java(
+                    """
+                    package android.os;
+
+                    @SuppressWarnings("all")
+                    public class Bundle extends BaseBundle {
+                    }
+                    """
+                ),
+                java(
+                    """
+                    package android.os;
+
+                    @SuppressWarnings("all")
+                    public class BaseBundle {
+                        public int getInt(String key) {
+                            return getInt(key, 0);
+                        }
+
+                        public int getInt(String key, int defaultValue) {
+                            return defaultValue;
+                        }
+                    }
+                    """
+                )
+            ),
+            warnings = "",
+            source = """
+                package test.pkg1;
+                @SuppressWarnings({"unchecked", "deprecation", "all"})
+                public class R {
+                public R() { throw new RuntimeException("Stub!"); }
+                @SuppressWarnings({"unchecked", "deprecation", "all"})
+                public static class attr {
+                public attr() { throw new RuntimeException("Stub!"); }
+                /**
+                 * Resource identifier to assign to this piece of named meta-data.
+                 * The resource identifier can later be retrieved from the meta data
+                 * Bundle through {@link android.os.Bundle#getInt Bundle.getInt}.
+                 * <p>May be a reference to another resource, in the form
+                 * "<code>@[+][<i>package</i>:]<i>type</i>/<i>name</i></code>" or a theme
+                 * attribute in the form
+                 * "<code>?[<i>package</i>:]<i>type</i>/<i>name</i></code>".
+                 */
+                public static final int resource = 16842789; // 0x1010025
+                }
+                }
+                """
+        )
+    }
+
+    @Test
+    fun `Rewrite relative documentation links in doc-stubs 3`() {
+        checkStubs(
+            docStubs = true,
+            checkDoclava1 = false,
+            sourceFiles =
+            *arrayOf(
+                java(
+                    """
+                    package android.accessibilityservice;
+
+                    import android.view.accessibility.AccessibilityEvent;
+
+                    /**
+                     * <p>
+                     * Window content may be retrieved with
+                     * {@link AccessibilityEvent#getSource() AccessibilityEvent.getSource()},
+                     * </p>
+                     */
+                    @SuppressWarnings("all")
+                    public abstract class AccessibilityService {
+                    }
+                    """
+                ),
+                java(
+                    """
+                    package android.view.accessibility;
+
+                    @SuppressWarnings("all")
+                    public final class AccessibilityEvent extends AccessibilityRecord {
+                    }
+                    """
+                ),
+                java(
+                    """
+                    package android.view.accessibility;
+
+                    @SuppressWarnings("all")
+                    public class AccessibilityRecord {
+                        public AccessibilityNodeInfo getSource() {
+                            return null;
+                        }
+                    }
+                    """
+                )
+            ),
+            warnings = "",
+            source = """
+                package android.accessibilityservice;
+                import android.view.accessibility.AccessibilityEvent;
+                /**
+                 * <p>
+                 * Window content may be retrieved with
+                 * {@link android.view.accessibility.AccessibilityEvent#getSource() AccessibilityEvent#getSource()},
+                 * </p>
+                 */
+                @SuppressWarnings({"unchecked", "deprecation", "all"})
+                public abstract class AccessibilityService {
+                public AccessibilityService() { throw new RuntimeException("Stub!"); }
+                }
+                """
+        )
+    }
+
+    @Test
+    fun `Rewrite relative documentation links in doc-stubs 4`() {
+        checkStubs(
+            docStubs = true,
+            checkDoclava1 = false,
+            sourceFiles =
+            *arrayOf(
+                java(
+                    """
+                    package android.content;
+
+                    import android.os.OperationCanceledException;
+
+                    @SuppressWarnings("all")
+                    public abstract class AsyncTaskLoader {
+                        /**
+                         * Called if the task was canceled before it was completed.  Gives the class a chance
+                         * to clean up post-cancellation and to properly dispose of the result.
+                         *
+                         * @param data The value that was returned by {@link #loadInBackground}, or null
+                         * if the task threw {@link OperationCanceledException}.
+                         */
+                        public void onCanceled(D data) {
+                        }
+
+                        /**
+                         * Called on a worker thread to perform the actual load and to return
+                         * the result of the load operation.
+                         *
+                         * Implementations should not deliver the result directly, but should return them
+                         * from this method, which will eventually end up calling {@link #deliverResult} on
+                         * the UI thread.  If implementations need to process the results on the UI thread
+                         * they may override {@link #deliverResult} and do so there.
+                         *
+                         * When the load is canceled, this method may either return normally or throw
+                         * {@link OperationCanceledException}.  In either case, the Loader will
+                         * call {@link #onCanceled} to perform post-cancellation cleanup and to dispose of the
+                         * result object, if any.
+                         *
+                         * @return The result of the load operation.
+                         *
+                         * @throws OperationCanceledException if the load is canceled during execution.
+                         *
+                         * @see #onCanceled
+                         */
+                        public abstract Object loadInBackground();
+
+                        /**
+                         * Sends the result of the load to the registered listener. Should only be called by subclasses.
+                         *
+                         * Must be called from the process's main thread.
+                         *
+                         * @param data the result of the load
+                         */
+                        public void deliverResult(Object data) {
+                        }
+                    }
+                    """
+                ),
+                java(
+                    """
+                    package android.os;
+
+
+                    /**
+                     * An exception type that is thrown when an operation in progress is canceled.
+                     */
+                    @SuppressWarnings("all")
+                    public class OperationCanceledException extends RuntimeException {
+                        public OperationCanceledException() {
+                            this(null);
+                        }
+
+                        public OperationCanceledException(String message) {
+                            super(message != null ? message : "The operation has been canceled.");
+                        }
+                    }
+                    """
+                )
+            ),
+            warnings = "",
+            source = """
+                package android.content;
+                import android.os.OperationCanceledException;
+                @SuppressWarnings({"unchecked", "deprecation", "all"})
+                public abstract class AsyncTaskLoader {
+                public AsyncTaskLoader() { throw new RuntimeException("Stub!"); }
+                /**
+                 * Called if the task was canceled before it was completed.  Gives the class a chance
+                 * to clean up post-cancellation and to properly dispose of the result.
+                 *
+                 * @param data The value that was returned by {@link #loadInBackground}, or null
+                 * if the task threw {@link android.os.OperationCanceledException OperationCanceledException}.
+                 */
+                public void onCanceled(D data) { throw new RuntimeException("Stub!"); }
+                /**
+                 * Called on a worker thread to perform the actual load and to return
+                 * the result of the load operation.
+                 *
+                 * Implementations should not deliver the result directly, but should return them
+                 * from this method, which will eventually end up calling {@link #deliverResult} on
+                 * the UI thread.  If implementations need to process the results on the UI thread
+                 * they may override {@link #deliverResult} and do so there.
+                 *
+                 * When the load is canceled, this method may either return normally or throw
+                 * {@link android.os.OperationCanceledException OperationCanceledException}.  In either case, the Loader will
+                 * call {@link #onCanceled} to perform post-cancellation cleanup and to dispose of the
+                 * result object, if any.
+                 *
+                 * @return The result of the load operation.
+                 *
+                 * @throws android.os.OperationCanceledException if the load is canceled during execution.
+                 *
+                 * @see #onCanceled
+                 */
+                public abstract java.lang.Object loadInBackground();
+                /**
+                 * Sends the result of the load to the registered listener. Should only be called by subclasses.
+                 *
+                 * Must be called from the process's main thread.
+                 *
+                 * @param data the result of the load
+                 */
+                public void deliverResult(java.lang.Object data) { throw new RuntimeException("Stub!"); }
+                }
+                """
+        )
+    }
+
+    @Test
+    fun `Rewrite relative documentation links in doc-stubs 5`() {
+        // Properly handle links to inherited methods
+        checkStubs(
+            docStubs = true,
+            checkDoclava1 = false,
+            sourceFiles =
+            *arrayOf(
+                java(
+                    """
+                    package org.xmlpull.v1;
+
+                    /**
+                     * Example docs.
+                     * <pre>
+                     * import org.xmlpull.v1.<a href="XmlPullParserException.html">XmlPullParserException</a>;
+                     *         xpp.<a href="#setInput">setInput</a>( new StringReader ( "&lt;foo>Hello World!&lt;/foo>" ) );
+                     * </pre>
+                     * see #setInput
+                     */
+                    @SuppressWarnings("all")
+                    public interface XmlPullParser {
+                        void setInput();
+                    }
+                    """
+                )
+            ),
+            warnings = "",
+            source = """
+                package org.xmlpull.v1;
+                /**
+                 * Example docs.
+                 * <pre>
+                 * import org.xmlpull.v1.<a href="XmlPullParserException.html">XmlPullParserException</a>;
+                 *         xpp.<a href="#setInput">setInput</a>( new StringReader ( "&lt;foo>Hello World!&lt;/foo>" ) );
+                 * </pre>
+                 * see #setInput
+                 */
+                @SuppressWarnings({"unchecked", "deprecation", "all"})
+                public interface XmlPullParser {
+                public void setInput();
+                }
+                """
+        )
+    }
+
+    @Test
+    fun `Check references to inherited field constants`() {
+        checkStubs(
+            docStubs = true,
+            compatibilityMode = false,
+            checkDoclava1 = false,
+            warnings = "",
+            sourceFiles =
+            *arrayOf(
+                java(
+                    """
+                    package test.pkg1;
+                    import test.pkg2.MyChild;
+
+                    /**
+                     * Reference to {@link MyChild#CONSTANT1},
+                     * {@link MyChild#CONSTANT2}, and
+                     * {@link MyChild#myMethod}.
+                     * <p>
+                     * Absolute reference:
+                     * {@link test.pkg2.MyChild#CONSTANT1 MyChild.CONSTANT1}
+                     * <p>
+                     * Inner class reference:
+                     * {@link Test.TestInner#CONSTANT3}, again
+                     * {@link TestInner#CONSTANT3}
+                     *
+                     * @see test.pkg2.MyChild#myMethod
+                     */
+                    @SuppressWarnings("all")
+                    public class Test {
+                        public static class TestInner {
+                            public static final String CONSTANT3 = "Hello";
+                        }
+                    }
+                    """
+                ),
+                java(
+                    """
+                    package test.pkg1;
+                    @SuppressWarnings("all")
+                    interface MyConstants {
+                        long CONSTANT1 = 12345;
+                    }
+                    """
+                ),
+                java(
+                    """
+                    package test.pkg1;
+                    import java.io.Closeable;
+                    @SuppressWarnings("all")
+                    class MyParent implements MyConstants, Closeable {
+                        public static final long CONSTANT2 = 67890;
+                        public void myMethod() {
+                        }
+                    }
+                    """
+                ),
+                java(
+                    """
+                    package test.pkg2;
+
+                    import test.pkg1.MyParent;
+                    @SuppressWarnings("all")
+                    public class MyChild extends MyParent implements MyConstants {
+                    }
+                    """
+                )
+            ),
+            source = """
+                package test.pkg1;
+                import test.pkg2.MyChild;
+                /**
+                 * Reference to {@link test.pkg2.MyChild#CONSTANT1 MyChild#CONSTANT1},
+                 * {@link test.pkg2.MyChild#CONSTANT2 MyChild#CONSTANT2}, and
+                 * {@link test.pkg2.MyChild#myMethod MyChild#myMethod}.
+                 * <p>
+                 * Absolute reference:
+                 * {@link test.pkg2.MyChild#CONSTANT1 MyChild.CONSTANT1}
+                 * <p>
+                 * Inner class reference:
+                 * {@link test.pkg1.Test.TestInner#CONSTANT3 Test.TestInner#CONSTANT3}, again
+                 * {@link test.pkg1.Test.TestInner#CONSTANT3 TestInner#CONSTANT3}
+                 *
+                 * @see test.pkg2.MyChild#myMethod
+                 */
+                @SuppressWarnings({"unchecked", "deprecation", "all"})
+                public class Test {
+                public Test() { throw new RuntimeException("Stub!"); }
+                @SuppressWarnings({"unchecked", "deprecation", "all"})
+                public static class TestInner {
+                public TestInner() { throw new RuntimeException("Stub!"); }
+                public static final java.lang.String CONSTANT3 = "Hello";
+                }
+                }
+                """
+        )
+    }
+
+    @Test
+    fun `Handle @attr references`() {
+        checkStubs(
+            docStubs = true,
+            compatibilityMode = false,
+            checkDoclava1 = false,
+            warnings = "",
+            sourceFiles =
+            *arrayOf(
+                java(
+                    """
+                    package test.pkg1;
+
+                    @SuppressWarnings("all")
+                    public class Test {
+                        /**
+                         * Returns the drawable that will be drawn between each item in the list.
+                         *
+                         * @return the current drawable drawn between list elements
+                         * This value may be {@code null}.
+                         * @attr ref R.styleable#ListView_divider
+                         */
+                        public Object getFoo() {
+                            return null;
+                        }
+                    }
+                    """
+                )
+            ),
+            source = """
+                package test.pkg1;
+                @SuppressWarnings({"unchecked", "deprecation", "all"})
+                public class Test {
+                public Test() { throw new RuntimeException("Stub!"); }
+                /**
+                 * Returns the drawable that will be drawn between each item in the list.
+                 *
+                 * @return the current drawable drawn between list elements
+                 * This value may be {@code null}.
+                 * @attr ref android.R.styleable#ListView_divider
+                 */
+                public java.lang.Object getFoo() { throw new RuntimeException("Stub!"); }
+                }
+                """
+        )
+    }
+
+    @Test
+    fun `Rewrite parameter list`() {
+        checkStubs(
+            docStubs = true,
+            compatibilityMode = false,
+            checkDoclava1 = false,
+            warnings = "",
+            sourceFiles =
+            *arrayOf(
+                java(
+                    """
+                    package test.pkg1;
+                    import test.pkg2.OtherClass1;
+                    import test.pkg2.OtherClass2;
+
+                    /**
+                     * Reference to {@link OtherClass1#myMethod(OtherClass2, int name, OtherClass2[])},
+                     */
+                    @SuppressWarnings("all")
+                    public class Test<E extends OtherClass2> {
+                        /**
+                         * Reference to {@link OtherClass1#myMethod(E, int, OtherClass2 [])},
+                         */
+                        public void test() { }
+                    }
+                    """
+                ),
+                java(
+                    """
+                    package test.pkg2;
+
+                    @SuppressWarnings("all")
+                    class OtherClass1 {
+                        public void myMethod(OtherClass2 parameter1, int parameter2, OtherClass2[] parameter3) {
+                        }
+                    }
+                    """
+                ),
+                java(
+                    """
+                    package test.pkg2;
+
+                    @SuppressWarnings("all")
+                    public class OtherClass2 {
+                    }
+                    """
+                )
+            ),
+            source = """
+                package test.pkg1;
+                import test.pkg2.OtherClass2;
+                /**
+                 * Reference to {@link test.pkg2.OtherClass1#myMethod(test.pkg2.OtherClass2,int name,test.pkg2.OtherClass2[]) OtherClass1#myMethod(OtherClass2, int name, OtherClass2[])},
+                 */
+                @SuppressWarnings({"unchecked", "deprecation", "all"})
+                public class Test<E extends test.pkg2.OtherClass2> {
+                public Test() { throw new RuntimeException("Stub!"); }
+                /**
+                 * Reference to {@link test.pkg2.OtherClass1#myMethod(E,int,test.pkg2.OtherClass2[]) OtherClass1#myMethod(E, int, OtherClass2 [])},
+                 */
+                public void test() { throw new RuntimeException("Stub!"); }
+                }
+                """
+        )
+    }
+
+    @Test
+    fun `Rewrite parameter list 2`() {
+        checkStubs(
+            docStubs = true,
+            compatibilityMode = false,
+            checkDoclava1 = false,
+            warnings = "",
+            sourceFiles =
+            *arrayOf(
+                java(
+                    """
+                    package test.pkg1;
+                    import java.nio.ByteBuffer;
+
+                    @SuppressWarnings("all")
+                    public class Test {
+                        /**
+                         * Blah blah
+                         * <blockquote><pre>
+                         * {@link #wrap(ByteBuffer [], int, int, ByteBuffer)
+                         *     engine.wrap(new ByteBuffer [] { src }, 0, 1, dst);}
+                         * </pre></blockquote>
+                         */
+                        public void test() { }
+
+                        public abstract void wrap(ByteBuffer [] srcs, int offset,
+                            int length, ByteBuffer dst);
+                    }
+                    """
+                )
+            ),
+            source = """
+                package test.pkg1;
+                import java.nio.ByteBuffer;
+                @SuppressWarnings({"unchecked", "deprecation", "all"})
+                public class Test {
+                public Test() { throw new RuntimeException("Stub!"); }
+                /**
+                 * Blah blah
+                 * <blockquote><pre>
+                 * {@link #wrap(java.nio.ByteBuffer[],int,int,java.nio.ByteBuffer)
+                 *     engine.wrap(new ByteBuffer [] { src }, 0, 1, dst);}
+                 * </pre></blockquote>
+                 */
+                public void test() { throw new RuntimeException("Stub!"); }
+                public abstract void wrap(java.nio.ByteBuffer[] srcs, int offset, int length, java.nio.ByteBuffer dst);
+                }
+                """
+        )
+    }
+
+    @Test
+    fun `Warn about unresolved`() {
+        @Suppress("ConstantConditionIf")
+        checkStubs(
+            docStubs = true,
+            compatibilityMode = false,
+            checkDoclava1 = false,
+            warnings =
+            if (REPORT_UNRESOLVED_SYMBOLS) {
+                """
+                src/test/pkg1/Test.java:6: lint: Unresolved documentation reference: SomethingMissing [UnresolvedLink:101]
+                src/test/pkg1/Test.java:6: lint: Unresolved documentation reference: OtherMissing [UnresolvedLink:101]
+            """
+            } else {
+                ""
+            },
+            sourceFiles =
+            *arrayOf(
+                java(
+                    """
+                    package test.pkg1;
+                    import java.nio.ByteBuffer;
+
+                    @SuppressWarnings("all")
+                    public class Test {
+                        /**
+                         * Reference to {@link SomethingMissing} and
+                         * {@link String#randomMethod}.
+                         *
+                         * @see OtherMissing
+                         */
+                        public void test() { }
+                    }
+                    """
+                )
+            ),
+            source = """
+                package test.pkg1;
+                @SuppressWarnings({"unchecked", "deprecation", "all"})
+                public class Test {
+                public Test() { throw new RuntimeException("Stub!"); }
+                /**
+                 * Reference to {@link SomethingMissing} and
+                 * {@link java.lang.String#randomMethod String#randomMethod}.
+                 *
+                 * @see OtherMissing
+                 */
+                public void test() { throw new RuntimeException("Stub!"); }
+                }
+                """
+        )
+    }
 }
\ No newline at end of file
diff --git a/src/test/java/com/android/tools/metalava/model/psi/PsiBasedCodebaseTest.kt b/src/test/java/com/android/tools/metalava/model/psi/PsiBasedCodebaseTest.kt
index 38fa85f..82e092e 100644
--- a/src/test/java/com/android/tools/metalava/model/psi/PsiBasedCodebaseTest.kt
+++ b/src/test/java/com/android/tools/metalava/model/psi/PsiBasedCodebaseTest.kt
@@ -25,7 +25,7 @@
     fun `Regression test for issue 112931426`() {
         check(
             sourceFiles = *arrayOf(
-                DriverTest.java(
+                java(
                     """
                     package test.pkg2;
                     public class Foo {