From fd543c9fad60b6240a7a755b4e8518ee72067f13 Mon Sep 17 00:00:00 2001 From: Suby S Surendran Date: Wed, 3 Dec 2025 15:33:57 +0530 Subject: [PATCH] Markdown Javadoc links not interpreted properly Markdown links [description](url) syntax not interpreting properly Fix: https://github.com/eclipse-jdt/eclipse.jdt.core/issues/4531 --- .../parser/AbstractCommentParser.java | 35 +++ .../compiler/parser/JavadocParser.java | 5 +- .../tests/dom/ASTConverterMarkdownTest.java | 246 ++++++++++++++++++ .../jdt/core/dom/DocCommentParser.java | 99 ++++--- 4 files changed, 342 insertions(+), 43 deletions(-) diff --git a/org.eclipse.jdt.core.compiler.batch/src/org/eclipse/jdt/internal/compiler/parser/AbstractCommentParser.java b/org.eclipse.jdt.core.compiler.batch/src/org/eclipse/jdt/internal/compiler/parser/AbstractCommentParser.java index 9d6b7e10d24..392c9f35914 100644 --- a/org.eclipse.jdt.core.compiler.batch/src/org/eclipse/jdt/internal/compiler/parser/AbstractCommentParser.java +++ b/org.eclipse.jdt.core.compiler.batch/src/org/eclipse/jdt/internal/compiler/parser/AbstractCommentParser.java @@ -1398,6 +1398,40 @@ protected boolean parseReference() throws InvalidInputException { return parseReference(false); } + // Parses a complete URL reference starting from current position + protected boolean parseURLReference(int pos, boolean advanceEndPos) throws InvalidInputException { + char[]fullURL = null; + int firstTokenStartPos; + StringBuilder urlBuilder = new StringBuilder(); + char c; + firstTokenStartPos = pos; + while (pos < this.source.length) { + c = this.source[pos]; + if (c == '[') // invalid syntax for url + return false; + if (c == '(' || c == ' ') { + pos++; + continue; + } + if (c == '\n' || c == '\r' || c == ')') { + break; + } + urlBuilder.append(c); + pos++; + } + if (advanceEndPos) + this.index = pos; + fullURL = urlBuilder.toString().toCharArray(); + + this.identifierPtr = 0; + this.identifierStack[this.identifierPtr] = fullURL; + this.identifierPositionStack[this.identifierPtr] = (((long) firstTokenStartPos) << 32) + (pos - 1); + this.identifierLengthStack[this.identifierLengthPtr] = 1; + Object typeRef = createTypeReference(TerminalToken.TokenNameInvalid, true); + pushSeeRef(typeRef); + return true; + } + /* * Parse a reference in @see tag */ @@ -3617,6 +3651,7 @@ protected boolean verifySpaceOrEndComment() { // Whitespace or inline tag closing brace char ch = peekChar(); switch (ch) { + case ')': case ']': // TODO: Check if we need to exclude escaped ] if (this.markdown) diff --git a/org.eclipse.jdt.core.compiler.batch/src/org/eclipse/jdt/internal/compiler/parser/JavadocParser.java b/org.eclipse.jdt.core.compiler.batch/src/org/eclipse/jdt/internal/compiler/parser/JavadocParser.java index 4b52bc978ef..9bb5ae6c782 100644 --- a/org.eclipse.jdt.core.compiler.batch/src/org/eclipse/jdt/internal/compiler/parser/JavadocParser.java +++ b/org.eclipse.jdt.core.compiler.batch/src/org/eclipse/jdt/internal/compiler/parser/JavadocParser.java @@ -581,6 +581,8 @@ protected boolean parseMarkdownLinks(int previousPosition) throws InvalidInputEx // move it past '[' currentChar = readChar(); start = this.index; + } else if (peekChar() == '(') { + valid = parseURLReference(this.index, true); } else { break loop; } @@ -602,7 +604,8 @@ protected boolean parseMarkdownLinks(int previousPosition) throws InvalidInputEx int eofBkup = this.scanner.eofPosition; this.scanner.resetTo(start, Math.max(this.javadocEnd, this.index)); this.tagValue = TAG_LINK_VALUE; - valid = parseReference(true); + if (!valid) + valid = parseReference(true); this.tagValue = NO_TAG_VALUE; this.scanner.eofPosition = eofBkup; this.markdownHelper.resetLineStart(); diff --git a/org.eclipse.jdt.core.tests.model/src/org/eclipse/jdt/core/tests/dom/ASTConverterMarkdownTest.java b/org.eclipse.jdt.core.tests.model/src/org/eclipse/jdt/core/tests/dom/ASTConverterMarkdownTest.java index d0a61ac81a2..5f991bfb4b1 100644 --- a/org.eclipse.jdt.core.tests.model/src/org/eclipse/jdt/core/tests/dom/ASTConverterMarkdownTest.java +++ b/org.eclipse.jdt.core.tests.model/src/org/eclipse/jdt/core/tests/dom/ASTConverterMarkdownTest.java @@ -1931,4 +1931,250 @@ public class Markdown() {} assertEquals("Incorrect TextElement value", "Where is my **bold text**???", textElement.getText()); } } + public void testMarkdownURLs4531_01() throws JavaModelException { + String source = """ + /// @see [Ex Si](ex.com) + public class Markdown() {} + """; + this.workingCopies = new ICompilationUnit[1]; + this.workingCopies[0] = getWorkingCopy("/Converter_25/src/markdown/gh3761/Markdown.java", source, null); + if (this.docCommentSupport.equals(JavaCore.ENABLED)) { + CompilationUnit compilUnit = (CompilationUnit) runConversion(this.workingCopies[0], true); + TypeDeclaration typedeclaration = (TypeDeclaration) compilUnit.types().get(0); + Javadoc javadoc = typedeclaration.getJavadoc(); + TagElement tags = (TagElement) javadoc.tags().get(0); + assertEquals("fragments count does not match", 2, tags.fragments().size()); + TagElement tagElement = (TagElement) tags.fragments().get(1); + List tagFragments = tagElement.fragments(); + assertTrue(tagFragments.get(0) instanceof TextElement); + assertTrue(tagFragments.get(1) instanceof SimpleName); + } + } + + public void testMarkdownURLs4531_02() throws JavaModelException { + String source = """ + /// @see [Ex Si](http://ex.com) + public class Markdown() {} + """; + this.workingCopies = new ICompilationUnit[1]; + this.workingCopies[0] = getWorkingCopy("/Converter_25/src/markdown/gh3761/Markdown.java", source, null); + if (this.docCommentSupport.equals(JavaCore.ENABLED)) { + CompilationUnit compilUnit = (CompilationUnit) runConversion(this.workingCopies[0], true); + TypeDeclaration typedeclaration = (TypeDeclaration) compilUnit.types().get(0); + Javadoc javadoc = typedeclaration.getJavadoc(); + TagElement tags = (TagElement) javadoc.tags().get(0); + assertEquals("fragments count does not match", 2, tags.fragments().size()); + TagElement tagElement = (TagElement) tags.fragments().get(1); + List tagFragments = tagElement.fragments(); + assertTrue(tagFragments.get(0) instanceof TextElement); + assertTrue(tagFragments.get(1) instanceof SimpleName); + } + } + + public void testMarkdownURLs4531_03() throws JavaModelException { + String source = """ + /// @see [Ex Si](https://ex.com/a) + public class Markdown() {} + """; + this.workingCopies = new ICompilationUnit[1]; + this.workingCopies[0] = getWorkingCopy("/Converter_25/src/markdown/gh3761/Markdown.java", source, null); + if (this.docCommentSupport.equals(JavaCore.ENABLED)) { + CompilationUnit compilUnit = (CompilationUnit) runConversion(this.workingCopies[0], true); + TypeDeclaration typedeclaration = (TypeDeclaration) compilUnit.types().get(0); + Javadoc javadoc = typedeclaration.getJavadoc(); + TagElement tags = (TagElement) javadoc.tags().get(0); + assertEquals("fragments count does not match", 2, tags.fragments().size()); + TagElement tagElement = (TagElement) tags.fragments().get(1); + List tagFragments = tagElement.fragments(); + assertTrue(tagFragments.get(0) instanceof TextElement); + assertTrue(tagFragments.get(1) instanceof SimpleName); + } + } + + public void testMarkdownURLs4531_04() throws JavaModelException { + String source = """ + /// @see [Ex Si](https://www.ex.net/a) + public class Markdown() {} + """; + this.workingCopies = new ICompilationUnit[1]; + this.workingCopies[0] = getWorkingCopy("/Converter_25/src/markdown/gh3761/Markdown.java", source, null); + if (this.docCommentSupport.equals(JavaCore.ENABLED)) { + CompilationUnit compilUnit = (CompilationUnit) runConversion(this.workingCopies[0], true); + TypeDeclaration typedeclaration = (TypeDeclaration) compilUnit.types().get(0); + Javadoc javadoc = typedeclaration.getJavadoc(); + TagElement tags = (TagElement) javadoc.tags().get(0); + assertEquals("fragments count does not match", 2, tags.fragments().size()); + TagElement tagElement = (TagElement) tags.fragments().get(1); + List tagFragments = tagElement.fragments(); + assertTrue(tagFragments.get(0) instanceof TextElement); + assertTrue(tagFragments.get(1) instanceof SimpleName); + } + } + + // invalid syntax + public void testMarkdownURLs4531_05() throws JavaModelException { + String source = """ + /// @see [Ex Si][http://ex.com] + public class Markdown() {} + """; + this.workingCopies = new ICompilationUnit[1]; + this.workingCopies[0] = getWorkingCopy("/Converter_25/src/markdown/gh3761/Markdown.java", source, null); + if (this.docCommentSupport.equals(JavaCore.ENABLED)) { + CompilationUnit compilUnit = (CompilationUnit) runConversion(this.workingCopies[0], true); + TypeDeclaration typedeclaration = (TypeDeclaration) compilUnit.types().get(0); + Javadoc javadoc = typedeclaration.getJavadoc(); + TagElement tags = (TagElement) javadoc.tags().get(0); + assertEquals("fragments count does not match", 3, tags.fragments().size()); + assertTrue(tags.fragments().get(0) instanceof TextElement); + assertTrue(tags.fragments().get(1) instanceof TextElement); + assertTrue(tags.fragments().get(2) instanceof TextElement); + } + } + + // [)[) - invalid condition + public void testMarkdownURLs4531_06() throws JavaModelException { + String source = """ + /// @see [Ex Si)[http://ex.com) + public class Markdown() {} + """; + this.workingCopies = new ICompilationUnit[1]; + this.workingCopies[0] = getWorkingCopy("/Converter_25/src/markdown/gh3761/Markdown.java", source, null); + if (this.docCommentSupport.equals(JavaCore.ENABLED)) { + CompilationUnit compilUnit = (CompilationUnit) runConversion(this.workingCopies[0], true); + TypeDeclaration typedeclaration = (TypeDeclaration) compilUnit.types().get(0); + Javadoc javadoc = typedeclaration.getJavadoc(); + assertEquals("Tags count does not match", 1, javadoc.tags().size()); + TagElement tags = (TagElement) javadoc.tags().get(0); + assertTrue(tags.fragments().get(0) instanceof TextElement); + assertTrue(tags.fragments().get(1) instanceof TextElement); + } + } + + // (](] - invalid condition + public void testMarkdownURLs4531_07() throws JavaModelException { + String source = """ + /// @see (Ex Si](http://ex.com] + public class Markdown() {} + """; + this.workingCopies = new ICompilationUnit[1]; + this.workingCopies[0] = getWorkingCopy("/Converter_25/src/markdown/gh3761/Markdown.java", source, null); + if (this.docCommentSupport.equals(JavaCore.ENABLED)) { + CompilationUnit compilUnit = (CompilationUnit) runConversion(this.workingCopies[0], true); + TypeDeclaration typedeclaration = (TypeDeclaration) compilUnit.types().get(0); + Javadoc javadoc = typedeclaration.getJavadoc(); + TagElement tags = (TagElement) javadoc.tags().get(0); + assertEquals("fragments count does not match", 1, tags.fragments().size()); + assertTrue(tags.fragments().get(0) instanceof TextElement); + } + } + + // ()[] - invalid condition + public void testMarkdownURLs4531_08() throws JavaModelException { + String source = """ + /// @see (Ex Si)[http://ex.com] + public class Markdown() {} + """; + this.workingCopies = new ICompilationUnit[1]; + this.workingCopies[0] = getWorkingCopy("/Converter_25/src/markdown/gh3761/Markdown.java", source, null); + if (this.docCommentSupport.equals(JavaCore.ENABLED)) { + CompilationUnit compilUnit = (CompilationUnit) runConversion(this.workingCopies[0], true); + TypeDeclaration typedeclaration = (TypeDeclaration) compilUnit.types().get(0); + Javadoc javadoc = typedeclaration.getJavadoc(); + TagElement tags = (TagElement) javadoc.tags().get(0); + assertEquals("fragments count does not match", 2, tags.fragments().size()); + assertTrue(tags.fragments().get(0) instanceof TextElement); + assertTrue(tags.fragments().get(1) instanceof TextElement); + } + } + + public void testMarkdownURLs4531_09() throws JavaModelException { + String source = """ + /// @see [Ex Si]( http://ex.com) + public class Markdown() {} + """; + this.workingCopies = new ICompilationUnit[1]; + this.workingCopies[0] = getWorkingCopy("/Converter_25/src/markdown/gh3761/Markdown.java", source, null); + if (this.docCommentSupport.equals(JavaCore.ENABLED)) { + CompilationUnit compilUnit = (CompilationUnit) runConversion(this.workingCopies[0], true); + TypeDeclaration typedeclaration = (TypeDeclaration) compilUnit.types().get(0); + Javadoc javadoc = typedeclaration.getJavadoc(); + TagElement tags = (TagElement) javadoc.tags().get(0); + assertEquals("fragments count does not match", 2, tags.fragments().size()); + assertTrue(tags.fragments().get(0) instanceof TextElement); + assertTrue(tags.fragments().get(1) instanceof TagElement); + TagElement fragTag = (TagElement) tags.fragments().get(1); + assertTrue(fragTag.fragments().get(0) instanceof TextElement); + assertTrue(fragTag.fragments().get(1) instanceof SimpleName); + } + } + + public void testMarkdownURLs4531_10() throws JavaModelException { + String source = """ + /// @see [Ex Si][java.lang.String] + public class Markdown() {} + """; + + this.workingCopies = new ICompilationUnit[1]; + this.workingCopies[0] = getWorkingCopy("/Converter_25/src/markdown/gh3761/Markdown.java", source, null); + if (this.docCommentSupport.equals(JavaCore.ENABLED)) { + CompilationUnit compilUnit = (CompilationUnit) runConversion(this.workingCopies[0], true); + TypeDeclaration typedeclaration = (TypeDeclaration) compilUnit.types().get(0); + Javadoc javadoc = typedeclaration.getJavadoc(); + TagElement tags = (TagElement) javadoc.tags().get(0); + assertEquals("fragments count does not match", 2, tags.fragments().size()); + assertTrue(tags.fragments().get(0) instanceof TextElement); + assertTrue(tags.fragments().get(1) instanceof TagElement); + TagElement fragTag = (TagElement) tags.fragments().get(1); + assertTrue(fragTag.fragments().get(0) instanceof TextElement); + assertTrue(fragTag.fragments().get(1) instanceof QualifiedName); + } + } + + public void testMarkdownURLs4531_11() throws JavaModelException { + String source = """ + /// @see [Ex Si][ java.lang.String] + public class Markdown() {} + """; + + this.workingCopies = new ICompilationUnit[1]; + this.workingCopies[0] = getWorkingCopy("/Converter_25/src/markdown/gh3761/Markdown.java", source, null); + if (this.docCommentSupport.equals(JavaCore.ENABLED)) { + CompilationUnit compilUnit = (CompilationUnit) runConversion(this.workingCopies[0], true); + TypeDeclaration typedeclaration = (TypeDeclaration) compilUnit.types().get(0); + Javadoc javadoc = typedeclaration.getJavadoc(); + TagElement tags = (TagElement) javadoc.tags().get(0); + assertEquals("fragments count does not match", 2, tags.fragments().size()); + assertTrue(tags.fragments().get(0) instanceof TextElement); + assertTrue(tags.fragments().get(1) instanceof TagElement); + TagElement fragTag = (TagElement) tags.fragments().get(1); + assertTrue(fragTag.fragments().get(0) instanceof TextElement); + assertTrue(fragTag.fragments().get(1) instanceof QualifiedName); + } + } + + public void testMarkdownURLs4531_12() throws JavaModelException { + String source = """ + /// @see [Ex Si](http://ex.com ) [Ex Si]( https://www.ex.com) + public class Markdown() {} + """; + this.workingCopies = new ICompilationUnit[1]; + this.workingCopies[0] = getWorkingCopy("/Converter_25/src/markdown/gh3761/Markdown.java", source, null); + if (this.docCommentSupport.equals(JavaCore.ENABLED)) { + CompilationUnit compilUnit = (CompilationUnit) runConversion(this.workingCopies[0], true); + TypeDeclaration typedeclaration = (TypeDeclaration) compilUnit.types().get(0); + Javadoc javadoc = typedeclaration.getJavadoc(); + TagElement tags = (TagElement) javadoc.tags().get(0); + assertEquals("fragments count does not match", 4, tags.fragments().size()); + assertTrue(tags.fragments().get(0) instanceof TextElement); + assertTrue(tags.fragments().get(1) instanceof TagElement); + TagElement tagFrag1 = (TagElement) tags.fragments().get(1); + SimpleName simplName1 = (SimpleName) tagFrag1.fragments().get(1); + assertEquals("SimpleName1 value not match", "http://ex.com", simplName1.getIdentifier()); + assertTrue(tags.fragments().get(0) instanceof TextElement); + assertTrue(tags.fragments().get(1) instanceof TagElement); + TagElement tagFrag2 = (TagElement) tags.fragments().get(3); + SimpleName simplName2 = (SimpleName) tagFrag2.fragments().get(1); + assertEquals("SimpleName2 value not match", "https://www.ex.com", simplName2.getIdentifier()); + } + } } diff --git a/org.eclipse.jdt.core/dom/org/eclipse/jdt/core/dom/DocCommentParser.java b/org.eclipse.jdt.core/dom/org/eclipse/jdt/core/dom/DocCommentParser.java index 8031b76aa39..2020a553a8b 100644 --- a/org.eclipse.jdt.core/dom/org/eclipse/jdt/core/dom/DocCommentParser.java +++ b/org.eclipse.jdt.core/dom/org/eclipse/jdt/core/dom/DocCommentParser.java @@ -733,53 +733,20 @@ protected boolean parseMarkdownLinks(int previousPosition) throws InvalidInputEx readChar(); } break; + case ')': + if (peekChar() == '\n' || peekChar() == ' ') { + valid = parseMarkdownLinkTags(true, start, previousPosition, tStart, tEnd); + break loop; + } + break; case ']': - if (peekChar() == '[') { + if ((peekChar() == '[' ) || peekChar() == '(') { tStart = start; tEnd = this.index - 1; currentChar = readChar(); start = this.index; - } else { - int eofBkup = this.scanner.eofPosition; - this.scanner.eofPosition = this.index - 1; - this.scanner.resetTo(start, this.javadocEnd); - this.inlineTagStarted = true; - this.inlineTagStart = previousPosition; - this.tagValue = TAG_LINK_VALUE; - int indexBkup = this.index; - valid = parseReference(true); - this.index = indexBkup; - // This creates a two level structure. The @link tag is added to - // another tag element, which gets added to the astStack - // Both tag elements must get the same source range. - TagElement previousTag = (TagElement) this.astStack[this.astPtr]; - int parentStart = previousTag.getStartPosition(); - previousTag.setSourceRange(parentStart, this.index - parentStart); - List fragments = previousTag.fragments(); - int size = fragments.size(); - if (size == 0) { - // no existing fragment => just add the element - TagElement inlineTag = this.ast.newTagElement(); - fragments.add(inlineTag); - previousTag = inlineTag; - } else { - // If last fragment is a tag, then use it as previous tag - ASTNode lastFragment = (ASTNode) fragments.get(size-1); - if (lastFragment.getNodeType() == ASTNode.TAG_ELEMENT) { - lastFragment.setSourceRange(lastFragment.getStartPosition(), this.index - previousPosition); - previousTag = (TagElement) lastFragment; - } - } - if (tEnd != -1) { - TextElement text = this.ast.newTextElement(); - text.setText(new String( this.source, tStart, tEnd-tStart)); - text.setSourceRange(tStart, tEnd-tStart); - previousTag.fragments().add(0, text); - } - this.tagValue = NO_TAG_VALUE; - this.inlineTagStarted = false; - this.inlineTagStart = -1; - this.scanner.eofPosition = eofBkup; + } else if (peekChar() != ']') { + valid = parseMarkdownLinkTags(false, start, previousPosition, tStart, tEnd); break loop; } break; @@ -795,6 +762,54 @@ protected boolean parseMarkdownLinks(int previousPosition) throws InvalidInputEx return valid; } + private boolean parseMarkdownLinkTags(boolean refFlag, int start, int previousPosition, int tStart, int tEnd ) throws InvalidInputException { + boolean valid = false; + int eofBkup = this.scanner.eofPosition; + this.scanner.eofPosition = this.index - 1; + this.scanner.resetTo(start, this.javadocEnd); + this.inlineTagStarted = true; + this.inlineTagStart = previousPosition; + this.tagValue = TAG_LINK_VALUE; + int indexBkup = this.index; + if (refFlag) + valid = parseURLReference(this.scanner.startPosition - 1, false); + else + valid = parseReference(true); + this.index = indexBkup; + // This creates a two level structure. The @link tag is added to + // another tag element, which gets added to the astStack + // Both tag elements must get the same source range. + TagElement previousTag = (TagElement) this.astStack[this.astPtr]; + int parentStart = previousTag.getStartPosition(); + previousTag.setSourceRange(parentStart, this.index - parentStart); + List fragments = previousTag.fragments(); + int size = fragments.size(); + if (size == 0) { + // no existing fragment => just add the element + TagElement inlineTag = this.ast.newTagElement(); + fragments.add(inlineTag); + previousTag = inlineTag; + } else { + // If last fragment is a tag, then use it as previous tag + ASTNode lastFragment = (ASTNode) fragments.get(size-1); + if (lastFragment.getNodeType() == ASTNode.TAG_ELEMENT) { + lastFragment.setSourceRange(lastFragment.getStartPosition(), this.index - previousPosition); + previousTag = (TagElement) lastFragment; + } + } + if (tEnd != -1) { + TextElement text = this.ast.newTextElement(); + text.setText(new String( this.source, tStart, tEnd-tStart)); + text.setSourceRange(tStart, tEnd-tStart); + previousTag.fragments().add(0, text); + } + this.tagValue = NO_TAG_VALUE; + this.inlineTagStarted = false; + this.inlineTagStart = -1; + this.scanner.eofPosition = eofBkup; + return valid; + } + @Override protected boolean parseTag(int previousPosition) throws InvalidInputException { this.markdownHelper.resetLineStart();