View Javadoc
1   /*
2    * SPDX-FileCopyrightText: Copyright (c) 2011-2026 Yegor Bugayenko
3    * SPDX-License-Identifier: MIT
4    */
5   package com.qulice.checkstyle;
6   
7   import com.puppycrawl.tools.checkstyle.api.AbstractCheck;
8   import com.puppycrawl.tools.checkstyle.api.DetailAST;
9   import com.puppycrawl.tools.checkstyle.api.TextBlock;
10  import com.puppycrawl.tools.checkstyle.api.TokenTypes;
11  import java.util.Locale;
12  
13  /**
14   * Check for a Javadoc paragraph that is never closed.
15   *
16   * <p>A paragraph opened with {@code <p>} must be closed with {@code </p>}
17   * before the next {@code <p>} starts and before the comment ends. Leaving
18   * it open makes the reader guess where the paragraph stops, and invites
19   * the habit of "closing" it with something else, such as a bare asterisk.
20   * See <a href="https://github.com/yegor256/qulice/issues/1783">#1783</a>.</p>
21   *
22   * <p>The following Javadoc will be reported as a violation, since the
23   * first paragraph is still open when the second one starts and the second
24   * one is still open when the comment ends:</p>
25   *
26   * <pre>
27   * &#47;**
28   *  <span style="color:red" >* &lt;p&gt;The first paragraph.</span>
29   *  *
30   *  <span style="color:red" >* &lt;p&gt;The second one.</span>
31   *  *&#47;
32   * </pre>
33   *
34   * <p>And this is how it should be written instead:</p>
35   *
36   * <pre>
37   * &#47;**
38   *  * &lt;p&gt;The first paragraph.&lt;/p&gt;
39   *  *
40   *  * &lt;p&gt;The second one.&lt;/p&gt;
41   *  *&#47;
42   * </pre>
43   *
44   * <p>The violation is reported on the line that opens the paragraph, since
45   * that is the tag left dangling. Lines inside a {@code <pre>...</pre>} block
46   * or a {@code {@snippet ...}} block are skipped, and so is the text of an
47   * inline {@code {@code ...}} or {@code {@literal ...}} tag, since a literal
48   * {@code <p>} may appear in any of them as example content rather than as
49   * a real tag.</p>
50   *
51   * @since 0.73.4
52   */
53  public final class JavadocUnclosedParagraphCheck extends AbstractCheck {
54  
55      /**
56       * Message about a paragraph that is never closed.
57       */
58      private static final String MSG =
59          "Opening paragraph tag <p> must be closed with </p>";
60  
61      /**
62       * Default constructor.
63       */
64      public JavadocUnclosedParagraphCheck() {
65          // nothing to initialize
66      }
67  
68      @Override
69      public int[] getDefaultTokens() {
70          return new int[] {
71              TokenTypes.PACKAGE_DEF,
72              TokenTypes.CLASS_DEF,
73              TokenTypes.INTERFACE_DEF,
74              TokenTypes.ANNOTATION_DEF,
75              TokenTypes.ANNOTATION_FIELD_DEF,
76              TokenTypes.ENUM_DEF,
77              TokenTypes.ENUM_CONSTANT_DEF,
78              TokenTypes.VARIABLE_DEF,
79              TokenTypes.CTOR_DEF,
80              TokenTypes.METHOD_DEF,
81          };
82      }
83  
84      @Override
85      public int[] getAcceptableTokens() {
86          return this.getDefaultTokens();
87      }
88  
89      @Override
90      public int[] getRequiredTokens() {
91          return this.getDefaultTokens();
92      }
93  
94      @Override
95      @SuppressWarnings("deprecation")
96      public void visitToken(final DetailAST ast) {
97          final TextBlock doc =
98              this.getFileContents().getJavadocBefore(ast.getLineNo());
99          if (doc != null) {
100             this.check(doc);
101         }
102     }
103 
104     private void check(final TextBlock doc) {
105         final String[] lines = JavadocUnclosedParagraphCheck.masked(doc);
106         boolean pre = false;
107         int depth = 0;
108         int open = -1;
109         for (int pos = 0; pos < lines.length; pos += 1) {
110             final String low = lines[pos];
111             if (depth > 0) {
112                 depth += JavadocUnclosedParagraphCheck.braces(low);
113             } else if (low.contains("{@snippet")) {
114                 depth = Math.max(
115                     0, JavadocUnclosedParagraphCheck.braces(low)
116                 );
117             } else if (pre) {
118                 pre = !low.contains("</pre>");
119             } else if (low.contains("<pre>")) {
120                 pre = !low.contains("</pre>");
121             } else {
122                 open = this.scan(doc, pos, low, open);
123             }
124         }
125         if (open >= 0) {
126             this.log(
127                 doc.getStartLineNo() + open,
128                 JavadocUnclosedParagraphCheck.MSG
129             );
130         }
131     }
132 
133     private int scan(final TextBlock doc, final int pos, final String low,
134         final int start) {
135         int open = start;
136         for (int idx = 0; idx < low.length(); idx += 1) {
137             if (low.startsWith("<p>", idx)) {
138                 if (open >= 0) {
139                     this.log(
140                         doc.getStartLineNo() + open,
141                         JavadocUnclosedParagraphCheck.MSG
142                     );
143                 }
144                 open = pos;
145             } else if (low.startsWith("</p>", idx)) {
146                 open = -1;
147             }
148         }
149         return open;
150     }
151 
152     private static String[] masked(final TextBlock doc) {
153         final String[] lines = doc.getText();
154         final String[] bodies = new String[lines.length];
155         int inline = 0;
156         for (int pos = 0; pos < lines.length; pos += 1) {
157             final String body = JavadocUnclosedParagraphCheck.body(lines[pos])
158                 .toLowerCase(Locale.ENGLISH);
159             final StringBuilder buf = new StringBuilder(body.length());
160             for (int idx = 0; idx < body.length(); idx += 1) {
161                 final char chr = body.charAt(idx);
162                 if (inline > 0) {
163                     if (chr == '{') {
164                         inline += 1;
165                     } else if (chr == '}') {
166                         inline -= 1;
167                     }
168                     buf.append(' ');
169                 } else if (body.startsWith("{@code", idx)
170                     || body.startsWith("{@literal", idx)) {
171                     inline = 1;
172                     buf.append(' ');
173                 } else {
174                     buf.append(chr);
175                 }
176             }
177             bodies[pos] = buf.toString();
178         }
179         return bodies;
180     }
181 
182     private static String body(final String line) {
183         String trimmed = line.trim();
184         if (trimmed.endsWith("*/")) {
185             trimmed = trimmed.substring(0, trimmed.length() - 2).trim();
186         }
187         if (trimmed.startsWith("/**")) {
188             trimmed = trimmed.substring(3).trim();
189         } else if (trimmed.startsWith("*")) {
190             trimmed = trimmed.substring(1).trim();
191         }
192         return trimmed;
193     }
194 
195     private static int braces(final String body) {
196         int delta = 0;
197         for (int pos = 0; pos < body.length(); pos += 1) {
198             final char chr = body.charAt(pos);
199             if (chr == '{') {
200                 delta += 1;
201             } else if (chr == '}') {
202                 delta -= 1;
203             }
204         }
205         return delta;
206     }
207 }