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.ArrayDeque;
023import java.util.Deque;
024import java.util.HashMap;
025import java.util.HashSet;
026import java.util.Map;
027import java.util.Set;
028
029import com.puppycrawl.tools.checkstyle.FileStatefulCheck;
030import com.puppycrawl.tools.checkstyle.api.DetailAST;
031import com.puppycrawl.tools.checkstyle.api.DetailNode;
032import com.puppycrawl.tools.checkstyle.api.FullIdent;
033import com.puppycrawl.tools.checkstyle.api.JavadocCommentsTokenTypes;
034import com.puppycrawl.tools.checkstyle.api.TokenTypes;
035import com.puppycrawl.tools.checkstyle.utils.JavadocUtil;
036import com.puppycrawl.tools.checkstyle.utils.NullUtil;
037
038/**
039 * <div>
040 * Checks that in Javadoc comments, each API name is linked with
041 * {@code {@link}} or {@code {@linkplain}} only on its first occurrence.
042 * Subsequent links to the same API name in the same comment are flagged.
043 * </div>
044 *
045 * <p>
046 * Rationale: From the
047 * <a href="https://www.oracle.com/technical-resources/articles/java/javadoc-tool.html">
048 * Documentation Comments style guide</a>, links call attention to
049 * themselves by their color and underline in HTML, and by their length
050 * in source code doc comments. Linking the same name multiple times
051 * is redundant.
052 * </p>
053 *
054 * <p>
055 * Two links are considered to reference the same API name if they resolve to
056 * the same canonical name. Simple names are resolved through explicit imports,
057 * types declared in the current file, star imports and the
058 * {@code java.lang} package. Names containing dots are resolved through
059 * imports of their outermost segment; otherwise they are compared as written.
060 * </p>
061 *
062 * @since 14.1.0
063 */
064@FileStatefulCheck
065public class JavadocLinkFirstOccurrenceCheck extends AbstractJavadocCheck {
066
067    /**
068     * A key is pointing to the warning message text in "messages.properties"
069     * file.
070     */
071    public static final String MSG_KEY = "javadoc.link.first.occurrence";
072
073    /**
074     * Dot.
075     */
076    private static final char DOT = '.';
077
078    /**
079     * Tokens of type declarations.
080     */
081    private static final Set<Integer> TYPE_DECLARATION_TOKENS = Set.of(
082            TokenTypes.CLASS_DEF,
083            TokenTypes.INTERFACE_DEF,
084            TokenTypes.ENUM_DEF,
085            TokenTypes.RECORD_DEF,
086            TokenTypes.ANNOTATION_DEF
087    );
088
089    /**
090     * Set of reference keys already seen in the current Javadoc comment.
091     */
092    private final Set<String> linkedNames = new HashSet<>();
093
094    /**
095     * Map of imported simple names to fully qualified names.
096     */
097    private Map<String, String> importedNames;
098
099    /**
100     * Set of star import base packages.
101     */
102    private Set<String> starImports;
103
104    /**
105     * Set of simple names of types declared in the current file.
106     */
107    private Set<String> declaredTypeNames;
108
109    /**
110     * Creates a new {@code JavadocLinkFirstOccurrenceCheck} instance.
111     */
112    public JavadocLinkFirstOccurrenceCheck() {
113        // no code by default
114    }
115
116    @Override
117    public int[] getRequiredTokens() {
118        return new int[] {
119            TokenTypes.BLOCK_COMMENT_BEGIN,
120            TokenTypes.IMPORT,
121        };
122    }
123
124    @Override
125    public int[] getDefaultJavadocTokens() {
126        return getRequiredJavadocTokens();
127    }
128
129    @Override
130    public int[] getRequiredJavadocTokens() {
131        return new int[] {
132            JavadocCommentsTokenTypes.LINK_INLINE_TAG,
133            JavadocCommentsTokenTypes.LINKPLAIN_INLINE_TAG,
134        };
135    }
136
137    @Override
138    public void beginTree(DetailAST rootAST) {
139        super.beginTree(rootAST);
140        importedNames = new HashMap<>();
141        starImports = new HashSet<>();
142        declaredTypeNames = new HashSet<>();
143        if (rootAST != null) {
144            collectDeclaredTypeNames(rootAST);
145        }
146    }
147
148    @Override
149    public void beginJavadocTree(DetailNode rootAst) {
150        linkedNames.clear();
151    }
152
153    @Override
154    public void visitToken(DetailAST ast) {
155        if (ast.getType() == TokenTypes.IMPORT) {
156            handleImport(ast);
157        }
158        else {
159            super.visitToken(ast);
160        }
161    }
162
163    @Override
164    public void visitJavadocToken(DetailNode ast) {
165        final DetailNode reference = JavadocUtil.findFirstToken(ast,
166                JavadocCommentsTokenTypes.REFERENCE);
167        final String originalReference = getNodeText(reference);
168        final String resolvedKey = resolveReference(originalReference);
169        if (!linkedNames.add(resolvedKey)) {
170            log(ast, MSG_KEY, originalReference);
171        }
172    }
173
174    /**
175     * Processes import statements and records imported names.
176     *
177     * @param ast import node
178     */
179    private void handleImport(DetailAST ast) {
180        final String importText = FullIdent.createFullIdentBelow(ast).getText();
181        if (importText.endsWith(".*")) {
182            starImports.add(importText.substring(0, importText.length() - 2));
183        }
184        else {
185            final int lastDot = importText.lastIndexOf(DOT);
186            final String simple = importText.substring(lastDot + 1);
187            importedNames.put(simple, importText);
188        }
189    }
190
191    /**
192     * Records the simple names of all type declarations.
193     * The whole tree is scanned so that types declared after their
194     * references are also taken into account.
195     *
196     * @param rootAST the root of the tree to scan
197     */
198    private void collectDeclaredTypeNames(DetailAST rootAST) {
199        final Deque<DetailAST> stack = new ArrayDeque<>();
200        stack.push(rootAST);
201        while (!stack.isEmpty()) {
202            final DetailAST ast = stack.pop();
203            if (TYPE_DECLARATION_TOKENS.contains(ast.getType())) {
204                final DetailAST ident =
205                        NullUtil.notNull(ast.findFirstToken(TokenTypes.IDENT));
206                declaredTypeNames.add(ident.getText());
207            }
208            DetailAST child = ast.getFirstChild();
209            while (child != null) {
210                stack.push(child);
211                child = child.getNextSibling();
212            }
213        }
214    }
215
216    /**
217     * Resolves a reference text to a canonical key for identity comparison.
218     * The class part of the reference is resolved through imports and
219     * types declared in the current file.
220     *
221     * @param reference the raw reference text
222     * @return the resolved identity key
223     */
224    private String resolveReference(String reference) {
225        final int hashIndex = reference.indexOf('#');
226        final String className;
227        final String memberPart;
228        if (hashIndex == -1) {
229            className = reference;
230            memberPart = "";
231        }
232        else {
233            className = reference.substring(0, hashIndex);
234            memberPart = reference.substring(hashIndex);
235        }
236        final String resolved = resolveClass(className);
237        return resolved + memberPart;
238    }
239
240    /**
241     * Resolves a class name to its canonical name through imports,
242     * types declared in the current file, star imports and the
243     * {@code java.lang} package. Names containing dots have only their
244     * outermost segment resolved; otherwise they are returned unchanged.
245     *
246     * @param name the class name
247     * @return the resolved canonical name
248     */
249    private String resolveClass(String name) {
250        final int dotIndex = name.indexOf(DOT);
251        final String result;
252        if (dotIndex == -1) {
253            result = resolveSimpleClassName(name);
254        }
255        else {
256            result = resolveOuterSegment(name, dotIndex);
257        }
258        return result;
259    }
260
261    /**
262     * Resolves a simple class name through imports, types declared in the
263     * current file, star imports and the {@code java.lang} package.
264     *
265     * @param name the simple class name
266     * @return the resolved canonical name
267     */
268    private String resolveSimpleClassName(String name) {
269        final String importCandidate = importedNames.get(name);
270        final String result;
271        if (importCandidate != null) {
272            result = importCandidate;
273        }
274        else if (declaredTypeNames.contains(name)) {
275            result = name;
276        }
277        else if (starImports.isEmpty()) {
278            result = "java.lang." + name;
279        }
280        else {
281            result = starImports.iterator().next() + DOT + name;
282        }
283        return result;
284    }
285
286    /**
287     * Resolves the outermost segment of a dotted class name through imports,
288     * keeping the remainder unchanged.
289     *
290     * @param name the dotted class name
291     * @param dotIndex the index of the first dot in the name
292     * @return the resolved canonical name
293     */
294    private String resolveOuterSegment(String name, int dotIndex) {
295        final String outer = name.substring(0, dotIndex);
296        final String importCandidate = importedNames.get(outer);
297        final String result;
298        if (importCandidate != null) {
299            final String remainder = name.substring(dotIndex);
300            result = importCandidate + remainder;
301        }
302        else {
303            result = name;
304        }
305        return result;
306    }
307
308    /**
309     * Recursively builds the full text of a node by concatenating
310     * the text of all its leaf descendants.
311     *
312     * @param node the node to get text from
313     * @return the concatenated text, or null if the node is null
314     */
315    private static String getNodeText(DetailNode node) {
316        final StringBuilder sb = new StringBuilder(256);
317        DetailNode child = node.getFirstChild();
318        while (child != null) {
319            sb.append(getNodeText(child));
320            child = child.getNextSibling();
321        }
322        final String text;
323        if (sb.isEmpty()) {
324            text = node.getText();
325        }
326        else {
327            text = sb.toString();
328        }
329        return text;
330    }
331
332}