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