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" file. 058 */ 059 public static final String MSG_SUMMARY_FIRST_SENTENCE = "summary.first.sentence"; 060 061 /** 062 * A key is pointing to the warning message text in "messages.properties" file. 063 */ 064 public static final String MSG_SUMMARY_JAVADOC = "summary.javaDoc"; 065 066 /** 067 * A key is pointing to the warning message text in "messages.properties" file. 068 */ 069 public static final String MSG_SUMMARY_JAVADOC_MISSING = "summary.javaDoc.missing"; 070 071 /** 072 * A key is pointing to the warning message text in "messages.properties" file. 073 */ 074 public static final String MSG_SUMMARY_MISSING_PERIOD = "summary.javaDoc.missing.period"; 075 076 /** 077 * This regexp is used to convert multiline javadoc to single-line without stars. 078 */ 079 private static final Pattern JAVADOC_MULTILINE_TO_SINGLELINE_PATTERN = 080 Pattern.compile("\n[ \\t]*(\\*)|^[ \\t]*(\\*)"); 081 082 /** 083 * This regexp is used to remove html tags, whitespace, and asterisks from a string. 084 */ 085 private static final Pattern HTML_ELEMENTS = 086 Pattern.compile("<[^>]*>"); 087 088 /** Default period literal. */ 089 private static final String DEFAULT_PERIOD = "."; 090 091 /** 092 * Specify the regexp for forbidden summary fragments. 093 */ 094 private Pattern forbiddenSummaryFragments = CommonUtil.createPattern("^$"); 095 096 /** 097 * Specify the period symbol. Used to check the first sentence ends with a period. Periods that 098 * are not followed by a whitespace character are ignored (eg. the period in v1.0). Because some 099 * periods include whitespace built into the character, if this is set to a non-default value 100 * any period will end the sentence, whether it is followed by whitespace or not. 101 */ 102 private String period = DEFAULT_PERIOD; 103 104 /** 105 * Whether to validate untagged summary text in Javadoc. 106 */ 107 private boolean shouldValidateUntaggedSummary = true; 108 109 /** 110 * Creates a new {@code SummaryJavadocCheck} instance. 111 */ 112 public SummaryJavadocCheck() { 113 // no code by default 114 } 115 116 /** 117 * Setter to specify the regexp for forbidden summary fragments. 118 * 119 * @param pattern a pattern. 120 * @since 6.0 121 */ 122 public void setForbiddenSummaryFragments(Pattern pattern) { 123 forbiddenSummaryFragments = pattern; 124 } 125 126 /** 127 * Setter to specify the period symbol. Used to check the first sentence ends with a period. 128 * Periods that are not followed by a whitespace character are ignored (eg. the period in v1.0). 129 * Because some periods include whitespace built into the character, if this is set to a 130 * non-default value any period will end the sentence, whether it is followed by whitespace or 131 * not. 132 * 133 * @param period period's value. 134 * @since 6.2 135 */ 136 public void setPeriod(String period) { 137 this.period = period; 138 } 139 140 @Override 141 public int[] getDefaultJavadocTokens() { 142 return new int[] { 143 JavadocCommentsTokenTypes.JAVADOC_CONTENT, 144 JavadocCommentsTokenTypes.SUMMARY_INLINE_TAG, 145 JavadocCommentsTokenTypes.RETURN_INLINE_TAG, 146 }; 147 } 148 149 @Override 150 public int[] getRequiredJavadocTokens() { 151 return getAcceptableJavadocTokens(); 152 } 153 154 @Override 155 public void visitJavadocToken(DetailNode ast) { 156 if (isSummaryTag(ast) && isDefinedFirst(ast.getParent())) { 157 shouldValidateUntaggedSummary = false; 158 validateSummaryTag(ast); 159 } 160 else if (isInlineReturnTag(ast)) { 161 shouldValidateUntaggedSummary = false; 162 validateInlineReturnTag(ast); 163 } 164 } 165 166 @Override 167 public void leaveJavadocToken(DetailNode ast) { 168 if (ast.getType() == JavadocCommentsTokenTypes.JAVADOC_CONTENT) { 169 if (shouldValidateUntaggedSummary && !startsWithInheritDoc(ast)) { 170 validateUntaggedSummary(ast); 171 } 172 shouldValidateUntaggedSummary = true; 173 } 174 } 175 176 /** 177 * Checks the javadoc text for {@code period} at end and forbidden fragments. 178 * 179 * @param ast the javadoc text node 180 */ 181 private void validateUntaggedSummary(DetailNode ast) { 182 final String summaryDoc = getSummarySentence(ast); 183 if (summaryDoc.isEmpty()) { 184 log(ast, MSG_SUMMARY_JAVADOC_MISSING); 185 } 186 else if (!period.isEmpty()) { 187 if (summaryDoc.contains(period)) { 188 final Optional<String> firstSentence = getFirstSentence(ast, period); 189 190 if (firstSentence.isPresent()) { 191 if (containsForbiddenFragment(firstSentence.get())) { 192 log(ast, MSG_SUMMARY_JAVADOC); 193 } 194 } 195 else { 196 log(ast, MSG_SUMMARY_FIRST_SENTENCE); 197 } 198 } 199 else { 200 log(ast, MSG_SUMMARY_FIRST_SENTENCE); 201 } 202 } 203 } 204 205 /** 206 * Whether the {@code {@summary}} tag is defined first in the javadoc. 207 * 208 * @param inlineTagNode node of type {@link JavadocCommentsTokenTypes#JAVADOC_INLINE_TAG} 209 * @return {@code true} if the {@code {@summary}} tag is defined first in the javadoc 210 */ 211 private static boolean isDefinedFirst(DetailNode inlineTagNode) { 212 boolean isDefinedFirst = true; 213 DetailNode currentAst = inlineTagNode.getPreviousSibling(); 214 while (currentAst != null && isDefinedFirst) { 215 switch (currentAst.getType()) { 216 case JavadocCommentsTokenTypes.TEXT -> 217 isDefinedFirst = currentAst.getText().isBlank(); 218 case JavadocCommentsTokenTypes.HTML_ELEMENT -> 219 isDefinedFirst = isHtmlTagWithoutText(currentAst); 220 case JavadocCommentsTokenTypes.LEADING_ASTERISK, 221 JavadocCommentsTokenTypes.LEADING_ASTERISKS, 222 JavadocCommentsTokenTypes.NEWLINE -> { 223 // Ignore formatting tokens 224 } 225 default -> isDefinedFirst = false; 226 } 227 currentAst = currentAst.getPreviousSibling(); 228 } 229 return isDefinedFirst; 230 } 231 232 /** 233 * Whether some text is present inside the HTML element or tag. 234 * 235 * @param node DetailNode of type {@link JavadocCommentsTokenTypes#HTML_ELEMENT} 236 * @return {@code true} if some text is present inside the HTML element 237 */ 238 public static boolean isHtmlTagWithoutText(DetailNode node) { 239 boolean isEmpty = true; 240 final DetailNode htmlContentToken = 241 JavadocUtil.findFirstToken(node, JavadocCommentsTokenTypes.HTML_CONTENT); 242 243 if (htmlContentToken != null) { 244 final DetailNode child = htmlContentToken.getFirstChild(); 245 isEmpty = child.getType() == JavadocCommentsTokenTypes.HTML_ELEMENT 246 && isHtmlTagWithoutText(child); 247 } 248 return isEmpty; 249 } 250 251 /** 252 * Checks if the given node is an inline summary tag. 253 * 254 * @param javadocInlineTag node 255 * @return {@code true} if inline tag is of 256 * type {@link JavadocCommentsTokenTypes#SUMMARY_INLINE_TAG} 257 */ 258 private static boolean isSummaryTag(DetailNode javadocInlineTag) { 259 return javadocInlineTag.getType() == JavadocCommentsTokenTypes.SUMMARY_INLINE_TAG; 260 } 261 262 /** 263 * Checks if the given node is an inline return node. 264 * 265 * @param javadocInlineTag node 266 * @return {@code true} if inline tag is of 267 * type {@link JavadocCommentsTokenTypes#RETURN_INLINE_TAG} 268 */ 269 private static boolean isInlineReturnTag(DetailNode javadocInlineTag) { 270 return javadocInlineTag.getType() == JavadocCommentsTokenTypes.RETURN_INLINE_TAG; 271 } 272 273 /** 274 * Checks the inline summary (if present) for {@code period} at end and forbidden fragments. 275 * 276 * @param inlineSummaryTag node of type {@link JavadocCommentsTokenTypes#SUMMARY_INLINE_TAG} 277 */ 278 private void validateSummaryTag(DetailNode inlineSummaryTag) { 279 final DetailNode descriptionNode = JavadocUtil.findFirstToken( 280 inlineSummaryTag, JavadocCommentsTokenTypes.DESCRIPTION); 281 final String inlineSummary = getContentOfInlineCustomTag(descriptionNode); 282 final String summaryVisible = getVisibleContent(inlineSummary); 283 if (summaryVisible.isEmpty()) { 284 log(inlineSummaryTag, MSG_SUMMARY_JAVADOC_MISSING); 285 } 286 else if (!period.isEmpty()) { 287 final boolean isPeriodNotAtEnd = 288 summaryVisible.lastIndexOf(period) != summaryVisible.length() - 1; 289 if (isPeriodNotAtEnd) { 290 log(inlineSummaryTag, MSG_SUMMARY_MISSING_PERIOD); 291 } 292 else if (containsForbiddenFragment(inlineSummary)) { 293 log(inlineSummaryTag, MSG_SUMMARY_JAVADOC); 294 } 295 } 296 } 297 298 /** 299 * Checks the inline return for forbidden fragments. 300 * 301 * @param inlineReturnTag node of type {@link JavadocCommentsTokenTypes#RETURN_INLINE_TAG} 302 */ 303 private void validateInlineReturnTag(DetailNode inlineReturnTag) { 304 final DetailNode descriptionNode = JavadocUtil.findFirstToken( 305 inlineReturnTag, JavadocCommentsTokenTypes.DESCRIPTION); 306 final String inlineReturn = getContentOfInlineCustomTag(descriptionNode); 307 final String returnVisible = getVisibleContent(inlineReturn); 308 if (returnVisible.isEmpty()) { 309 log(inlineReturnTag, MSG_SUMMARY_JAVADOC_MISSING); 310 } 311 else if (containsForbiddenFragment(prependJavadocToolWord(inlineReturn))) { 312 log(inlineReturnTag, 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 && !isLeadingAsterisk(curNode)) { 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 * Checks whether the given node is a leading asterisk. 344 * 345 * @param node the node to check 346 * @return true if the node is a leading asterisk 347 */ 348 private static boolean isLeadingAsterisk(DetailNode node) { 349 return node.getType() == JavadocCommentsTokenTypes.LEADING_ASTERISK 350 || node.getType() == JavadocCommentsTokenTypes.LEADING_ASTERISKS; 351 } 352 353 /** 354 * Gets the string that is visible to user in javadoc. 355 * 356 * @param summary entire content of summary javadoc. 357 * @return string that is visible to user in javadoc. 358 */ 359 private static String getVisibleContent(String summary) { 360 final String visibleSummary = HTML_ELEMENTS.matcher(summary).replaceAll(""); 361 return visibleSummary.trim(); 362 } 363 364 /** 365 * Tests if first sentence contains forbidden summary fragment. 366 * 367 * @param firstSentence string with first sentence. 368 * @return {@code true} if first sentence contains forbidden summary fragment. 369 */ 370 private boolean containsForbiddenFragment(String firstSentence) { 371 final String javadocText = JAVADOC_MULTILINE_TO_SINGLELINE_PATTERN 372 .matcher(firstSentence).replaceAll(" "); 373 return forbiddenSummaryFragments.matcher(trimExcessWhitespaces(javadocText)).find(); 374 } 375 376 /** 377 * Prepends the word "Returns" to the given inline {@code {@return}} tag content, 378 * since Javadoc renders {@code {@return ...}} as "Returns ..." in the method summary. 379 * 380 * @param inlineReturn String consisting of the content of inline {@code {@return}} tag 381 * @return the inline return content prefixed with "Returns " 382 */ 383 private static String prependJavadocToolWord(String inlineReturn) { 384 return "Returns " + inlineReturn; 385 } 386 387 /** 388 * Trims the given {@code text} of duplicate whitespaces. 389 * 390 * @param text the text to transform. 391 * @return the finalized form of the text. 392 */ 393 private static String trimExcessWhitespaces(String text) { 394 final StringBuilder result = new StringBuilder(256); 395 boolean previousWhitespace = true; 396 397 for (int index = 0; index < text.length(); index++) { 398 final char letter = text.charAt(index); 399 final char print; 400 if (Character.isWhitespace(letter)) { 401 if (previousWhitespace) { 402 continue; 403 } 404 405 previousWhitespace = true; 406 print = ' '; 407 } 408 else { 409 previousWhitespace = false; 410 print = letter; 411 } 412 413 result.append(print); 414 } 415 416 return result.toString(); 417 } 418 419 /** 420 * Checks if the node starts with an {@inheritDoc}. 421 * 422 * @param root the root node to examine. 423 * @return {@code true} if the javadoc starts with an {@inheritDoc}. 424 */ 425 private static boolean startsWithInheritDoc(DetailNode root) { 426 boolean found = false; 427 DetailNode node = root.getFirstChild(); 428 429 while (node != null) { 430 if (node.getType() == JavadocCommentsTokenTypes.JAVADOC_INLINE_TAG 431 && node.getFirstChild().getType() 432 == JavadocCommentsTokenTypes.INHERIT_DOC_INLINE_TAG) { 433 found = true; 434 } 435 if ((node.getType() == JavadocCommentsTokenTypes.TEXT 436 || node.getType() == JavadocCommentsTokenTypes.HTML_ELEMENT) 437 && !CommonUtil.isBlank(node.getText())) { 438 break; 439 } 440 node = node.getNextSibling(); 441 } 442 443 return found; 444 } 445 446 /** 447 * Finds and returns summary sentence. 448 * 449 * @param ast javadoc root node. 450 * @return violation string. 451 */ 452 private static String getSummarySentence(DetailNode ast) { 453 final StringBuilder result = new StringBuilder(256); 454 DetailNode node = ast.getFirstChild(); 455 while (node != null) { 456 if (node.getType() == JavadocCommentsTokenTypes.TEXT) { 457 result.append(node.getText()); 458 } 459 else { 460 final String summary = result.toString(); 461 if (CommonUtil.isBlank(summary) 462 && node.getType() == JavadocCommentsTokenTypes.HTML_ELEMENT) { 463 final DetailNode htmlContentToken = JavadocUtil.findFirstToken( 464 node, JavadocCommentsTokenTypes.HTML_CONTENT); 465 result.append(getStringInsideHtmlTag(summary, htmlContentToken)); 466 } 467 } 468 node = node.getNextSibling(); 469 } 470 return result.toString().trim(); 471 } 472 473 /** 474 * Get concatenated string within text of html tags. 475 * 476 * @param result javadoc string 477 * @param detailNode htmlContent node 478 * @return java doc tag content appended in result 479 */ 480 private static String getStringInsideHtmlTag(String result, DetailNode detailNode) { 481 final StringBuilder contents = new StringBuilder(result); 482 if (detailNode != null) { 483 DetailNode tempNode = detailNode.getFirstChild(); 484 while (tempNode != null) { 485 if (tempNode.getType() == JavadocCommentsTokenTypes.TEXT) { 486 contents.append(tempNode.getText()); 487 } 488 else { 489 final DetailNode htmlContentToken = JavadocUtil.findFirstToken( 490 tempNode, JavadocCommentsTokenTypes.HTML_CONTENT); 491 contents.append(getStringInsideHtmlTag("", htmlContentToken)); 492 } 493 tempNode = tempNode.getNextSibling(); 494 } 495 } 496 return contents.toString(); 497 } 498 499 /** 500 * Finds the first sentence. 501 * 502 * @param ast The Javadoc root node. 503 * @param period The configured period symbol. 504 * @return An Optional containing the first sentence up to and excluding the period, or an empty 505 * Optional if no ending was found. 506 */ 507 private static Optional<String> getFirstSentence(DetailNode ast, String period) { 508 final List<String> sentenceParts = new ArrayList<>(); 509 Optional<String> result = Optional.empty(); 510 for (String text : (Iterable<String>) streamTextParts(ast)::iterator) { 511 final Optional<String> sentenceEnding = findSentenceEnding(text, period); 512 513 if (sentenceEnding.isPresent()) { 514 sentenceParts.add(sentenceEnding.get()); 515 result = Optional.of(String.join("", sentenceParts)); 516 break; 517 } 518 sentenceParts.add(text); 519 } 520 return result; 521 } 522 523 /** 524 * Streams through all the text under the given node. 525 * 526 * @param node The Javadoc node to examine. 527 * @return All the text in all nodes that have no child nodes. 528 */ 529 private static Stream<String> streamTextParts(DetailNode node) { 530 final Stream<String> result; 531 if (node.getFirstChild() == null) { 532 result = Stream.of(node.getText()); 533 } 534 else { 535 final List<Stream<String>> childStreams = new ArrayList<>(); 536 DetailNode child = node.getFirstChild(); 537 while (child != null) { 538 childStreams.add(streamTextParts(child)); 539 child = child.getNextSibling(); 540 } 541 result = childStreams.stream().flatMap(Function.identity()); 542 } 543 return result; 544 } 545 546 /** 547 * Finds the end of a sentence. The end of sentence detection here could be replaced in the 548 * future by Java's built-in BreakIterator class. 549 * 550 * @param text The string to search. 551 * @param period The period character to find. 552 * @return An Optional containing the string up to and excluding the period, 553 * or empty Optional if no ending was found. 554 */ 555 private static Optional<String> findSentenceEnding(String text, String period) { 556 int periodIndex = text.indexOf(period); 557 Optional<String> result = Optional.empty(); 558 while (periodIndex >= 0) { 559 final int afterPeriodIndex = periodIndex + period.length(); 560 561 // Handle western period separately as it is only the end of a sentence if followed 562 // by whitespace. Other period characters often include whitespace in the character. 563 if (!DEFAULT_PERIOD.equals(period) 564 || afterPeriodIndex >= text.length() 565 || Character.isWhitespace(text.charAt(afterPeriodIndex))) { 566 final String resultStr = text.substring(0, periodIndex); 567 result = Optional.of(resultStr); 568 break; 569 } 570 periodIndex = text.indexOf(period, afterPeriodIndex); 571 } 572 return result; 573 } 574 575}