001/////////////////////////////////////////////////////////////////////////////////////////////// 002// checkstyle: Checks Java source code and other text files for adherence to a set of rules. 003// Copyright (C) 2001-2026 the original author or authors. 004// 005// This library is free software; you can redistribute it and/or 006// modify it under the terms of the GNU Lesser General Public 007// License as published by the Free Software Foundation; either 008// version 2.1 of the License, or (at your option) any later version. 009// 010// This library is distributed in the hope that it will be useful, 011// but WITHOUT ANY WARRANTY; without even the implied warranty of 012// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 013// Lesser General Public License for more details. 014// 015// You should have received a copy of the GNU Lesser General Public 016// License along with this library; if not, write to the Free Software 017// Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA 018/////////////////////////////////////////////////////////////////////////////////////////////// 019 020package com.puppycrawl.tools.checkstyle.checks.javadoc; 021 022import java.util.ArrayList; 023import java.util.List; 024import java.util.Optional; 025import java.util.function.Function; 026import java.util.regex.Pattern; 027import java.util.stream.Stream; 028 029import com.puppycrawl.tools.checkstyle.FileStatefulCheck; 030import com.puppycrawl.tools.checkstyle.api.DetailNode; 031import com.puppycrawl.tools.checkstyle.api.JavadocCommentsTokenTypes; 032import com.puppycrawl.tools.checkstyle.utils.CommonUtil; 033import com.puppycrawl.tools.checkstyle.utils.JavadocUtil; 034 035/** 036 * <div> 037 * Checks that 038 * <a href="https://www.oracle.com/technical-resources/articles/java/javadoc-tool.html#firstsentence"> 039 * Javadoc summary sentence</a> does not contain phrases that are not recommended to use. 040 * Summaries that contain only the {@code {@inheritDoc}} tag are skipped. 041 * Summaries that contain a non-empty {@code {@return}} are allowed. 042 * Check also violate Javadoc that does not contain first sentence, though with {@code {@return}} a 043 * period is not required as the Javadoc tool adds it. 044 * </div> 045 * 046 * <p> 047 * Note: For defining a summary, both the first sentence and the @summary tag approaches 048 * are supported. 049 * </p> 050 * 051 * @since 6.0 052 */ 053@FileStatefulCheck 054public class SummaryJavadocCheck extends AbstractJavadocCheck { 055 056 /** 057 * A key is pointing to the warning message text in "messages.properties" 058 * file. 059 */ 060 public static final String MSG_SUMMARY_FIRST_SENTENCE = "summary.first.sentence"; 061 062 /** 063 * A key is pointing to the warning message text in "messages.properties" 064 * file. 065 */ 066 public static final String MSG_SUMMARY_JAVADOC = "summary.javaDoc"; 067 068 /** 069 * A key is pointing to the warning message text in "messages.properties" 070 * file. 071 */ 072 public static final String MSG_SUMMARY_JAVADOC_MISSING = "summary.javaDoc.missing"; 073 074 /** 075 * A key is pointing to the warning message text in "messages.properties" file. 076 */ 077 public static final String MSG_SUMMARY_MISSING_PERIOD = "summary.javaDoc.missing.period"; 078 079 /** 080 * This regexp is used to convert multiline javadoc to single-line without stars. 081 */ 082 private static final Pattern JAVADOC_MULTILINE_TO_SINGLELINE_PATTERN = 083 Pattern.compile("\n[ \\t]+(\\*)|^[ \\t]+(\\*)"); 084 085 /** 086 * This regexp is used to remove html tags, whitespace, and asterisks from a string. 087 */ 088 private static final Pattern HTML_ELEMENTS = 089 Pattern.compile("<[^>]*>"); 090 091 /** Default period literal. */ 092 private static final String DEFAULT_PERIOD = "."; 093 094 /** 095 * Specify the regexp for forbidden summary fragments. 096 */ 097 private Pattern forbiddenSummaryFragments = CommonUtil.createPattern("^$"); 098 099 /** 100 * Specify the period symbol. Used to check the first sentence ends with a period. Periods that 101 * are not followed by a whitespace character are ignored (eg. the period in v1.0). Because some 102 * periods include whitespace built into the character, if this is set to a non-default value 103 * any period will end the sentence, whether it is followed by whitespace or not. 104 */ 105 private String period = DEFAULT_PERIOD; 106 107 /** 108 * Whether to validate untagged summary text in Javadoc. 109 */ 110 private boolean shouldValidateUntaggedSummary = true; 111 112 /** 113 * Setter to specify the regexp for forbidden summary fragments. 114 * 115 * @param pattern a pattern. 116 * @since 6.0 117 */ 118 public void setForbiddenSummaryFragments(Pattern pattern) { 119 forbiddenSummaryFragments = pattern; 120 } 121 122 /** 123 * Setter to specify the period symbol. Used to check the first sentence ends with a period. 124 * Periods that are not followed by a whitespace character are ignored (eg. the period in v1.0). 125 * Because some periods include whitespace built into the character, if this is set to a 126 * non-default value any period will end the sentence, whether it is followed by whitespace or 127 * not. 128 * 129 * @param period period's value. 130 * @since 6.2 131 */ 132 public void setPeriod(String period) { 133 this.period = period; 134 } 135 136 @Override 137 public int[] getDefaultJavadocTokens() { 138 return new int[] { 139 JavadocCommentsTokenTypes.JAVADOC_CONTENT, 140 JavadocCommentsTokenTypes.SUMMARY_INLINE_TAG, 141 JavadocCommentsTokenTypes.RETURN_INLINE_TAG, 142 }; 143 } 144 145 @Override 146 public int[] getRequiredJavadocTokens() { 147 return getAcceptableJavadocTokens(); 148 } 149 150 @Override 151 public void visitJavadocToken(DetailNode ast) { 152 if (isSummaryTag(ast) && isDefinedFirst(ast.getParent())) { 153 shouldValidateUntaggedSummary = false; 154 validateSummaryTag(ast); 155 } 156 else if (isInlineReturnTag(ast)) { 157 shouldValidateUntaggedSummary = false; 158 validateInlineReturnTag(ast); 159 } 160 } 161 162 @Override 163 public void leaveJavadocToken(DetailNode ast) { 164 if (ast.getType() == JavadocCommentsTokenTypes.JAVADOC_CONTENT) { 165 if (shouldValidateUntaggedSummary && !startsWithInheritDoc(ast)) { 166 validateUntaggedSummary(ast); 167 } 168 shouldValidateUntaggedSummary = true; 169 } 170 } 171 172 /** 173 * Checks the javadoc text for {@code period} at end and forbidden fragments. 174 * 175 * @param ast the javadoc text node 176 */ 177 private void validateUntaggedSummary(DetailNode ast) { 178 final String summaryDoc = getSummarySentence(ast); 179 if (summaryDoc.isEmpty()) { 180 log(ast.getLineNumber(), ast.getColumnNumber(), MSG_SUMMARY_JAVADOC_MISSING); 181 } 182 else if (!period.isEmpty()) { 183 if (summaryDoc.contains(period)) { 184 final Optional<String> firstSentence = getFirstSentence(ast, period); 185 186 if (firstSentence.isPresent()) { 187 if (containsForbiddenFragment(firstSentence.get())) { 188 log(ast.getLineNumber(), ast.getColumnNumber(), MSG_SUMMARY_JAVADOC); 189 } 190 } 191 else { 192 log(ast.getLineNumber(), ast.getColumnNumber(), MSG_SUMMARY_FIRST_SENTENCE); 193 } 194 } 195 else { 196 log(ast.getLineNumber(), ast.getColumnNumber(), MSG_SUMMARY_FIRST_SENTENCE); 197 } 198 } 199 } 200 201 /** 202 * Whether the {@code {@summary}} tag is defined first in the javadoc. 203 * 204 * @param inlineTagNode node of type {@link JavadocCommentsTokenTypes#JAVADOC_INLINE_TAG} 205 * @return {@code true} if the {@code {@summary}} tag is defined first in the javadoc 206 */ 207 private static boolean isDefinedFirst(DetailNode inlineTagNode) { 208 boolean isDefinedFirst = true; 209 DetailNode currentAst = inlineTagNode.getPreviousSibling(); 210 while (currentAst != null && isDefinedFirst) { 211 switch (currentAst.getType()) { 212 case JavadocCommentsTokenTypes.TEXT -> 213 isDefinedFirst = currentAst.getText().isBlank(); 214 case JavadocCommentsTokenTypes.HTML_ELEMENT -> 215 isDefinedFirst = isHtmlTagWithoutText(currentAst); 216 case JavadocCommentsTokenTypes.LEADING_ASTERISK, 217 JavadocCommentsTokenTypes.NEWLINE -> { 218 // Ignore formatting tokens 219 } 220 default -> isDefinedFirst = false; 221 } 222 currentAst = currentAst.getPreviousSibling(); 223 } 224 return isDefinedFirst; 225 } 226 227 /** 228 * Whether some text is present inside the HTML element or tag. 229 * 230 * @param node DetailNode of type {@link JavadocCommentsTokenTypes#HTML_ELEMENT} 231 * @return {@code true} if some text is present inside the HTML element 232 */ 233 public static boolean isHtmlTagWithoutText(DetailNode node) { 234 boolean isEmpty = true; 235 final DetailNode htmlContentToken = 236 JavadocUtil.findFirstToken(node, JavadocCommentsTokenTypes.HTML_CONTENT); 237 238 if (htmlContentToken != null) { 239 final DetailNode child = htmlContentToken.getFirstChild(); 240 isEmpty = child.getType() == JavadocCommentsTokenTypes.HTML_ELEMENT 241 && isHtmlTagWithoutText(child); 242 } 243 return isEmpty; 244 } 245 246 /** 247 * Checks if the given node is an inline summary tag. 248 * 249 * @param javadocInlineTag node 250 * @return {@code true} if inline tag is of 251 * type {@link JavadocCommentsTokenTypes#SUMMARY_INLINE_TAG} 252 */ 253 private static boolean isSummaryTag(DetailNode javadocInlineTag) { 254 return javadocInlineTag.getType() == JavadocCommentsTokenTypes.SUMMARY_INLINE_TAG; 255 } 256 257 /** 258 * Checks if the given node is an inline return node. 259 * 260 * @param javadocInlineTag node 261 * @return {@code true} if inline tag is of 262 * type {@link JavadocCommentsTokenTypes#RETURN_INLINE_TAG} 263 */ 264 private static boolean isInlineReturnTag(DetailNode javadocInlineTag) { 265 return javadocInlineTag.getType() == JavadocCommentsTokenTypes.RETURN_INLINE_TAG; 266 } 267 268 /** 269 * Checks the inline summary (if present) for {@code period} at end and forbidden fragments. 270 * 271 * @param inlineSummaryTag node of type {@link JavadocCommentsTokenTypes#SUMMARY_INLINE_TAG} 272 */ 273 private void validateSummaryTag(DetailNode inlineSummaryTag) { 274 final DetailNode descriptionNode = JavadocUtil.findFirstToken( 275 inlineSummaryTag, JavadocCommentsTokenTypes.DESCRIPTION); 276 final String inlineSummary = getContentOfInlineCustomTag(descriptionNode); 277 final String summaryVisible = getVisibleContent(inlineSummary); 278 if (summaryVisible.isEmpty()) { 279 log(inlineSummaryTag.getLineNumber(), inlineSummaryTag.getColumnNumber(), 280 MSG_SUMMARY_JAVADOC_MISSING); 281 } 282 else if (!period.isEmpty()) { 283 final boolean isPeriodNotAtEnd = 284 summaryVisible.lastIndexOf(period) != summaryVisible.length() - 1; 285 if (isPeriodNotAtEnd) { 286 log(inlineSummaryTag.getLineNumber(), inlineSummaryTag.getColumnNumber(), 287 MSG_SUMMARY_MISSING_PERIOD); 288 } 289 else if (containsForbiddenFragment(inlineSummary)) { 290 log(inlineSummaryTag.getLineNumber(), inlineSummaryTag.getColumnNumber(), 291 MSG_SUMMARY_JAVADOC); 292 } 293 } 294 } 295 296 /** 297 * Checks the inline return for forbidden fragments. 298 * 299 * @param inlineReturnTag node of type {@link JavadocCommentsTokenTypes#RETURN_INLINE_TAG} 300 */ 301 private void validateInlineReturnTag(DetailNode inlineReturnTag) { 302 final DetailNode descriptionNode = JavadocUtil.findFirstToken( 303 inlineReturnTag, JavadocCommentsTokenTypes.DESCRIPTION); 304 final String inlineReturn = getContentOfInlineCustomTag(descriptionNode); 305 final String returnVisible = getVisibleContent(inlineReturn); 306 if (returnVisible.isEmpty()) { 307 log(inlineReturnTag.getLineNumber(), inlineReturnTag.getColumnNumber(), 308 MSG_SUMMARY_JAVADOC_MISSING); 309 } 310 else if (containsForbiddenFragment(inlineReturn)) { 311 log(inlineReturnTag.getLineNumber(), inlineReturnTag.getColumnNumber(), 312 MSG_SUMMARY_JAVADOC); 313 } 314 } 315 316 /** 317 * Gets the content of inline custom tag. 318 * 319 * @param descriptionNode node of type {@link JavadocCommentsTokenTypes#DESCRIPTION} 320 * @return String consisting of the content of inline custom tag. 321 */ 322 public static String getContentOfInlineCustomTag(DetailNode descriptionNode) { 323 final StringBuilder customTagContent = new StringBuilder(256); 324 DetailNode curNode = descriptionNode; 325 while (curNode != null) { 326 if (curNode.getFirstChild() == null 327 && curNode.getType() != JavadocCommentsTokenTypes.LEADING_ASTERISK) { 328 customTagContent.append(curNode.getText()); 329 } 330 331 DetailNode toVisit = curNode.getFirstChild(); 332 while (curNode != descriptionNode && toVisit == null) { 333 toVisit = curNode.getNextSibling(); 334 curNode = curNode.getParent(); 335 } 336 337 curNode = toVisit; 338 } 339 return customTagContent.toString(); 340 } 341 342 /** 343 * Gets the string that is visible to user in javadoc. 344 * 345 * @param summary entire content of summary javadoc. 346 * @return string that is visible to user in javadoc. 347 */ 348 private static String getVisibleContent(String summary) { 349 final String visibleSummary = HTML_ELEMENTS.matcher(summary).replaceAll(""); 350 return visibleSummary.trim(); 351 } 352 353 /** 354 * Tests if first sentence contains forbidden summary fragment. 355 * 356 * @param firstSentence string with first sentence. 357 * @return {@code true} if first sentence contains forbidden summary fragment. 358 */ 359 private boolean containsForbiddenFragment(String firstSentence) { 360 final String javadocText = JAVADOC_MULTILINE_TO_SINGLELINE_PATTERN 361 .matcher(firstSentence).replaceAll(" "); 362 return forbiddenSummaryFragments.matcher(trimExcessWhitespaces(javadocText)).find(); 363 } 364 365 /** 366 * Trims the given {@code text} of duplicate whitespaces. 367 * 368 * @param text the text to transform. 369 * @return the finalized form of the text. 370 */ 371 private static String trimExcessWhitespaces(String text) { 372 final StringBuilder result = new StringBuilder(256); 373 boolean previousWhitespace = true; 374 375 for (int index = 0; index < text.length(); index++) { 376 final char letter = text.charAt(index); 377 final char print; 378 if (Character.isWhitespace(letter)) { 379 if (previousWhitespace) { 380 continue; 381 } 382 383 previousWhitespace = true; 384 print = ' '; 385 } 386 else { 387 previousWhitespace = false; 388 print = letter; 389 } 390 391 result.append(print); 392 } 393 394 return result.toString(); 395 } 396 397 /** 398 * Checks if the node starts with an {@inheritDoc}. 399 * 400 * @param root the root node to examine. 401 * @return {@code true} if the javadoc starts with an {@inheritDoc}. 402 */ 403 private static boolean startsWithInheritDoc(DetailNode root) { 404 boolean found = false; 405 DetailNode node = root.getFirstChild(); 406 407 while (node != null) { 408 if (node.getType() == JavadocCommentsTokenTypes.JAVADOC_INLINE_TAG 409 && node.getFirstChild().getType() 410 == JavadocCommentsTokenTypes.INHERIT_DOC_INLINE_TAG) { 411 found = true; 412 } 413 if ((node.getType() == JavadocCommentsTokenTypes.TEXT 414 || node.getType() == JavadocCommentsTokenTypes.HTML_ELEMENT) 415 && !CommonUtil.isBlank(node.getText())) { 416 break; 417 } 418 node = node.getNextSibling(); 419 } 420 421 return found; 422 } 423 424 /** 425 * Finds and returns summary sentence. 426 * 427 * @param ast javadoc root node. 428 * @return violation string. 429 */ 430 private static String getSummarySentence(DetailNode ast) { 431 final StringBuilder result = new StringBuilder(256); 432 DetailNode node = ast.getFirstChild(); 433 while (node != null) { 434 if (node.getType() == JavadocCommentsTokenTypes.TEXT) { 435 result.append(node.getText()); 436 } 437 else { 438 final String summary = result.toString(); 439 if (CommonUtil.isBlank(summary) 440 && node.getType() == JavadocCommentsTokenTypes.HTML_ELEMENT) { 441 final DetailNode htmlContentToken = JavadocUtil.findFirstToken( 442 node, JavadocCommentsTokenTypes.HTML_CONTENT); 443 result.append(getStringInsideHtmlTag(summary, htmlContentToken)); 444 } 445 } 446 node = node.getNextSibling(); 447 } 448 return result.toString().trim(); 449 } 450 451 /** 452 * Get concatenated string within text of html tags. 453 * 454 * @param result javadoc string 455 * @param detailNode htmlContent node 456 * @return java doc tag content appended in result 457 */ 458 private static String getStringInsideHtmlTag(String result, DetailNode detailNode) { 459 final StringBuilder contents = new StringBuilder(result); 460 if (detailNode != null) { 461 DetailNode tempNode = detailNode.getFirstChild(); 462 while (tempNode != null) { 463 if (tempNode.getType() == JavadocCommentsTokenTypes.TEXT) { 464 contents.append(tempNode.getText()); 465 } 466 tempNode = tempNode.getNextSibling(); 467 } 468 } 469 return contents.toString(); 470 } 471 472 /** 473 * Finds the first sentence. 474 * 475 * @param ast The Javadoc root node. 476 * @param period The configured period symbol. 477 * @return An Optional containing the first sentence 478 * up to and excluding the period, or an empty 479 * Optional if no ending was found. 480 */ 481 private static Optional<String> getFirstSentence(DetailNode ast, String period) { 482 final List<String> sentenceParts = new ArrayList<>(); 483 Optional<String> result = Optional.empty(); 484 for (String text : (Iterable<String>) streamTextParts(ast)::iterator) { 485 final Optional<String> sentenceEnding = findSentenceEnding(text, period); 486 487 if (sentenceEnding.isPresent()) { 488 sentenceParts.add(sentenceEnding.get()); 489 result = Optional.of(String.join("", sentenceParts)); 490 break; 491 } 492 sentenceParts.add(text); 493 } 494 return result; 495 } 496 497 /** 498 * Streams through all the text under the given node. 499 * 500 * @param node The Javadoc node to examine. 501 * @return All the text in all nodes that have no child nodes. 502 */ 503 private static Stream<String> streamTextParts(DetailNode node) { 504 final Stream<String> result; 505 if (node.getFirstChild() == null) { 506 result = Stream.of(node.getText()); 507 } 508 else { 509 final List<Stream<String>> childStreams = new ArrayList<>(); 510 DetailNode child = node.getFirstChild(); 511 while (child != null) { 512 childStreams.add(streamTextParts(child)); 513 child = child.getNextSibling(); 514 } 515 result = childStreams.stream().flatMap(Function.identity()); 516 } 517 return result; 518 } 519 520 /** 521 * Finds the end of a sentence. The end of sentence detection here could be replaced in the 522 * future by Java's built-in BreakIterator class. 523 * 524 * @param text The string to search. 525 * @param period The period character to find. 526 * @return An Optional containing the string up to and excluding the period, 527 * or empty Optional if no ending was found. 528 */ 529 private static Optional<String> findSentenceEnding(String text, String period) { 530 int periodIndex = text.indexOf(period); 531 Optional<String> result = Optional.empty(); 532 while (periodIndex >= 0) { 533 final int afterPeriodIndex = periodIndex + period.length(); 534 535 // Handle western period separately as it is only the end of a sentence if followed 536 // by whitespace. Other period characters often include whitespace in the character. 537 if (!DEFAULT_PERIOD.equals(period) 538 || afterPeriodIndex >= text.length() 539 || Character.isWhitespace(text.charAt(afterPeriodIndex))) { 540 final String resultStr = text.substring(0, periodIndex); 541 result = Optional.of(resultStr); 542 break; 543 } 544 periodIndex = text.indexOf(period, afterPeriodIndex); 545 } 546 return result; 547 } 548}