diff --git a/RSTALanguageSupport/build.gradle b/RSTALanguageSupport/build.gradle index 87f8cc33..6130bc7a 100644 --- a/RSTALanguageSupport/build.gradle +++ b/RSTALanguageSupport/build.gradle @@ -8,6 +8,8 @@ dependencies { api 'com.fifesoft:rsyntaxtextarea:3.6.1' api 'com.fifesoft:autocomplete:3.3.3' implementation 'org.mozilla:rhino-all:1.9.0' + testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' } base { @@ -30,6 +32,7 @@ jar { } } test { + useJUnitPlatform() testLogging { events 'failed' //, 'passed', 'skipped', 'standardOut', 'standardError' diff --git a/RSTALanguageSupport/src/main/java/org/fife/rsta/ac/java/ClassCompletion.java b/RSTALanguageSupport/src/main/java/org/fife/rsta/ac/java/ClassCompletion.java index c5cba360..e855a4f7 100644 --- a/RSTALanguageSupport/src/main/java/org/fife/rsta/ac/java/ClassCompletion.java +++ b/RSTALanguageSupport/src/main/java/org/fife/rsta/ac/java/ClassCompletion.java @@ -181,6 +181,16 @@ public String getSummary() { } + /** + * Returns whether this class is an annotation type (has ACC_ANNOTATION flag). + * + * @return Whether this is an annotation type. + */ + public boolean isAnnotationType() { + return (cf.getAccessFlags() & AccessFlags.ACC_ANNOTATION) != 0; + } + + @Override public String getToolTipText() { return "class " + getReplacementText(); diff --git a/RSTALanguageSupport/src/main/java/org/fife/rsta/ac/java/SourceCompletionProvider.java b/RSTALanguageSupport/src/main/java/org/fife/rsta/ac/java/SourceCompletionProvider.java index 51ff1102..6b94b64b 100644 --- a/RSTALanguageSupport/src/main/java/org/fife/rsta/ac/java/SourceCompletionProvider.java +++ b/RSTALanguageSupport/src/main/java/org/fife/rsta/ac/java/SourceCompletionProvider.java @@ -29,6 +29,7 @@ import org.fife.rsta.ac.ShorthandCompletionCache; import org.fife.rsta.ac.java.buildpath.LibraryInfo; import org.fife.rsta.ac.java.buildpath.SourceLocation; +import org.fife.rsta.ac.java.classreader.AccessFlags; import org.fife.rsta.ac.java.classreader.ClassFile; import org.fife.rsta.ac.java.classreader.FieldInfo; import org.fife.rsta.ac.java.classreader.MemberInfo; @@ -46,6 +47,7 @@ import org.fife.rsta.ac.java.rjc.lang.Type; import org.fife.rsta.ac.java.rjc.lang.TypeArgument; import org.fife.rsta.ac.java.rjc.lang.TypeParameter; +import org.fife.ui.autocomplete.BasicCompletion; import org.fife.ui.autocomplete.Completion; import org.fife.ui.autocomplete.DefaultCompletionProvider; import org.fife.ui.rsyntaxtextarea.RSyntaxDocument; @@ -261,6 +263,93 @@ public void setShorthandCache(ShorthandCompletionCache shorthandCache) { } + /** + * Adds completions for annotation element parameters when the caret + * is inside an annotation's parentheses (e.g. {@code @Serialize(nam|)}). + * + * @param set The set to add completions to. + * @param cu The compilation unit being parsed. + * @param annotationClassName The simple name of the annotation type. + * @return {@code true} if annotation completions were added (caller + * should return them directly), {@code false} if not in + * annotation context. + * @since 3.3.0 + */ + private boolean addAnnotationElementCompletions(Set set, + CompilationUnit cu, String annotationClassName) { + + ClassFile cf = getClassFileFor(cu, annotationClassName); + if (cf == null || + (cf.getAccessFlags() & AccessFlags.ACC_ANNOTATION) == 0) { + return false; + } + + // Add annotation element methods as completions + int methodCount = cf.getMethodCount(); + for (int i=0; i tdi = + annotCu.getTypeDeclarationIterator(); + while (tdi.hasNext()) { + TypeDeclaration td = tdi.next(); + if (td.getName().equals( + cf.getClassName(false))) { + Iterator members = + td.getMemberIterator(); + while (members.hasNext()) { + Member m = members.next(); + if (m instanceof Method && + ((Method) m).getName() + .equals(name)) { + String doc = + ((Method) m).getDocComment(); + if (doc != null && + doc.startsWith("/**")) { + bc.setSummary(Util + .docCommentToHtml(doc)); + } + break; + } + } + break; + } + } + } + } + + set.add(bc); + } + + return !set.isEmpty(); + } + + /** * Gets the {@link ClassFile} for a class. * @@ -523,6 +612,34 @@ protected List getCompletionsImpl(JTextComponent comp) { // Note: getAlreadyEnteredText() never returns null String text = getAlreadyEnteredText(comp); + // Check for annotation parameter context: @Annotation(param| + String annotationClassName = getAnnotationClassName(comp); + if (annotationClassName != null && text.indexOf('.') == -1) { + if (addAnnotationElementCompletions(set, cu, + annotationClassName)) { + completions = new ArrayList<>(set); + Collections.sort(completions); + text = text.substring(text.lastIndexOf('.') + 1); + @SuppressWarnings("unchecked") + int startIdx = Collections.binarySearch(completions, + text, comparator); + if (startIdx < 0) { + startIdx = -(startIdx + 1); + } + else { + while (startIdx > 0 && comparator.compare( + completions.get(startIdx - 1), text) == 0) { + startIdx--; + } + } + @SuppressWarnings("unchecked") + int endIdx = Collections.binarySearch(completions, + text + '{', comparator); + endIdx = -(endIdx + 1); + return completions.subList(startIdx, endIdx); + } + } + // Special case - end of a String literal boolean stringLiteralMember = checkStringLiteralMember(comp, text, cu, set); @@ -552,6 +669,20 @@ protected List getCompletionsImpl(JTextComponent comp) { // Do a final sort of all of our completions and we're good to go! completions = new ArrayList<>(set); + + // If in annotation context (@), filter to only annotation types + boolean annotationContext = isAnnotationContext(comp, text); + if (annotationContext) { + Iterator it = completions.iterator(); + while (it.hasNext()) { + Completion c = it.next(); + if (!(c instanceof ClassCompletion) || + !((ClassCompletion) c).isAnnotationType()) { + it.remove(); + } + } + } + Collections.sort(completions); // Only match based on stuff after the final '.', since that's what is @@ -600,9 +731,109 @@ public List getJars() { -public SourceLocation getSourceLocForClass(String className) { - return jarManager.getSourceLocForClass(className); -} + /** + * Returns the source location for the specified class, if available. + * + * @param className The fully qualified class name. + * @return The source location, or {@code null} if not found. + * @since 3.3.0 + */ + public SourceLocation getSourceLocForClass(String className) { + return jarManager.getSourceLocForClass(className); + } + + + /** + * Checks if the character immediately before the already-entered text + * is '@', indicating an annotation context. + */ + private boolean isAnnotationContext(JTextComponent comp, + String alreadyEntered) { + try { + int caret = comp.getCaretPosition(); + int textStart = caret - alreadyEntered.length(); + if (textStart > 0) { + String prev = comp.getDocument().getText( + textStart - 1, 1); + return prev.charAt(0) == '@'; + } + } + catch (BadLocationException e) { + // ignore + } + return false; + } + + + /** + * If the caret is inside an annotation's parentheses, returns the + * annotation class name (simple name, to be resolved via imports). + * Returns null if not in annotation parameter context. + */ + private String getAnnotationClassName(JTextComponent comp) { + try { + javax.swing.text.Document doc = comp.getDocument(); + int caret = comp.getCaretPosition(); + javax.swing.text.Element root = doc.getDefaultRootElement(); + // Scan up to 3 lines back (annotation params may span lines) + int lineIdx = root.getElementIndex(caret); + int scanStart = root.getElement( + Math.max(0, lineIdx - 3)).getStartOffset(); + String text = doc.getText(scanStart, caret - scanStart); + + // Find unmatched '(' scanning backward + int depth = 0; + int i = text.length() - 1; + int parenPos = -1; + while (i >= 0) { + char c = text.charAt(i); + if (c == ')') { + depth++; + } + else if (c == '(') { + if (depth == 0) { + parenPos = i; + break; + } + depth--; + } + i--; + } + if (parenPos < 0) { + return null; + } + + // Extract identifier before '(' + int nameEnd = parenPos; + int nameStart = nameEnd - 1; + while (nameStart >= 0 && + Character.isJavaIdentifierPart( + text.charAt(nameStart))) { + nameStart--; + } + nameStart++; + if (nameStart >= nameEnd) { + return null; + } + + // Check for '@' before the identifier + int atPos = nameStart - 1; + // Skip whitespace between @ and name + while (atPos >= 0 && + Character.isWhitespace(text.charAt(atPos))) { + atPos--; + } + if (atPos < 0 || text.charAt(atPos) != '@') { + return null; + } + + return text.substring(nameStart, nameEnd); + } + catch (BadLocationException e) { + return null; + } + } + /** * Returns whether a method defined by a super class is accessible to diff --git a/RSTALanguageSupport/src/main/java/org/fife/rsta/ac/java/rjc/parser/ASTFactory.java b/RSTALanguageSupport/src/main/java/org/fife/rsta/ac/java/rjc/parser/ASTFactory.java index 8ec8fbf3..d0b9e791 100644 --- a/RSTALanguageSupport/src/main/java/org/fife/rsta/ac/java/rjc/parser/ASTFactory.java +++ b/RSTALanguageSupport/src/main/java/org/fife/rsta/ac/java/rjc/parser/ASTFactory.java @@ -461,9 +461,11 @@ private TypeDeclaration getClassOrInterfaceDeclaration(CompilationUnit cu, break; case ANNOTATION_START: - // TODO: AnnotationTypeDeclaration, implement me. - throw new IOException( - "AnnotationTypeDeclaration not implemented"); + // @interface — annotation type declaration. + // The '@' token has been consumed; now consume the 'interface' keyword. + s.yylexNonNull(KEYWORD_INTERFACE, "'interface' expected after '@'"); + td = getNormalInterfaceDeclaration(cu, s, addTo); + break; default: ParserNotice notice = new ParserNotice(t, @@ -873,9 +875,10 @@ else if (methodDecl) { type.incrementBracketPairCount(s.skipBracketPairs()); } List thrownTypeNames = getThrownTypeNames(cu, s); - t = s.yylexNonNull("'{' or ';' expected"); + t = s.yylexNonNull("';' expected"); if (t.getType() != SEPARATOR_SEMICOLON) { - throw new IOException("';' expected"); + // Could be 'default' clause in annotation type element — skip to ';' + s.eatThroughNextSkippingBlocks(SEPARATOR_SEMICOLON); } Method m = new Method(s, modList, type, methodNameToken, formalParams, thrownTypeNames); diff --git a/RSTALanguageSupport/src/test/java/org/fife/rsta/ac/java/SourceCompletionProviderTest.java b/RSTALanguageSupport/src/test/java/org/fife/rsta/ac/java/SourceCompletionProviderTest.java new file mode 100644 index 00000000..5249bf64 --- /dev/null +++ b/RSTALanguageSupport/src/test/java/org/fife/rsta/ac/java/SourceCompletionProviderTest.java @@ -0,0 +1,50 @@ +/* + * This library is distributed under a modified BSD license. See the included + * RSTALanguageSupport.License.txt file for details. + */ +package org.fife.rsta.ac.java; + +import static org.junit.jupiter.api.Assertions.*; + +import javax.swing.JTextArea; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for annotation-related autocomplete features in + * {@link SourceCompletionProvider}. + */ +class SourceCompletionProviderTest { + + private SourceCompletionProvider provider; + + @BeforeEach + void setUp() { + provider = new SourceCompletionProvider(); + } + + /** + * Helper: creates a JTextArea with the given text and caret at the end, + * then returns getAlreadyEnteredText result. + */ + private String getEnteredText(String text) { + JTextArea textArea = new JTextArea(text); + textArea.setCaretPosition(text.length()); + return provider.getAlreadyEnteredText(textArea); + } + + // --- annotation prefix tests --- + + @Test + void testAnnotationPrefix() { + // @ is not a Java identifier part, so it should NOT be included + assertEquals("Serialize", getEnteredText("@Serialize")); + } + + @Test + void testAnnotationPrefixPartial() { + assertEquals("Ser", getEnteredText("@Ser")); + } + +} diff --git a/RSTALanguageSupport/src/test/java/org/fife/rsta/ac/java/rjc/parser/AnnotationTypeDeclarationTest.java b/RSTALanguageSupport/src/test/java/org/fife/rsta/ac/java/rjc/parser/AnnotationTypeDeclarationTest.java new file mode 100644 index 00000000..015da385 --- /dev/null +++ b/RSTALanguageSupport/src/test/java/org/fife/rsta/ac/java/rjc/parser/AnnotationTypeDeclarationTest.java @@ -0,0 +1,77 @@ +package org.fife.rsta.ac.java.rjc.parser; + +import java.io.BufferedReader; +import java.io.FileReader; +import java.io.IOException; +import java.io.StringReader; + +import org.fife.rsta.ac.java.rjc.ast.CompilationUnit; +import org.fife.rsta.ac.java.rjc.ast.TypeDeclaration; +import org.fife.rsta.ac.java.rjc.lexer.Scanner; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class AnnotationTypeDeclarationTest { + + private CompilationUnit parse(String filename) throws IOException { + BufferedReader r = new BufferedReader( + new FileReader("src/test/resources/" + filename)); + Scanner s = new Scanner(r); + ASTFactory fact = new ASTFactory(); + CompilationUnit cu = fact.getCompilationUnit(filename, s); + r.close(); + return cu; + } + + @Test + void testAnnotationTypeDeclarationParses() throws IOException { + CompilationUnit cu = parse("AnnotationTypeDecl.java"); + // Should parse without errors (no IOException) + assertEquals(1, cu.getTypeDeclarationCount()); + + TypeDeclaration td = cu.getTypeDeclarationIterator().next(); + assertEquals("AnnotationTypeDecl", td.getName()); + } + + @Test + void testAnnotationTypeDocComment() throws IOException { + CompilationUnit cu = parse("AnnotationTypeDecl.java"); + TypeDeclaration td = cu.getTypeDeclaration(0); + assertNotNull(td.getDocComment()); + assertTrue(td.getDocComment().contains("A sample annotation for testing")); + } + + @Test + void testAnnotationTypeElements() throws IOException { + CompilationUnit cu = parse("AnnotationTypeDecl.java"); + TypeDeclaration td = cu.getTypeDeclaration(0); + // Elements are parsed as methods: value(), count(), tags(), required() + // At minimum, check member count is > 0 (elements were not skipped) + assertTrue(td.getMemberCount() > 0, + "Annotation elements should be parsed as members"); + } + + @Test + void testInlineAnnotationType() throws IOException { + // Test @interface parsed from a string (inner annotation in a class) + String source = + "package foo;\n" + + "public class Outer {\n" + + " /** Inner annotation. */\n" + + " public @interface InnerAnnot {\n" + + " String value();\n" + + " }\n" + + " public void method() {}\n" + + "}\n"; + Scanner s = new Scanner(new StringReader(source)); + ASTFactory fact = new ASTFactory(); + CompilationUnit cu = fact.getCompilationUnit("Outer", s); + assertEquals(1, cu.getTypeDeclarationCount()); + TypeDeclaration outer = cu.getTypeDeclaration(0); + assertEquals("Outer", outer.getName()); + // Inner @interface should be a child type declaration + assertTrue(outer.getChildTypeCount() > 0, + "Inner @interface should be parsed as child type"); + } +} diff --git a/RSTALanguageSupport/src/test/resources/AnnotationTypeDecl.java b/RSTALanguageSupport/src/test/resources/AnnotationTypeDecl.java new file mode 100644 index 00000000..3b6f8ac6 --- /dev/null +++ b/RSTALanguageSupport/src/test/resources/AnnotationTypeDecl.java @@ -0,0 +1,27 @@ +package com.example; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +/** + * A sample annotation for testing. + */ +@Retention(RetentionPolicy.RUNTIME) +public @interface AnnotationTypeDecl { + + /** + * The primary value. + */ + String value() default ""; + + /** + * An integer element. + */ + int count() default 0; + + /** Array element. */ + String[] tags() default {}; + + /** Element without default. */ + boolean required(); +}