View Javadoc
1   ///////////////////////////////////////////////////////////////////////////////////////////////
2   // checkstyle: Checks Java source code and other text files for adherence to a set of rules.
3   // Copyright (C) 2001-2025 the original author or authors.
4   //
5   // This library is free software; you can redistribute it and/or
6   // modify it under the terms of the GNU Lesser General Public
7   // License as published by the Free Software Foundation; either
8   // version 2.1 of the License, or (at your option) any later version.
9   //
10  // This library is distributed in the hope that it will be useful,
11  // but WITHOUT ANY WARRANTY; without even the implied warranty of
12  // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
13  // Lesser General Public License for more details.
14  //
15  // You should have received a copy of the GNU Lesser General Public
16  // License along with this library; if not, write to the Free Software
17  // Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
18  ///////////////////////////////////////////////////////////////////////////////////////////////
19  
20  package com.puppycrawl.tools.checkstyle.xpath;
21  
22  import java.util.ArrayList;
23  import java.util.List;
24  
25  import javax.annotation.Nullable;
26  
27  import com.puppycrawl.tools.checkstyle.TreeWalkerAuditEvent;
28  import com.puppycrawl.tools.checkstyle.api.DetailAST;
29  import com.puppycrawl.tools.checkstyle.api.FileText;
30  import com.puppycrawl.tools.checkstyle.utils.CommonUtil;
31  import com.puppycrawl.tools.checkstyle.utils.TokenUtil;
32  import com.puppycrawl.tools.checkstyle.utils.XpathUtil;
33  
34  /**
35   * Generates xpath queries. Xpath queries are generated based on received
36   * {@code DetailAst} element, line number, column number and token type.
37   * Token type parameter is optional.
38   *
39   * <p>
40   *     Example class
41   * </p>
42   * <pre>
43   * public class Main {
44   *
45   *     public String sayHello(String name) {
46   *         return "Hello, " + name;
47   *     }
48   * }
49   * </pre>
50   *
51   * <p>
52   *     Following expression returns list of queries. Each query is the string representing full
53   *     path to the node inside Xpath tree, whose line number is 3 and column number is 4.
54   * </p>
55   * <pre>
56   *     new XpathQueryGenerator(rootAst, 3, 4).generate();
57   * </pre>
58   *
59   * <p>
60   *     Result list
61   * </p>
62   * <ul>
63   *     <li>
64   *         /COMPILATION_UNIT/CLASS_DEF[./IDENT[@text='Main']]/OBJBLOCK/METHOD_DEF[./IDENT[@text='sayHello']]
65   *     </li>
66   *     <li>
67   *         /COMPILATION_UNIT/CLASS_DEF[./IDENT[@text='Main']]/OBJBLOCK/METHOD_DEF[./IDENT[@text='sayHello']]
68   *         /MODIFIERS
69   *     </li>
70   *     <li>
71   *         /COMPILATION_UNIT/CLASS_DEF[./IDENT[@text='Main']]/OBJBLOCK/METHOD_DEF[./IDENT[@text='sayHello']]
72   *         /MODIFIERS/LITERAL_PUBLIC
73   *     </li>
74   * </ul>
75   *
76   */
77  public class XpathQueryGenerator {
78  
79      /** The root ast. */
80      private final DetailAST rootAst;
81      /** The line number of the element for which the query should be generated. */
82      private final int lineNumber;
83      /** The column number of the element for which the query should be generated. */
84      private final int columnNumber;
85      /** The token type of the element for which the query should be generated. Optional. */
86      private final int tokenType;
87      /** The {@code FileText} object, representing content of the file. */
88      private final FileText fileText;
89      /** The distance between tab stop position. */
90      private final int tabWidth;
91  
92      /**
93       * Creates a new {@code XpathQueryGenerator} instance.
94       *
95       * @param event {@code TreeWalkerAuditEvent} object
96       * @param tabWidth distance between tab stop position
97       */
98      public XpathQueryGenerator(TreeWalkerAuditEvent event, int tabWidth) {
99          this(event.getRootAst(), event.getLine(), event.getColumn(), event.getTokenType(),
100                 event.getFileContents().getText(), tabWidth);
101     }
102 
103     /**
104      * Creates a new {@code XpathQueryGenerator} instance.
105      *
106      * @param rootAst root ast
107      * @param lineNumber line number of the element for which the query should be generated
108      * @param columnNumber column number of the element for which the query should be generated
109      * @param fileText the {@code FileText} object
110      * @param tabWidth distance between tab stop position
111      */
112     public XpathQueryGenerator(DetailAST rootAst, int lineNumber, int columnNumber,
113                                FileText fileText, int tabWidth) {
114         this(rootAst, lineNumber, columnNumber, 0, fileText, tabWidth);
115     }
116 
117     /**
118      * Creates a new {@code XpathQueryGenerator} instance.
119      *
120      * @param rootAst root ast
121      * @param lineNumber line number of the element for which the query should be generated
122      * @param columnNumber column number of the element for which the query should be generated
123      * @param tokenType token type of the element for which the query should be generated
124      * @param fileText the {@code FileText} object
125      * @param tabWidth distance between tab stop position
126      */
127     public XpathQueryGenerator(DetailAST rootAst, int lineNumber, int columnNumber, int tokenType,
128                                FileText fileText, int tabWidth) {
129         this.rootAst = rootAst;
130         this.lineNumber = lineNumber;
131         this.columnNumber = columnNumber;
132         this.tokenType = tokenType;
133         this.fileText = fileText;
134         this.tabWidth = tabWidth;
135     }
136 
137     /**
138      * Returns list of xpath queries of nodes, matching line number, column number and token type.
139      * This approach uses DetailAST traversal. DetailAST means detail abstract syntax tree.
140      *
141      * @return list of xpath queries of nodes, matching line number, column number and token type
142      */
143     public List<String> generate() {
144         return getMatchingAstElements()
145             .stream()
146             .map(XpathQueryGenerator::generateXpathQuery)
147             .toList();
148     }
149 
150     /**
151      * Returns child {@code DetailAst} element of the given root, which has text attribute.
152      *
153      * @param root {@code DetailAST} root ast
154      * @return child {@code DetailAst} element of the given root
155      */
156     @Nullable
157     private static DetailAST findChildWithTextAttribute(DetailAST root) {
158         return TokenUtil.findFirstTokenByPredicate(root,
159                 XpathUtil::supportsTextAttribute).orElse(null);
160     }
161 
162     /**
163      * Returns child {@code DetailAst} element of the given root, which has text attribute.
164      * Performs search recursively inside node's subtree.
165      *
166      * @param root {@code DetailAST} root ast
167      * @return child {@code DetailAst} element of the given root
168      */
169     @Nullable
170     private static DetailAST findChildWithTextAttributeRecursively(DetailAST root) {
171         DetailAST res = findChildWithTextAttribute(root);
172         for (DetailAST ast = root.getFirstChild(); ast != null && res == null;
173              ast = ast.getNextSibling()) {
174             res = findChildWithTextAttributeRecursively(ast);
175         }
176         return res;
177     }
178 
179     /**
180      * Returns full xpath query for given ast element.
181      *
182      * @param ast {@code DetailAST} ast element
183      * @return full xpath query for given ast element
184      */
185     public static String generateXpathQuery(DetailAST ast) {
186         final StringBuilder xpathQueryBuilder = new StringBuilder(getXpathQuery(null, ast));
187         if (!isXpathQueryForNodeIsAccurateEnough(ast)) {
188             xpathQueryBuilder.append('[');
189             final DetailAST child = findChildWithTextAttributeRecursively(ast);
190             if (child == null) {
191                 xpathQueryBuilder.append(findPositionAmongSiblings(ast));
192             }
193             else {
194                 xpathQueryBuilder.append('.').append(getXpathQuery(ast, child));
195             }
196             xpathQueryBuilder.append(']');
197         }
198         return xpathQueryBuilder.toString();
199     }
200 
201     /**
202      * Finds position of the ast element among siblings.
203      *
204      * @param ast {@code DetailAST} ast element
205      * @return position of the ast element
206      */
207     private static int findPositionAmongSiblings(DetailAST ast) {
208         DetailAST cur = ast;
209         int pos = 0;
210         while (cur != null) {
211             if (cur.getType() == ast.getType()) {
212                 pos++;
213             }
214             cur = cur.getPreviousSibling();
215         }
216         return pos;
217     }
218 
219     /**
220      * Checks if ast element has all requirements to have unique xpath query.
221      *
222      * @param ast {@code DetailAST} ast element
223      * @return true if ast element will have unique xpath query, false otherwise
224      */
225     private static boolean isXpathQueryForNodeIsAccurateEnough(DetailAST ast) {
226         return !hasAtLeastOneSiblingWithSameTokenType(ast)
227                 || XpathUtil.supportsTextAttribute(ast)
228                 || findChildWithTextAttribute(ast) != null;
229     }
230 
231     /**
232      * Returns list of nodes matching defined line number, column number and token type.
233      *
234      * @return list of nodes matching defined line number, column number and token type
235      */
236     private List<DetailAST> getMatchingAstElements() {
237         final List<DetailAST> result = new ArrayList<>();
238         DetailAST curNode = rootAst;
239         while (curNode != null) {
240             if (isMatchingByLineAndColumnAndTokenType(curNode)) {
241                 result.add(curNode);
242             }
243             DetailAST toVisit = curNode.getFirstChild();
244             while (curNode != null && toVisit == null) {
245                 toVisit = curNode.getNextSibling();
246                 curNode = curNode.getParent();
247             }
248 
249             curNode = toVisit;
250         }
251         return result;
252     }
253 
254     /**
255      * Returns relative xpath query for given ast element from root.
256      *
257      * @param root {@code DetailAST} root element
258      * @param ast {@code DetailAST} ast element
259      * @return relative xpath query for given ast element from root
260      */
261     private static String getXpathQuery(DetailAST root, DetailAST ast) {
262         final StringBuilder resultBuilder = new StringBuilder(1024);
263         DetailAST cur = ast;
264         while (cur != root) {
265             final StringBuilder curNodeQueryBuilder = new StringBuilder(256);
266             curNodeQueryBuilder.append('/')
267                     .append(TokenUtil.getTokenName(cur.getType()));
268             if (XpathUtil.supportsTextAttribute(cur)) {
269                 curNodeQueryBuilder.append("[@text='")
270                         .append(encode(XpathUtil.getTextAttributeValue(cur)))
271                         .append("']");
272             }
273             else {
274                 final DetailAST child = findChildWithTextAttribute(cur);
275                 if (child != null && child != ast) {
276                     curNodeQueryBuilder.append("[.")
277                             .append(getXpathQuery(cur, child))
278                             .append(']');
279                 }
280             }
281 
282             resultBuilder.insert(0, curNodeQueryBuilder);
283             cur = cur.getParent();
284         }
285         return resultBuilder.toString();
286     }
287 
288     /**
289      * Checks if the given ast element has unique {@code TokenTypes} among siblings.
290      *
291      * @param ast {@code DetailAST} ast element
292      * @return if the given ast element has unique {@code TokenTypes} among siblings
293      */
294     private static boolean hasAtLeastOneSiblingWithSameTokenType(DetailAST ast) {
295         boolean result = false;
296         DetailAST prev = ast.getPreviousSibling();
297         while (prev != null) {
298             if (prev.getType() == ast.getType()) {
299                 result = true;
300                 break;
301             }
302             prev = prev.getPreviousSibling();
303         }
304         DetailAST next = ast.getNextSibling();
305         while (next != null) {
306             if (next.getType() == ast.getType()) {
307                 result = true;
308                 break;
309             }
310             next = next.getNextSibling();
311         }
312         return result;
313     }
314 
315     /**
316      * Returns the column number with tabs expanded.
317      *
318      * @param ast {@code DetailAST} root ast
319      * @return the column number with tabs expanded
320      */
321     private int expandedTabColumn(DetailAST ast) {
322         return 1 + CommonUtil.lengthExpandedTabs(fileText.get(lineNumber - 1),
323                 ast.getColumnNo(), tabWidth);
324     }
325 
326     /**
327      * Checks if the given {@code DetailAST} node is matching line number, column number and token
328      * type.
329      *
330      * @param ast {@code DetailAST} ast element
331      * @return true if the given {@code DetailAST} node is matching
332      */
333     private boolean isMatchingByLineAndColumnAndTokenType(DetailAST ast) {
334         return ast.getLineNo() == lineNumber
335                 && expandedTabColumn(ast) == columnNumber
336                 && (tokenType == 0 || tokenType == ast.getType());
337     }
338 
339     /**
340      * Escape &lt;, &gt;, &amp;, &#39; and &quot; as their entities.
341      * Custom method for Xpath generation to maintain compatibility
342      * with Saxon and encoding outside Ascii range characters.
343      *
344      * <p>According to
345      * <a href="https://saxon.sourceforge.net/saxon7.1/expressions.html">Saxon documentation</a>:
346      * <br>
347      * From Saxon 7.1, string delimiters can be doubled within the string to represent`
348      * the delimiter itself: for example select='"He said, ""Go!"""'.
349      *
350      * <p>Guava cannot as Guava encoding does not meet our requirements like
351      * double encoding for apos, removed slashes which are basic requirements
352      * for Saxon to decode.
353      *
354      * @param value the value to escape.
355      * @return the escaped value if necessary.
356      */
357     private static String encode(String value) {
358         final StringBuilder sb = new StringBuilder(256);
359         value.codePoints().forEach(
360             chr -> {
361                 sb.append(encodeCharacter(Character.toChars(chr)[0]));
362             }
363         );
364         return sb.toString();
365     }
366 
367     /**
368      * Encodes escape character for Xpath. Escape characters need '&amp;' before, but it also
369      * requires XML 1.1
370      * until <a href="https://github.com/checkstyle/checkstyle/issues/5168">#5168</a>.
371      *
372      * @param chr Character to check.
373      * @return String, Encoded string.
374      */
375     private static String encodeCharacter(char chr) {
376         return switch (chr) {
377             case '<' -> "&lt;";
378             case '>' -> "&gt;";
379             case '\'' -> "&apos;&apos;";
380             case '\"' -> "&quot;";
381             case '&' -> "&amp;";
382             default -> String.valueOf(chr);
383         };
384     }
385 }