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