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.TokenTypes;
10  import java.util.Locale;
11  
12  /**
13   * Check for compact paragraph tags in Javadoc.
14   *
15   * <p>The built-in {@code JavadocParagraph} module governs the blank lines
16   * around {@code <p>}, but nothing stops the tag from sitting alone on its
17   * own line. This check keeps {@code <p>} glued to the text it opens and
18   * {@code </p>} glued to the text it closes, while
19   * {@link JavadocUnclosedParagraphCheck} is the one that insists on the
20   * closing tag being there. So a line whose trimmed content ends with
21   * {@code <p>}, or whose trimmed content starts with {@code </p>}, is
22   * reported as a violation. See
23   * <a href="https://github.com/yegor256/qulice/issues/1709">#1709</a>.</p>
24   *
25   * <p>The following Javadoc will be reported as a violation, since the
26   * opening tag ends a line and the closing tag starts a line:</p>
27   * <pre>
28   * &#47;**
29   *  <span style="color:red" >* &lt;p&gt;</span>
30   *  * An example of how to configure the check is:
31   *  <span style="color:red" >* &lt;/p&gt;</span>
32   *  *&#47;
33   * </pre>
34   *
35   * <p>And this is how it should be written instead:</p>
36   * <pre>
37   * &#47;**
38   *  * &lt;p&gt;An example of how to configure the check is:&lt;/p&gt;
39   *  *&#47;
40   * </pre>
41   *
42   * <p>Lines inside a {@code <pre>...</pre>} block or a {@code {@snippet ...}}
43   * block are skipped, since a literal {@code <p>} or {@code </p>} may appear
44   * there as example content rather than as a real tag.</p>
45   *
46   * @since 0.73.3
47   */
48  public final class JavadocCompactParagraphCheck extends AbstractCheck {
49  
50      /**
51       * Default constructor.
52       */
53      public JavadocCompactParagraphCheck() {
54          // nothing to initialize
55      }
56  
57      @Override
58      public int[] getDefaultTokens() {
59          return new int[] {
60              TokenTypes.PACKAGE_DEF,
61              TokenTypes.CLASS_DEF,
62              TokenTypes.INTERFACE_DEF,
63              TokenTypes.ANNOTATION_DEF,
64              TokenTypes.ANNOTATION_FIELD_DEF,
65              TokenTypes.ENUM_DEF,
66              TokenTypes.ENUM_CONSTANT_DEF,
67              TokenTypes.VARIABLE_DEF,
68              TokenTypes.CTOR_DEF,
69              TokenTypes.METHOD_DEF,
70          };
71      }
72  
73      @Override
74      public int[] getAcceptableTokens() {
75          return this.getDefaultTokens();
76      }
77  
78      @Override
79      public int[] getRequiredTokens() {
80          return this.getDefaultTokens();
81      }
82  
83      @Override
84      public void visitToken(final DetailAST ast) {
85          final String[] lines = this.getLines();
86          final int current = ast.getLineNo();
87          final int start =
88              JavadocCompactParagraphCheck.findCommentStart(lines, current) + 1;
89          final int end =
90              JavadocCompactParagraphCheck.findCommentEnd(lines, current) - 1;
91          if (JavadocCompactParagraphCheck.isNodeHavingJavadoc(ast, start)
92              && start < lines.length && end >= start) {
93              this.checkParagraphs(lines, start, end);
94          }
95      }
96  
97      private void checkParagraphs(final String[] lines, final int start,
98          final int end) {
99          boolean pre = false;
100         int depth = 0;
101         for (int pos = start; pos <= end && pos < lines.length; pos += 1) {
102             final String body = JavadocCompactParagraphCheck.body(lines[pos]);
103             final String low = body.toLowerCase(Locale.ENGLISH);
104             if (depth > 0) {
105                 depth += JavadocCompactParagraphCheck.braces(body);
106             } else if (low.contains("{@snippet")) {
107                 depth = Math.max(0, JavadocCompactParagraphCheck.braces(body));
108             } else if (pre) {
109                 pre = !low.contains("</pre>");
110             } else if (low.contains("<pre>")) {
111                 pre = !low.contains("</pre>");
112             } else {
113                 this.report(pos, body);
114             }
115         }
116     }
117 
118     private void report(final int pos, final String body) {
119         if (body.endsWith("<p>")) {
120             this.log(
121                 pos + 1,
122                 "Opening paragraph tag <p> must be followed by text on the same line"
123             );
124         }
125         if (body.startsWith("</p>")) {
126             this.log(
127                 pos + 1,
128                 "Closing paragraph tag </p> must be preceded by text on the same line"
129             );
130         }
131     }
132 
133     private static String body(final String line) {
134         String trimmed = line.trim();
135         if (trimmed.startsWith("*")) {
136             trimmed = trimmed.substring(1).trim();
137         }
138         return trimmed;
139     }
140 
141     private static int braces(final String body) {
142         int delta = 0;
143         for (int pos = 0; pos < body.length(); pos += 1) {
144             final char chr = body.charAt(pos);
145             if (chr == '{') {
146                 delta += 1;
147             } else if (chr == '}') {
148                 delta -= 1;
149             }
150         }
151         return delta;
152     }
153 
154     private static boolean isNodeHavingJavadoc(final DetailAST node,
155         final int start) {
156         return start > JavadocCompactParagraphCheck.getLineNoOfPreviousNode(
157             node
158         );
159     }
160 
161     private static int getLineNoOfPreviousNode(final DetailAST node) {
162         int start = 0;
163         final DetailAST previous = node.getPreviousSibling();
164         if (previous != null) {
165             start = previous.getLineNo();
166         }
167         return start;
168     }
169 
170     private static int findCommentStart(final String[] lines, final int start) {
171         return JavadocCompactParagraphCheck.findTrimmedTextUp(
172             lines, start, "/**"
173         );
174     }
175 
176     private static int findCommentEnd(final String[] lines, final int start) {
177         int found = -1;
178         for (int pos = start - 1; pos >= 0; pos -= 1) {
179             final String trimmed = lines[pos].trim();
180             if ("*/".equals(trimmed) || "**/".equals(trimmed)) {
181                 found = pos;
182                 break;
183             }
184         }
185         return found;
186     }
187 
188     private static int findTrimmedTextUp(final String[] lines,
189         final int start, final String text) {
190         int found = -1;
191         for (int pos = start - 1; pos >= 0; pos -= 1) {
192             if (lines[pos].trim().equals(text)) {
193                 found = pos;
194                 break;
195             }
196         }
197         return found;
198     }
199 }