blob: 0554807b4906bf08d408947b5c60345ff295e25b [file] [log] [blame]
////////////////////////////////////////////////////////////////////////////////
// checkstyle: Checks Java source code for adherence to a set of rules.
// Copyright (C) 2001-2017 the original author or authors.
//
// This library is free software; you can redistribute it and/or
// modify it under the terms of the GNU Lesser General Public
// License as published by the Free Software Foundation; either
// version 2.1 of the License, or (at your option) any later version.
//
// This library is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
// Lesser General Public License for more details.
//
// You should have received a copy of the GNU Lesser General Public
// License along with this library; if not, write to the Free Software
// Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
////////////////////////////////////////////////////////////////////////////////
package com.puppycrawl.tools.checkstyle.checks.javadoc;
import java.util.ArrayDeque;
import java.util.Arrays;
import java.util.Collections;
import java.util.Deque;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.TreeSet;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import com.puppycrawl.tools.checkstyle.JavadocDetailNodeParser;
import com.puppycrawl.tools.checkstyle.StatelessCheck;
import com.puppycrawl.tools.checkstyle.api.AbstractCheck;
import com.puppycrawl.tools.checkstyle.api.DetailAST;
import com.puppycrawl.tools.checkstyle.api.FileContents;
import com.puppycrawl.tools.checkstyle.api.Scope;
import com.puppycrawl.tools.checkstyle.api.TextBlock;
import com.puppycrawl.tools.checkstyle.api.TokenTypes;
import com.puppycrawl.tools.checkstyle.utils.CheckUtils;
import com.puppycrawl.tools.checkstyle.utils.CommonUtils;
import com.puppycrawl.tools.checkstyle.utils.ScopeUtils;
/**
* Custom Checkstyle Check to validate Javadoc.
*
* @author Chris Stillwell
* @author Daniel Grenner
* @author Travis Schneeberger
*/
@StatelessCheck
public class JavadocStyleCheck
extends AbstractCheck {
/** Message property key for the Unclosed HTML message. */
public static final String MSG_JAVADOC_MISSING = "javadoc.missing";
/** Message property key for the Unclosed HTML message. */
public static final String MSG_EMPTY = "javadoc.empty";
/** Message property key for the Unclosed HTML message. */
public static final String MSG_NO_PERIOD = "javadoc.noPeriod";
/** Message property key for the Unclosed HTML message. */
public static final String MSG_INCOMPLETE_TAG = "javadoc.incompleteTag";
/** Message property key for the Unclosed HTML message. */
public static final String MSG_UNCLOSED_HTML = JavadocDetailNodeParser.MSG_UNCLOSED_HTML_TAG;
/** Message property key for the Extra HTML message. */
public static final String MSG_EXTRA_HTML = "javadoc.extraHtml";
/** HTML tags that do not require a close tag. */
private static final Set<String> SINGLE_TAGS = Collections.unmodifiableSortedSet(
Arrays.stream(new String[] {"br", "li", "dt", "dd", "hr", "img", "p", "td", "tr", "th", })
.collect(Collectors.toCollection(TreeSet::new)));
/** HTML tags that are allowed in java docs.
* From https://www.w3schools.com/tags/default.asp
* The forms and structure tags are not allowed
*/
private static final Set<String> ALLOWED_TAGS = Collections.unmodifiableSortedSet(
Arrays.stream(new String[] {
"a", "abbr", "acronym", "address", "area", "b", "bdo", "big",
"blockquote", "br", "caption", "cite", "code", "colgroup", "dd",
"del", "div", "dfn", "dl", "dt", "em", "fieldset", "font", "h1",
"h2", "h3", "h4", "h5", "h6", "hr", "i", "img", "ins", "kbd",
"li", "ol", "p", "pre", "q", "samp", "small", "span", "strong",
"style", "sub", "sup", "table", "tbody", "td", "tfoot", "th",
"thead", "tr", "tt", "u", "ul", "var", })
.collect(Collectors.toCollection(TreeSet::new)));
/** The scope to check. */
private Scope scope = Scope.PRIVATE;
/** The visibility scope where Javadoc comments shouldn't be checked. **/
private Scope excludeScope;
/** Format for matching the end of a sentence. */
private Pattern endOfSentenceFormat = Pattern.compile("([.?!][ \t\n\r\f<])|([.?!]$)");
/**
* Indicates if the first sentence should be checked for proper end of
* sentence punctuation.
*/
private boolean checkFirstSentence = true;
/**
* Indicates if the HTML within the comment should be checked.
*/
private boolean checkHtml = true;
/**
* Indicates if empty javadoc statements should be checked.
*/
private boolean checkEmptyJavadoc;
@Override
public int[] getDefaultTokens() {
return getAcceptableTokens();
}
@Override
public int[] getAcceptableTokens() {
return new int[] {
TokenTypes.ANNOTATION_DEF,
TokenTypes.ANNOTATION_FIELD_DEF,
TokenTypes.CLASS_DEF,
TokenTypes.CTOR_DEF,
TokenTypes.ENUM_CONSTANT_DEF,
TokenTypes.ENUM_DEF,
TokenTypes.INTERFACE_DEF,
TokenTypes.METHOD_DEF,
TokenTypes.PACKAGE_DEF,
TokenTypes.VARIABLE_DEF,
};
}
@Override
public int[] getRequiredTokens() {
return CommonUtils.EMPTY_INT_ARRAY;
}
@Override
public void visitToken(DetailAST ast) {
if (shouldCheck(ast)) {
final FileContents contents = getFileContents();
// Need to start searching for the comment before the annotations
// that may exist. Even if annotations are not defined on the
// package, the ANNOTATIONS AST is defined.
final TextBlock textBlock =
contents.getJavadocBefore(ast.getFirstChild().getLineNo());
checkComment(ast, textBlock);
}
}
/**
* Whether we should check this node.
* @param ast a given node.
* @return whether we should check a given node.
*/
private boolean shouldCheck(final DetailAST ast) {
boolean check = false;
if (ast.getType() == TokenTypes.PACKAGE_DEF) {
check = getFileContents().inPackageInfo();
}
else if (!ScopeUtils.isInCodeBlock(ast)) {
final Scope customScope;
if (ScopeUtils.isInInterfaceOrAnnotationBlock(ast)
|| ast.getType() == TokenTypes.ENUM_CONSTANT_DEF) {
customScope = Scope.PUBLIC;
}
else {
customScope = ScopeUtils.getScopeFromMods(ast.findFirstToken(TokenTypes.MODIFIERS));
}
final Scope surroundingScope = ScopeUtils.getSurroundingScope(ast);
check = customScope.isIn(scope)
&& (surroundingScope == null || surroundingScope.isIn(scope))
&& (excludeScope == null
|| !customScope.isIn(excludeScope)
|| surroundingScope != null
&& !surroundingScope.isIn(excludeScope));
}
return check;
}
/**
* Performs the various checks against the Javadoc comment.
*
* @param ast the AST of the element being documented
* @param comment the source lines that make up the Javadoc comment.
*
* @see #checkFirstSentenceEnding(DetailAST, TextBlock)
* @see #checkHtmlTags(DetailAST, TextBlock)
*/
private void checkComment(final DetailAST ast, final TextBlock comment) {
if (comment == null) {
// checking for missing docs in JavadocStyleCheck is not consistent
// with the rest of CheckStyle... Even though, I didn't think it
// made sense to make another check just to ensure that the
// package-info.java file actually contains package Javadocs.
if (getFileContents().inPackageInfo()) {
log(ast.getLineNo(), MSG_JAVADOC_MISSING);
}
}
else {
if (checkFirstSentence) {
checkFirstSentenceEnding(ast, comment);
}
if (checkHtml) {
checkHtmlTags(ast, comment);
}
if (checkEmptyJavadoc) {
checkJavadocIsNotEmpty(comment);
}
}
}
/**
* Checks that the first sentence ends with proper punctuation. This method
* uses a regular expression that checks for the presence of a period,
* question mark, or exclamation mark followed either by whitespace, an
* HTML element, or the end of string. This method ignores {_AT_inheritDoc}
* comments for TokenTypes that are valid for {_AT_inheritDoc}.
*
* @param ast the current node
* @param comment the source lines that make up the Javadoc comment.
*/
private void checkFirstSentenceEnding(final DetailAST ast, TextBlock comment) {
final String commentText = getCommentText(comment.getText());
if (!commentText.isEmpty()
&& !endOfSentenceFormat.matcher(commentText).find()
&& !(commentText.startsWith("{@inheritDoc}")
&& JavadocTagInfo.INHERIT_DOC.isValidOn(ast))) {
log(comment.getStartLineNo(), MSG_NO_PERIOD);
}
}
/**
* Checks that the Javadoc is not empty.
*
* @param comment the source lines that make up the Javadoc comment.
*/
private void checkJavadocIsNotEmpty(TextBlock comment) {
final String commentText = getCommentText(comment.getText());
if (commentText.isEmpty()) {
log(comment.getStartLineNo(), MSG_EMPTY);
}
}
/**
* Returns the comment text from the Javadoc.
* @param comments the lines of Javadoc.
* @return a comment text String.
*/
private static String getCommentText(String... comments) {
final StringBuilder builder = new StringBuilder(1024);
for (final String line : comments) {
final int textStart = findTextStart(line);
if (textStart != -1) {
if (line.charAt(textStart) == '@') {
//we have found the tag section
break;
}
builder.append(line.substring(textStart));
trimTail(builder);
builder.append('\n');
}
}
return builder.toString().trim();
}
/**
* Finds the index of the first non-whitespace character ignoring the
* Javadoc comment start and end strings (&#47** and *&#47) as well as any
* leading asterisk.
* @param line the Javadoc comment line of text to scan.
* @return the int index relative to 0 for the start of text
* or -1 if not found.
*/
private static int findTextStart(String line) {
int textStart = -1;
int index = 0;
while (index < line.length()) {
if (!Character.isWhitespace(line.charAt(index))) {
if (line.regionMatches(index, "/**", 0, "/**".length())) {
index += 2;
}
else if (line.regionMatches(index, "*/", 0, 2)) {
index++;
}
else if (line.charAt(index) != '*') {
textStart = index;
break;
}
}
index++;
}
return textStart;
}
/**
* Trims any trailing whitespace or the end of Javadoc comment string.
* @param builder the StringBuilder to trim.
*/
private static void trimTail(StringBuilder builder) {
int index = builder.length() - 1;
while (true) {
if (Character.isWhitespace(builder.charAt(index))) {
builder.deleteCharAt(index);
}
else if (index > 0 && builder.charAt(index) == '/'
&& builder.charAt(index - 1) == '*') {
builder.deleteCharAt(index);
builder.deleteCharAt(index - 1);
index--;
while (builder.charAt(index - 1) == '*') {
builder.deleteCharAt(index - 1);
index--;
}
}
else {
break;
}
index--;
}
}
/**
* Checks the comment for HTML tags that do not have a corresponding close
* tag or a close tag that has no previous open tag. This code was
* primarily copied from the DocCheck checkHtml method.
*
* @param ast the node with the Javadoc
* @param comment the {@code TextBlock} which represents
* the Javadoc comment.
* @noinspection MethodWithMultipleReturnPoints
*/
// -@cs[ReturnCount] Too complex to break apart.
private void checkHtmlTags(final DetailAST ast, final TextBlock comment) {
final int lineNo = comment.getStartLineNo();
final Deque<HtmlTag> htmlStack = new ArrayDeque<>();
final String[] text = comment.getText();
final TagParser parser = new TagParser(text, lineNo);
while (parser.hasNextTag()) {
final HtmlTag tag = parser.nextTag();
if (tag.isIncompleteTag()) {
log(tag.getLineNo(), MSG_INCOMPLETE_TAG,
text[tag.getLineNo() - lineNo]);
return;
}
if (tag.isClosedTag()) {
//do nothing
continue;
}
if (tag.isCloseTag()) {
// We have found a close tag.
if (isExtraHtml(tag.getId(), htmlStack)) {
// No corresponding open tag was found on the stack.
log(tag.getLineNo(),
tag.getPosition(),
MSG_EXTRA_HTML,
tag.getText());
}
else {
// See if there are any unclosed tags that were opened
// after this one.
checkUnclosedTags(htmlStack, tag.getId());
}
}
else {
//We only push html tags that are allowed
if (isAllowedTag(tag)) {
htmlStack.push(tag);
}
}
}
// Identify any tags left on the stack.
// Skip multiples, like <b>...<b>
String lastFound = "";
final List<String> typeParameters = CheckUtils.getTypeParameterNames(ast);
for (final HtmlTag htmlTag : htmlStack) {
if (!isSingleTag(htmlTag)
&& !htmlTag.getId().equals(lastFound)
&& !typeParameters.contains(htmlTag.getId())) {
log(htmlTag.getLineNo(), htmlTag.getPosition(),
MSG_UNCLOSED_HTML, htmlTag.getText());
lastFound = htmlTag.getId();
}
}
}
/**
* Checks to see if there are any unclosed tags on the stack. The token
* represents a html tag that has been closed and has a corresponding open
* tag on the stack. Any tags, except single tags, that were opened
* (pushed on the stack) after the token are missing a close.
*
* @param htmlStack the stack of opened HTML tags.
* @param token the current HTML tag name that has been closed.
*/
private void checkUnclosedTags(Deque<HtmlTag> htmlStack, String token) {
final Deque<HtmlTag> unclosedTags = new ArrayDeque<>();
HtmlTag lastOpenTag = htmlStack.pop();
while (!token.equalsIgnoreCase(lastOpenTag.getId())) {
// Find unclosed elements. Put them on a stack so the
// output order won't be back-to-front.
if (isSingleTag(lastOpenTag)) {
lastOpenTag = htmlStack.pop();
}
else {
unclosedTags.push(lastOpenTag);
lastOpenTag = htmlStack.pop();
}
}
// Output the unterminated tags, if any
// Skip multiples, like <b>..<b>
String lastFound = "";
for (final HtmlTag htag : unclosedTags) {
lastOpenTag = htag;
if (lastOpenTag.getId().equals(lastFound)) {
continue;
}
lastFound = lastOpenTag.getId();
log(lastOpenTag.getLineNo(),
lastOpenTag.getPosition(),
MSG_UNCLOSED_HTML,
lastOpenTag.getText());
}
}
/**
* Determines if the HtmlTag is one which does not require a close tag.
*
* @param tag the HtmlTag to check.
* @return {@code true} if the HtmlTag is a single tag.
*/
private static boolean isSingleTag(HtmlTag tag) {
// If its a singleton tag (<p>, <br>, etc.), ignore it
// Can't simply not put them on the stack, since singletons
// like <dt> and <dd> (unhappily) may either be terminated
// or not terminated. Both options are legal.
return SINGLE_TAGS.contains(tag.getId().toLowerCase(Locale.ENGLISH));
}
/**
* Determines if the HtmlTag is one which is allowed in a javadoc.
*
* @param tag the HtmlTag to check.
* @return {@code true} if the HtmlTag is an allowed html tag.
*/
private static boolean isAllowedTag(HtmlTag tag) {
return ALLOWED_TAGS.contains(tag.getId().toLowerCase(Locale.ENGLISH));
}
/**
* Determines if the given token is an extra HTML tag. This indicates that
* a close tag was found that does not have a corresponding open tag.
*
* @param token an HTML tag id for which a close was found.
* @param htmlStack a Stack of previous open HTML tags.
* @return {@code false} if a previous open tag was found
* for the token.
*/
private static boolean isExtraHtml(String token, Deque<HtmlTag> htmlStack) {
boolean isExtra = true;
for (final HtmlTag tag : htmlStack) {
// Loop, looking for tags that are closed.
// The loop is needed in case there are unclosed
// tags on the stack. In that case, the stack would
// not be empty, but this tag would still be extra.
if (token.equalsIgnoreCase(tag.getId())) {
isExtra = false;
break;
}
}
return isExtra;
}
/**
* Sets the scope to check.
* @param scope a scope.
*/
public void setScope(Scope scope) {
this.scope = scope;
}
/**
* Set the excludeScope.
* @param excludeScope a scope.
*/
public void setExcludeScope(Scope excludeScope) {
this.excludeScope = excludeScope;
}
/**
* Set the format for matching the end of a sentence.
* @param pattern a pattern.
*/
public void setEndOfSentenceFormat(Pattern pattern) {
endOfSentenceFormat = pattern;
}
/**
* Sets the flag that determines if the first sentence is checked for
* proper end of sentence punctuation.
* @param flag {@code true} if the first sentence is to be checked
*/
public void setCheckFirstSentence(boolean flag) {
checkFirstSentence = flag;
}
/**
* Sets the flag that determines if HTML checking is to be performed.
* @param flag {@code true} if HTML checking is to be performed.
*/
public void setCheckHtml(boolean flag) {
checkHtml = flag;
}
/**
* Sets the flag that determines if empty Javadoc checking should be done.
* @param flag {@code true} if empty Javadoc checking should be done.
*/
public void setCheckEmptyJavadoc(boolean flag) {
checkEmptyJavadoc = flag;
}
}