diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 33434366b632..6896561cfa7b 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,12 +7,17 @@ ### User-visible changes +Added support for IntelliJ IDEA annotation files (`annotations.xml`) via the +`-AintellijAnnotations` command-line option. + ### Changes for type system implementers Renamed `AnnotatedTypes.innerMostType()` to `innermostComponentType()`. ### Closed issues +(Filled in at release time.) + ## Version 4.2.3 (2026-09-01) ### User-visible changes diff --git a/docs/manual/annotating-libraries.tex b/docs/manual/annotating-libraries.tex index ef6c41a4c768..e8ff4af744db 100644 --- a/docs/manual/annotating-libraries.tex +++ b/docs/manual/annotating-libraries.tex @@ -897,7 +897,11 @@ By default, the Checker Framework warns about such problems in a stub file provided on the command line, but does not warn about built-in stub files. These command-line options turn the warnings on or off (respectively) for - all stub files. \\ + all stub files. + An IntelliJ IDEA annotation file (Section~\ref{intellij-annotations}) is always + provided on the command line, so the Checker Framework warns about it by default; + \<-AstubNoWarnIfNotFound> turns those warnings off. + \\ The \<@NoAnnotationFileParserWarning> annotation on a package or type in a stub file causes no warning to be issued for that package or type, regardless of the command-line options. @@ -913,6 +917,8 @@ to report only missing methods/fields, but ignore missing classes, even if other classes from the same package are present. Useful if a package spans more than one jar. + This also applies to IntelliJ IDEA annotation files + (Section~\ref{intellij-annotations}). \item[\<-AstubWarnIfRedundantWithBytecode>] Warn if a stub file entry is redundant with bytecode information. The @@ -1081,6 +1087,29 @@ \end{Verbatim} +\sectionAndLabel{External annotations in IntelliJ IDEA format}{intellij-annotations} + +The Checker Framework can read external annotations stored in IntelliJ +IDEA's \code{annotations.xml} format. This allows users to create +\href{https://www.jetbrains.com/help/rider/Code_Analysis__External_Annotations.html}{external +annotations} +using +\href{https://www.jetbrains.com/help/idea/annotating-source-code.html#external-annotations}{IntelliJ +IDEA's user interface}. + +The \code{-AintellijAnnotations} command-line argument takes a +path-separated list of \code{annotations.xml} files, directories, jar files, or zip files +containing \code{annotations.xml} files arranged in directory structures matching their package names. +The path separator is colon on Unix and semicolon on Windows. + +For example: +\begin{Verbatim} + javac -processor org.checkerframework.checker.nullness.NullnessChecker \ + -AintellijAnnotations=path/to/annotations-dir:path/to/annotations.jar \ + MyFile.java +\end{Verbatim} + + \sectionAndLabel{Troubleshooting/debugging annotated libraries}{libraries-troubleshooting} Sometimes, it may seem that a checker is treating a library as unannotated diff --git a/docs/manual/contributors.tex b/docs/manual/contributors.tex index a27f91848c97..c260330d6b13 100644 --- a/docs/manual/contributors.tex +++ b/docs/manual/contributors.tex @@ -25,6 +25,7 @@ Calvin Loncaric, Charles Chen, Charlie Garrett, +Chimaobi Emeka-Iheonu, Chris Povirk, Chris Toxiadis, Christopher Mackie, diff --git a/docs/manual/introduction.tex b/docs/manual/introduction.tex index ffdca9358510..311fd1c11da3 100644 --- a/docs/manual/introduction.tex +++ b/docs/manual/introduction.tex @@ -748,6 +748,10 @@ \item \<-Astubs> List of stub files or directories; see Section~\ref{stub-using}. +\item \<-AintellijAnnotations> + List of IntelliJ IDEA annotation files, directories, jar files, or zip files; + see Section~\ref{intellij-annotations}. + \item \<-AstubWarnIfNotFound>, \<-AstubNoWarnIfNotFound>, diff --git a/framework/src/main/java/org/checkerframework/framework/source/SourceChecker.java b/framework/src/main/java/org/checkerframework/framework/source/SourceChecker.java index 16a893564e71..56515f49547f 100644 --- a/framework/src/main/java/org/checkerframework/framework/source/SourceChecker.java +++ b/framework/src/main/java/org/checkerframework/framework/source/SourceChecker.java @@ -271,8 +271,11 @@ // Additional ajava files to use // org.checkerframework.framework.type.AnnotatedTypeFactory.parserAjavaFiles() "ajava", - // Whether to print warnings about types/members in a stub file - // that were not found on the class path + // Annotations in IntelliJ annotations.xml format + // org.checkerframework.framework.stub.AnnotationFileElementTypes.parseIntellijAnnotations() + "intellijAnnotations", + // Whether to print warnings about types/members in a stub file (or IntelliJ + // IDEA annotation file) that were not found on the classpath. // org.checkerframework.framework.stub.AnnotationFileParser.warnIfNotFound "stubWarnIfNotFound", "stubNoWarnIfNotFound", diff --git a/framework/src/main/java/org/checkerframework/framework/stub/AnnotationFileElementTypes.java b/framework/src/main/java/org/checkerframework/framework/stub/AnnotationFileElementTypes.java index a52a9c459aa0..a0520b50087a 100644 --- a/framework/src/main/java/org/checkerframework/framework/stub/AnnotationFileElementTypes.java +++ b/framework/src/main/java/org/checkerframework/framework/stub/AnnotationFileElementTypes.java @@ -144,6 +144,8 @@ public boolean isParsing() { *
  • Stub files returned by {@link BaseTypeChecker#getExtraStubFiles} (treated like those * listed in @StubFiles annotation) *
  • Stub files provided via {@code -Astubs} compiler option + *
  • IntelliJ IDEA external annotations provided via {@code -AintellijAnnotations} compiler + * option * * *

    If a type is annotated with a qualifier from the same hierarchy in more than one stub file, @@ -202,6 +204,13 @@ public void parseStubFiles() { AnnotationFileType.COMMAND_LINE_STUB); } + // 6. Annotations provided via -AintellijAnnotations command-line option + String intellijAnnotationsOption = checker.getOption("intellijAnnotations"); + if (intellijAnnotationsOption != null) { + parseIntellijAnnotations( + SystemUtil.pathSeparatorSplitter.splitToList(intellijAnnotationsOption)); + } + parsing = false; if (stubDebug) { @@ -288,6 +297,80 @@ public void parseAjavaFileWithTree(String ajavaPath, CompilationUnitTree root) { } } + /** + * Parses IntelliJ annotation files. + * + * @param intellijAnnotationPaths list of files, directories, or jars/zips to parse + */ + public void parseIntellijAnnotations(List intellijAnnotationPaths) { + if (intellijAnnotationPaths.isEmpty()) { + return; + } + boolean wasParsing = parsing; + parsing = true; + try { + ProcessingEnvironment processingEnv = factory.getProcessingEnv(); + if (stubDebug) { + AnnotationFileParser.stubDebugStatic( + processingEnv, "AFET.parseIntellijAnnotations(%s)", intellijAnnotationPaths); + } + SourceChecker checker = factory.getChecker(); + for (String path : intellijAnnotationPaths) { + String fullPath = resolveAgainstTestSrc(path); + + List allFiles = + AnnotationFileUtil.allAnnotationFiles( + fullPath, AnnotationFileType.INTELLIJ_ANNOTATIONS); + if (allFiles == null) { + checker.message( + Diagnostic.Kind.ERROR, "IntelliJ IDEA annotations file not found: " + path); + } else if (allFiles.isEmpty()) { + // The path exists but contains no annotations.xml file, so the user gets no + // annotations from it. That is most likely a mistake, so warn rather than issuing a + // note, which javac does not display by default. + checker.message(Diagnostic.Kind.WARNING, "No annotations.xml file found within " + path); + } else { + for (AnnotationFileResource resource : allFiles) { + // Closing the stream is safe even for a jar file entry: it does not close the + // JarFile that other entries in `allFiles` share. + try (BufferedInputStream annotationFileStream = + new BufferedInputStream(resource.getInputStream())) { + IntelliJAnnotationParser.parseAnnotationsXml( + resource.getDescription(), + annotationFileStream, + factory, + processingEnv, + annotationFileAnnos); + } catch (IOException e) { + checker.message( + Diagnostic.Kind.ERROR, + "Could not read IntelliJ IDEA annotations: " + resource.getDescription()); + } + } + } + } + } finally { + parsing = wasParsing; + } + } + + /** + * Returns the path to use for an annotation file that was named on the command line. This is a + * special case when running in jtreg, which runs the compiler in a different directory than the + * one that contains the test's annotation files. + * + * @param path a relative or absolute path, from a command-line argument + * @return {@code path}, resolved against the {@code test.src} system property if that property is + * set and {@code path} is relative + */ + private static String resolveAgainstTestSrc(String path) { + String base = System.getProperty("test.src"); + if (base == null || Paths.get(path).isAbsolute()) { + return path; + } + return base + "/" + path; + } + /** * Parses the files in {@code annotationFiles} of the given file type. This includes files listed * directly in {@code annotationFiles} and for each listed directory, also includes all files @@ -315,9 +398,7 @@ private void parseAnnotationFiles(List annotationFiles, AnnotationFileTy processingEnv, "AFET.parseAnnotationFiles(%s, %s)", annotationFiles, fileType); } for (String path : annotationFiles) { - // Special case when running in jtreg. - String base = System.getProperty("test.src"); - String fullPath = (base == null) ? path : base + "/" + path; + String fullPath = resolveAgainstTestSrc(path); List allFiles = AnnotationFileUtil.allAnnotationFiles(fullPath, fileType); diff --git a/framework/src/main/java/org/checkerframework/framework/stub/AnnotationFileUtil.java b/framework/src/main/java/org/checkerframework/framework/stub/AnnotationFileUtil.java index f20d73496e59..1e3ae0f57c6f 100644 --- a/framework/src/main/java/org/checkerframework/framework/stub/AnnotationFileUtil.java +++ b/framework/src/main/java/org/checkerframework/framework/stub/AnnotationFileUtil.java @@ -60,7 +60,9 @@ public enum AnnotationFileType { /** Ajava file being parsed as if it is a stub file. */ AJAVA_AS_STUB, /** Ajava file provided on command line. */ - AJAVA; + AJAVA, + /** IntelliJ annotations. */ + INTELLIJ_ANNOTATIONS; /** * Returns true if this represents a stub file. @@ -70,7 +72,7 @@ public enum AnnotationFileType { public boolean isStub() { return switch (this) { case JDK_STUB, BUILTIN_STUB, COMMAND_LINE_STUB, AJAVA_AS_STUB -> true; - case AJAVA -> false; + case AJAVA, INTELLIJ_ANNOTATIONS -> false; default -> throw new BugInCF("unhandled case " + this); }; } @@ -83,7 +85,7 @@ public boolean isStub() { public boolean isBuiltIn() { return switch (this) { case JDK_STUB, BUILTIN_STUB -> true; - case COMMAND_LINE_STUB, AJAVA_AS_STUB, AJAVA -> false; + case COMMAND_LINE_STUB, AJAVA_AS_STUB, AJAVA, INTELLIJ_ANNOTATIONS -> false; default -> throw new BugInCF("unhandled case " + this); }; } @@ -96,7 +98,7 @@ public boolean isBuiltIn() { public boolean isCommandLine() { return switch (this) { case JDK_STUB, BUILTIN_STUB -> false; - case COMMAND_LINE_STUB, AJAVA_AS_STUB, AJAVA -> true; + case COMMAND_LINE_STUB, AJAVA_AS_STUB, AJAVA, INTELLIJ_ANNOTATIONS -> true; default -> throw new BugInCF("unhandled case " + this); }; } @@ -352,8 +354,11 @@ public void visit(WildcardType n, Void arg) { /** * Returns annotation files found at a given file system location (does not look on classpath). * - * @param location an annotation file (stub file or ajava file), a jarfile, or a directory. Look - * for it as an absolute file and relative to the current directory. + * @param location an annotation file (a stub file, ajava file, or IntelliJ IDEA annotation file), + * a jarfile, or a directory. Look for it as an absolute file and relative to the current + * directory. Because it is named explicitly, a file is used no matter what its name is; + * within a jarfile or directory, only files whose name indicates that they are of type {@code + * fileType} are used. * @param fileType file type of files to collect * @return annotation files with the given file type found in the file system (does not look on * classpath). Returns null if the file system location does not exist; the caller may wish to @@ -364,7 +369,7 @@ public void visit(WildcardType n, Void arg) { File file = new File(location); if (file.exists()) { List resources = new ArrayList<>(); - addAnnotationFilesToList(file, resources, fileType); + addAnnotationFilesToList(file, resources, fileType, true); return resources; } @@ -373,7 +378,7 @@ public void visit(WildcardType n, Void arg) { file = new File(System.getProperty("user.dir"), location); if (file.exists()) { List resources = new ArrayList<>(); - addAnnotationFilesToList(file, resources, fileType); + addAnnotationFilesToList(file, resources, fileType, true); return resources; } @@ -400,39 +405,55 @@ private static boolean isAnnotationFile(File f, AnnotationFileType fileType) { * otherwise */ private static boolean isAnnotationFile(String path, AnnotationFileType fileType) { + if (fileType == AnnotationFileType.INTELLIJ_ANNOTATIONS) { + // Within a directory or an archive, an IntelliJ IDEA annotation file is always named + // "annotations.xml". (A file named on the command line may have any name; see + // addAnnotationFilesToList.) + return "annotations.xml".equals(new File(path).getName()); + } return path.endsWith(fileType.isStub() ? ".astub" : ".ajava"); } - private static boolean isJar(File f) { - return f.isFile() && f.getName().endsWith(".jar"); + /** + * Returns true if {@code f} is a JAR or ZIP archive file. + * + * @param f the file to check + * @return true if {@code f} is a JAR or ZIP file + */ + private static boolean isJarOrZip(File f) { + return f.isFile() && (f.getName().endsWith(".jar") || f.getName().endsWith(".zip")); } /** * Side-effects {@code resources} by adding annotation files of the given file type to it. * - * @param location an annotation file (a stub file or ajava file), a jarfile, or a directory. If a - * stub file or ajava file, add it to the {@code resources} list. If a jarfile, use all - * annotation files (of type {@code fileType}) contained in it. If a directory, recurse on all - * files contained in it. + * @param location an annotation file (a stub file, ajava file, or IntelliJ IDEA annotation file), + * a jarfile, or a directory. If an annotation file, add it to the {@code resources} list. If + * a jarfile, use all annotation files (of type {@code fileType}) contained in it. If a + * directory, recurse on all files contained in it. * @param resources the list to add the found files to * @param fileType type of annotation files to add + * @param isUserSupplied true if {@code location} was named by the user (say, on the command line) + * rather than being found by searching a directory or an archive. A file named by the user is + * used no matter what its name is; a file found by searching is used only if its name + * indicates that it is an annotation file of type {@code fileType}. */ @SuppressWarnings({ "JdkObsolete", // JarFile.entries() - "nullness:argument", // inference failed in Arrays.sort "builder:required.method.not.called" // ownership passed to list of // JarEntryAnnotationFileResource, where `file` appears in every element of the list }) private static void addAnnotationFilesToList( - File location, List resources, AnnotationFileType fileType) { - if (isAnnotationFile(location, fileType)) { - resources.add(new FileAnnotationFileResource(location)); - } else if (isJar(location)) { + File location, + List resources, + AnnotationFileType fileType, + boolean isUserSupplied) { + if (isJarOrZip(location)) { JarFile file; try { file = new JarFile(location); } catch (IOException e) { - System.err.println("AnnotationFileUtil: could not process JAR file: " + location); + System.err.println("AnnotationFileUtil: could not process archive: " + location); return; } Enumeration entries = file.entries(); @@ -445,10 +466,16 @@ private static void addAnnotationFilesToList( } else if (location.isDirectory()) { File[] directoryContents = location.listFiles(); + if (directoryContents == null) { + System.err.println("AnnotationFileUtil: could not list directory: " + location); + return; + } Arrays.sort(directoryContents, Comparator.comparing(File::getName)); for (File enclosed : directoryContents) { - addAnnotationFilesToList(enclosed, resources, fileType); + addAnnotationFilesToList(enclosed, resources, fileType, false); } + } else if ((isUserSupplied && location.isFile()) || isAnnotationFile(location, fileType)) { + resources.add(new FileAnnotationFileResource(location)); } } diff --git a/framework/src/main/java/org/checkerframework/framework/stub/IntelliJAnnotationParser.java b/framework/src/main/java/org/checkerframework/framework/stub/IntelliJAnnotationParser.java new file mode 100644 index 000000000000..f05a172ec966 --- /dev/null +++ b/framework/src/main/java/org/checkerframework/framework/stub/IntelliJAnnotationParser.java @@ -0,0 +1,1241 @@ +package org.checkerframework.framework.stub; + +import java.io.IOException; +import java.io.InputStream; +import java.lang.annotation.Target; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import javax.annotation.processing.ProcessingEnvironment; +import javax.lang.model.element.AnnotationMirror; +import javax.lang.model.element.AnnotationValue; +import javax.lang.model.element.Element; +import javax.lang.model.element.ElementKind; +import javax.lang.model.element.ExecutableElement; +import javax.lang.model.element.PackageElement; +import javax.lang.model.element.TypeElement; +import javax.lang.model.element.VariableElement; +import javax.lang.model.type.ArrayType; +import javax.lang.model.type.DeclaredType; +import javax.lang.model.type.PrimitiveType; +import javax.lang.model.type.TypeKind; +import javax.lang.model.type.TypeMirror; +import javax.lang.model.type.TypeVariable; +import javax.lang.model.util.ElementFilter; +import javax.lang.model.util.Elements; +import javax.lang.model.util.Types; +import javax.tools.Diagnostic; +import javax.xml.XMLConstants; +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.parsers.ParserConfigurationException; +import org.checkerframework.checker.nullness.qual.Nullable; +import org.checkerframework.checker.signature.qual.CanonicalName; +import org.checkerframework.framework.qual.FromStubFile; +import org.checkerframework.framework.source.SourceChecker; +import org.checkerframework.framework.stub.AnnotationFileParser.AnnotationFileAnnotations; +import org.checkerframework.framework.type.AnnotatedTypeFactory; +import org.checkerframework.framework.type.AnnotatedTypeMirror; +import org.checkerframework.framework.type.AnnotatedTypeMirror.AnnotatedExecutableType; +import org.checkerframework.javacutil.AnnotationBuilder; +import org.checkerframework.javacutil.AnnotationMirrorSet; +import org.checkerframework.javacutil.AnnotationUtils; +import org.checkerframework.javacutil.BugInCF; +import org.checkerframework.javacutil.ElementUtils; +import org.plumelib.util.ArrayMap; +import org.plumelib.util.ArraySet; +import org.plumelib.util.StringsP; +import org.w3c.dom.Document; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; +import org.xml.sax.SAXException; + +/** + * Parser for IntelliJ IDEA external annotations format ({@code annotations.xml}). + * + *

    IntelliJ stores "external" annotations in XML files named {@code annotations.xml} located in + * directory trees mirroring package names. + */ +public final class IntelliJAnnotationParser { + + /** Do not instantiate. */ + private IntelliJAnnotationParser() { + throw new AssertionError("Do not instantiate"); + } + + /** + * Parses an IntelliJ {@code annotations.xml} stream and populates {@code annotationFileAnnos}. + * + * @param filename the name or path of the file (for diagnostic reporting) + * @param inputStream the input stream of the annotations.xml file + * @param atypeFactory the type factory + * @param processingEnv the processing environment + * @param annotationFileAnnos the annotation storage to populate + */ + public static void parseAnnotationsXml( + String filename, + InputStream inputStream, + AnnotatedTypeFactory atypeFactory, + ProcessingEnvironment processingEnv, + AnnotationFileAnnotations annotationFileAnnos) { + SourceChecker checker = atypeFactory.getChecker(); + Document doc; + try { + DocumentBuilderFactory dbFactory = DocumentBuilderFactory.newInstance(); + try { + dbFactory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true); + } catch (Exception ignored) { + // Feature unsupported by specific parser + } + try { + dbFactory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); + } catch (Exception ignored) { + // Feature unsupported by specific parser + } + try { + dbFactory.setFeature("http://xml.org/sax/features/external-general-entities", false); + } catch (Exception ignored) { + // Feature unsupported by specific parser + } + try { + dbFactory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); + } catch (Exception ignored) { + // Feature unsupported by specific parser + } + try { + dbFactory.setFeature( + "http://apache.org/xml/features/nonvalidating/load-external-dtd", false); + } catch (Exception ignored) { + // Feature unsupported by specific parser + } + dbFactory.setXIncludeAware(false); + dbFactory.setExpandEntityReferences(false); + DocumentBuilder dBuilder = dbFactory.newDocumentBuilder(); + doc = dBuilder.parse(inputStream); + doc.getDocumentElement().normalize(); + } catch (ParserConfigurationException | SAXException | IOException e) { + checker.message( + Diagnostic.Kind.WARNING, + String.format("Could not parse annotations XML %s: %s", filename, e.getMessage())); + return; + } + + NodeList itemNodes = doc.getElementsByTagName("item"); + for (int i = 0; i < itemNodes.getLength(); i++) { + Node node = itemNodes.item(i); + if (node.getNodeType() == Node.ELEMENT_NODE) { + org.w3c.dom.Element itemElement = (org.w3c.dom.Element) node; + String itemName = itemElement.getAttribute("name"); + if (itemName == null || itemName.trim().isEmpty()) { + continue; + } + + try { + List annotations = + parseItemAnnotations(itemElement, atypeFactory, processingEnv, filename); + if (!annotations.isEmpty()) { + applyAnnotationsToElement( + itemName.trim(), + annotations, + atypeFactory, + processingEnv, + annotationFileAnnos, + filename); + } + } catch (BugInCF e) { + throw e; + } catch (Exception e) { + checker.message( + Diagnostic.Kind.WARNING, + String.format( + "Could not apply annotation item '%s' in %s: %s", + itemName.trim(), filename, e.getMessage())); + } + } + } + } + + /** + * Issues a warning about a missing element, unless the -AstubNoWarnIfNotFound option is set. + * + *

    An IntelliJ annotation file is always supplied on the command line, so this warns by + * default, as {@link AnnotationFileParser} does for a command-line file. + * + * @param checker the source checker + * @param message the warning message + */ + private static void warnNotFound(SourceChecker checker, String message) { + if (!checker.hasOption("stubNoWarnIfNotFound")) { + Diagnostic.Kind kind = + checker.hasOption("stubWarnNote") ? Diagnostic.Kind.NOTE : Diagnostic.Kind.WARNING; + checker.message(kind, message); + } + } + + /** + * Issues a warning about a class that the annotation file mentions but that does not exist, + * unless the -AstubNoWarnIfNotFound or -AstubWarnIfNotFoundIgnoresClasses option is set. + * + * @param checker the source checker + * @param message the warning message + */ + private static void warnClassNotFound(SourceChecker checker, String message) { + if (!checker.hasOption("stubWarnIfNotFoundIgnoresClasses")) { + warnNotFound(checker, message); + } + } + + /** + * Parses the {@code } children of an {@code } element. + * + * @param itemElement the XML item element containing annotation child tags + * @param atypeFactory the type factory + * @param processingEnv the processing environment + * @param filename the name or path of the file (for diagnostic reporting) + * @return a list of parsed and canonicalized {@link AnnotationMirror}s + */ + private static List parseItemAnnotations( + org.w3c.dom.Element itemElement, + AnnotatedTypeFactory atypeFactory, + ProcessingEnvironment processingEnv, + String filename) { + List result = new ArrayList<>(); + Elements elements = processingEnv.getElementUtils(); + String context = + String.format("item '%s' in %s", itemElement.getAttribute("name").trim(), filename); + + for (org.w3c.dom.Element annoElement : childElements(itemElement, "annotation")) { + String annoName = annoElement.getAttribute("name"); + if (annoName == null || annoName.trim().isEmpty()) { + continue; + } + annoName = annoName.trim(); + + TypeElement annoTypeElt = getTypeElement(annoName, elements); + if (annoTypeElt == null) { + warnNotFound(atypeFactory.getChecker(), "Unknown annotation: " + annoName); + continue; + } + if (annoTypeElt.getKind() != ElementKind.ANNOTATION_TYPE) { + warnNotFound(atypeFactory.getChecker(), "Not an annotation type: " + annoName); + continue; + } + + try { + AnnotationMirror annoMirror = + buildAnnotationMirror( + annoElement, annoTypeElt, processingEnv, atypeFactory.getChecker(), context); + if (annoMirror != null) { + AnnotationMirror canonical = atypeFactory.canonicalAnnotation(annoMirror); + result.add(canonical != null ? canonical : annoMirror); + } + } catch (BugInCF e) { + throw e; + } catch (Exception e) { + warnNotFound( + atypeFactory.getChecker(), + "Failed to build annotation @" + annoName + ": " + e.getMessage()); + } + } + return result; + } + + /** + * Returns the children of {@code parent} that are elements with the given tag name. Unlike {@link + * org.w3c.dom.Element#getElementsByTagName}, this returns only direct children rather than all + * descendants. + * + * @param parent an XML element + * @param tagName a tag name + * @return the direct children of {@code parent} whose tag name is {@code tagName} + */ + private static List childElements( + org.w3c.dom.Element parent, String tagName) { + List result = new ArrayList<>(); + NodeList children = parent.getChildNodes(); + for (int i = 0; i < children.getLength(); i++) { + Node child = children.item(i); + if (child.getNodeType() == Node.ELEMENT_NODE && tagName.equals(child.getNodeName())) { + result.add((org.w3c.dom.Element) child); + } + } + return result; + } + + /** + * Constructs an {@link AnnotationMirror} from an XML {@code } element. + * + *

    If any {@code } child cannot be parsed, or if some element that has no default value is + * not given a value, this issues a warning and returns null, rather than building an annotation + * that is missing a mandatory element. + * + * @param annoElement the XML element for the annotation + * @param annoTypeElt the TypeElement corresponding to the annotation + * @param processingEnv the processing environment + * @param checker the source checker, for issuing diagnostics + * @param context a description of the enclosing item, for diagnostics + * @return the constructed {@link AnnotationMirror}, or null if construction fails + */ + private static @Nullable AnnotationMirror buildAnnotationMirror( + org.w3c.dom.Element annoElement, + TypeElement annoTypeElt, + ProcessingEnvironment processingEnv, + SourceChecker checker, + String context) { + @SuppressWarnings("signature") // the qualified name of a TypeElement is a canonical name + @CanonicalName String canonicalName = annoTypeElt.getQualifiedName().toString(); + Elements elements = processingEnv.getElementUtils(); + + AnnotationBuilder builder = new AnnotationBuilder(processingEnv, canonicalName); + Set writtenElements = new ArraySet<>(2); // most annotations have few elements + for (org.w3c.dom.Element valElem : childElements(annoElement, "val")) { + if (!valElem.hasAttribute("val")) { + continue; + } + String memberName = + valElem.hasAttribute("name") ? valElem.getAttribute("name").trim() : "value"; + String valStr = valElem.getAttribute("val").trim(); + String problem = setBuilderValue(builder, memberName, valStr, annoTypeElt, processingEnv); + if (problem != null) { + checker.message( + Diagnostic.Kind.WARNING, + String.format("Ignoring annotation @%s on %s: %s", canonicalName, context, problem)); + return null; + } + writtenElements.add(memberName); + } + + for (ExecutableElement annoElt : ElementFilter.methodsIn(annoTypeElt.getEnclosedElements())) { + String elementName = annoElt.getSimpleName().toString(); + if (annoElt.getDefaultValue() == null && !writtenElements.contains(elementName)) { + checker.message( + Diagnostic.Kind.WARNING, + String.format( + "Ignoring annotation @%s on %s: no value for element '%s', which has no default", + canonicalName, context, elementName)); + return null; + } + } + + // Index the values by element name, so that fromName can supply the default value of every + // element that the annotations.xml file does not mention. + Map builtValues = + builder.build().getElementValues(); + Map elementValues = new ArrayMap<>(builtValues.size()); + for (Map.Entry entry : + builtValues.entrySet()) { + elementValues.put(entry.getKey().getSimpleName().toString(), entry.getValue()); + } + return AnnotationBuilder.fromName(elements, canonicalName, elementValues); + } + + /** + * Sets a value on the {@link AnnotationBuilder} based on the expected element type. + * + * @param builder the annotation builder + * @param memberName the name of the annotation element + * @param valStr the raw string value from the XML + * @param annoTypeElt the TypeElement of the annotation + * @param processingEnv the processing environment + * @return null if the value was set, or a description of the problem if it was not + */ + private static @Nullable String setBuilderValue( + AnnotationBuilder builder, + String memberName, + String valStr, + TypeElement annoTypeElt, + ProcessingEnvironment processingEnv) { + ExecutableElement memberMethod = null; + for (ExecutableElement m : ElementFilter.methodsIn(annoTypeElt.getEnclosedElements())) { + if (m.getSimpleName().contentEquals(memberName)) { + memberMethod = m; + break; + } + } + if (memberMethod == null) { + return String.format("the annotation has no element named '%s'", memberName); + } + + TypeMirror returnType = memberMethod.getReturnType(); + if (returnType.getKind() == TypeKind.ARRAY) { + ArrayType at = (ArrayType) returnType; + TypeMirror compType = at.getComponentType(); + List items = parseArrayLiteral(valStr); + List parsedItems = new ArrayList<>(items.size()); + for (String item : items) { + Object val = parseElementValue(item, compType, processingEnv); + if (val == null) { + return String.format( + "cannot parse '%s' as a value of type %s, for element '%s'", + item, compType, memberName); + } + parsedItems.add(val); + } + builder.setValue(memberName, parsedItems); + return null; + } + + Object val = parseElementValue(valStr, returnType, processingEnv); + if (val instanceof Boolean b) { + builder.setValue(memberName, b); + } else if (val instanceof Integer i) { + builder.setValue(memberName, i); + } else if (val instanceof Long l) { + builder.setValue(memberName, l); + } else if (val instanceof Float f) { + builder.setValue(memberName, f); + } else if (val instanceof Double d) { + builder.setValue(memberName, d); + } else if (val instanceof Short s) { + builder.setValue(memberName, s); + } else if (val instanceof Byte b) { + builder.setValue(memberName, b); + } else if (val instanceof Character c) { + builder.setValue(memberName, c); + } else if (val instanceof String s) { + builder.setValue(memberName, s); + } else if (val instanceof VariableElement ve) { + builder.setValue(memberName, ve); + } else if (val instanceof TypeMirror tm) { + builder.setValue(memberName, tm); + } else { + return String.format( + "cannot parse '%s' as a value of type %s, for element '%s'", + valStr, returnType, memberName); + } + return null; + } + + /** + * Parses a single element value according to its expected type. + * + * @param rawVal the raw value string + * @param type the expected TypeMirror of the value + * @param processingEnv the processing environment + * @return the parsed object or null if it cannot be parsed + */ + private static @Nullable Object parseElementValue( + String rawVal, TypeMirror type, ProcessingEnvironment processingEnv) { + String unquotedVal = stripQuotes(rawVal); + TypeKind kind = type.getKind(); + try { + if (kind == TypeKind.BOOLEAN) { + return parseBoolean(unquotedVal); + } else if (kind == TypeKind.INT) { + return Integer.parseInt(unquotedVal); + } else if (kind == TypeKind.LONG) { + return Long.parseLong(unquotedVal.replaceAll("[lL]$", "")); + } else if (kind == TypeKind.FLOAT) { + return Float.parseFloat(unquotedVal.replaceAll("[fF]$", "")); + } else if (kind == TypeKind.DOUBLE) { + return Double.parseDouble(unquotedVal.replaceAll("[dD]$", "")); + } else if (kind == TypeKind.SHORT) { + return Short.parseShort(unquotedVal); + } else if (kind == TypeKind.BYTE) { + return Byte.parseByte(unquotedVal); + } + } catch (NumberFormatException e) { + return null; + } + if (kind == TypeKind.CHAR) { + return parseChar(unquotedVal); + } else if (kind == TypeKind.DECLARED) { + DeclaredType dt = (DeclaredType) type; + TypeElement dtElt = (TypeElement) dt.asElement(); + if (dtElt.getQualifiedName().contentEquals("java.lang.String")) { + return unquotedVal; + } else if (dtElt.getKind() == ElementKind.ENUM) { + String enumConstName = + unquotedVal.substring( + Math.max(unquotedVal.lastIndexOf('.'), unquotedVal.lastIndexOf('$')) + 1); + for (VariableElement enumField : ElementFilter.fieldsIn(dtElt.getEnclosedElements())) { + if (enumField.getSimpleName().contentEquals(enumConstName)) { + return enumField; + } + } + } else if (dtElt.getQualifiedName().contentEquals("java.lang.Class")) { + String className = unquotedVal.replaceAll("\\.class$", ""); + return classLiteralType(className, processingEnv); + } + } + return null; + } + + /** + * Returns the type represented by a class literal name. + * + * @param className a class literal name without the {@code .class} suffix + * @param processingEnv the processing environment + * @return the class literal's type, or null if {@code className} does not name a type + */ + private static @Nullable TypeMirror classLiteralType( + String className, ProcessingEnvironment processingEnv) { + int dimensions = 0; + while (className.endsWith("[]")) { + dimensions++; + className = className.substring(0, className.length() - 2); + } + + Types types = processingEnv.getTypeUtils(); + TypeMirror result = + switch (className) { + case "boolean" -> types.getPrimitiveType(TypeKind.BOOLEAN); + case "byte" -> types.getPrimitiveType(TypeKind.BYTE); + case "short" -> types.getPrimitiveType(TypeKind.SHORT); + case "int" -> types.getPrimitiveType(TypeKind.INT); + case "long" -> types.getPrimitiveType(TypeKind.LONG); + case "char" -> types.getPrimitiveType(TypeKind.CHAR); + case "float" -> types.getPrimitiveType(TypeKind.FLOAT); + case "double" -> types.getPrimitiveType(TypeKind.DOUBLE); + case "void" -> types.getNoType(TypeKind.VOID); + default -> { + TypeElement classTypeElt = getTypeElement(className, processingEnv.getElementUtils()); + // Erase, because that is what javac stores for a class literal and what + // AnnotationBuilder.setValue(CharSequence, TypeMirror) does. + yield classTypeElt == null ? null : types.erasure(classTypeElt.asType()); + } + }; + if (result == null || (dimensions > 0 && result.getKind() == TypeKind.VOID)) { + return null; + } + if (dimensions == 0 && result.getKind().isPrimitive()) { + // Box, because that is what AnnotationBuilder.setValue(CharSequence, TypeMirror) does, and + // an annotation read from an annotations.xml file should be indistinguishable from one that + // the Checker Framework builds itself. Only a scalar is boxed: `int[]` is a reference + // type, which AnnotationBuilder leaves alone. + result = types.boxedClass((PrimitiveType) result).asType(); + } + for (int i = 0; i < dimensions; i++) { + result = types.getArrayType(result); + } + return result; + } + + /** + * Returns the {@link TypeElement} for the given class name, which may use either '.' or '$' to + * separate the name of a nested class from the name of its enclosing class. + * + * @param className a class name read from an {@code annotations.xml} file + * @param elements the element utilities + * @return the {@link TypeElement} for {@code className}, or null if there is none + */ + @SuppressWarnings("signature:argument") // an annotations.xml file contains canonical names + private static @Nullable TypeElement getTypeElement(String className, Elements elements) { + TypeElement result = elements.getTypeElement(className.replace('$', '.')); + if (result == null) { + result = elements.getTypeElement(className); + } + return result; + } + + /** + * Parses a boolean literal. Unlike {@link Boolean#parseBoolean}, which treats every string other + * than "true" as false, this returns null for a string that is not a boolean literal. + * + * @param s a string + * @return the boolean that {@code s} represents, or null if {@code s} is not a boolean literal + */ + static @Nullable Boolean parseBoolean(String s) { + if (s.equalsIgnoreCase("true")) { + return true; + } else if (s.equalsIgnoreCase("false")) { + return false; + } else { + return null; + } + } + + /** + * Parses a char literal value string, interpreting escape sequences. The argument has already had + * its enclosing quotation marks, if any, stripped by {@link #stripQuotes}, which also interprets + * escape sequences; this method interprets escape sequences in an unquoted value. + * + * @param s the unquoted string + * @return the char that {@code s} represents, or null if {@code s} is not a char literal + */ + /*package*/ static @Nullable Character parseChar(String s) { + if (s.length() == 1) { + return s.charAt(0); + } + if (s.length() < 2 || s.charAt(0) != '\\') { + // The empty string, or more than one character with no escape sequence. + return null; + } + String escape = s.substring(1); + if (escape.length() == 1) { + @Nullable Character simpleEscape = + switch (escape.charAt(0)) { + case 'n' -> '\n'; + case 'r' -> '\r'; + case 't' -> '\t'; + case 'b' -> '\b'; + case 'f' -> '\f'; + case 's' -> ' '; + case '\'' -> '\''; + case '"' -> '"'; + case '\\' -> '\\'; + default -> null; + }; + if (simpleEscape != null) { + return simpleEscape; + } + } + if (escape.charAt(0) == 'u') { + // A unicode escape may contain more than one 'u', as in a backslash followed by + // "uuu0041". + int hexStart = 1; + while (hexStart < escape.length() && escape.charAt(hexStart) == 'u') { + hexStart++; + } + String hex = escape.substring(hexStart); + if (hex.length() != 4 || !isHexDigits(hex)) { + return null; + } + return (char) Integer.parseInt(hex, 16); + } + // An octal escape is 1 to 3 octal digits, with value at most 0377. + if (escape.length() <= 3 && isOctalDigits(escape)) { + int value = Integer.parseInt(escape, 8); + if (value <= 0377) { + return (char) value; + } + } + return null; + } + + /** + * Returns true if every character of {@code s} is a hexadecimal digit. + * + * @param s a non-empty string + * @return true if every character of {@code s} is a hexadecimal digit + */ + private static boolean isHexDigits(String s) { + for (int i = 0; i < s.length(); i++) { + if (Character.digit(s.charAt(i), 16) == -1) { + return false; + } + } + return true; + } + + /** + * Returns true if every character of {@code s} is an octal digit. + * + * @param s a non-empty string + * @return true if every character of {@code s} is an octal digit + */ + private static boolean isOctalDigits(String s) { + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if (c < '0' || c > '7') { + return false; + } + } + return true; + } + + /** + * Strips surrounding single or double quotation marks from a string literal value, and interprets + * the Java escape sequences within it. + * + * @param s the string to strip quotes from + * @return the string without surrounding quotes and with escape sequences interpreted + */ + /*package*/ static String stripQuotes(String s) { + s = s.trim(); + if (s.length() >= 2 + && ((s.startsWith("\"") && s.endsWith("\"")) || (s.startsWith("'") && s.endsWith("'"))) + && !lastCharIsEscaped(s)) { + return StringsP.unescapeJava(s.substring(1, s.length() - 1)); + } + return s; + } + + /** + * Returns true if the last character of {@code s} is escaped by a preceding backslash; that is, + * the last character is preceded by an odd number of backslashes. + * + * @param s a string of length at least 2 + * @return true if the last character of {@code s} is escaped + */ + private static boolean lastCharIsEscaped(String s) { + int backslashes = 0; + for (int i = s.length() - 2; i >= 0 && s.charAt(i) == '\\'; i--) { + backslashes++; + } + return backslashes % 2 == 1; + } + + /** + * Parses an array literal in IntelliJ IDEA external annotations file format (e.g., {@code {val1, + * val2}}). + * + * @param s the array literal string + * @return a list of parsed item strings + */ + /*package*/ static List parseArrayLiteral(String s) { + s = s.trim(); + if (s.startsWith("{") && s.endsWith("}")) { + s = s.substring(1, s.length() - 1).trim(); + } + if (s.isEmpty()) { + return Collections.emptyList(); + } + List items = new ArrayList<>(); + char quoteChar = '\0'; + boolean escaped = false; + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if (escaped) { + sb.append(c); + escaped = false; + } else if (c == '\\') { + escaped = true; + sb.append(c); + } else if (quoteChar == '\0' && (c == '"' || c == '\'')) { + quoteChar = c; + sb.append(c); + } else if (quoteChar != '\0' && c == quoteChar) { + quoteChar = '\0'; + sb.append(c); + } else if (c == ',' && quoteChar == '\0') { + items.add(sb.toString().trim()); + sb.setLength(0); + } else { + sb.append(c); + } + } + if (sb.length() > 0) { + items.add(sb.toString().trim()); + } + return items; + } + + /** Represents a parsed IntelliJ item signature. */ + /* package-private */ static final class ParsedItemSignature { + /** Fully qualified class name. */ + final String className; + + /** Simple member name (method name, constructor name, or field name), or null for class. */ + final @Nullable String memberName; + + /** List of parameter type names for executable members. */ + final List paramTypes; + + /** Zero-based parameter index, or -1 if the target is not a parameter. */ + final int paramIndex; + + /** True if the target is a method or constructor. */ + final boolean isMethodOrConstructor; + + /** True if the target is a constructor. */ + final boolean isConstructor; + + /** True if the target is a field. */ + final boolean isField; + + /** True if the target is a class. */ + final boolean isClass; + + /** + * Creates a new {@link ParsedItemSignature}. + * + * @param className fully qualified class name + * @param memberName member name or null + * @param paramTypes list of parameter type names + * @param paramIndex parameter index or -1 + * @param isMethodOrConstructor true if method or constructor + * @param isConstructor true if constructor + * @param isField true if field + * @param isClass true if class + */ + ParsedItemSignature( + String className, + @Nullable String memberName, + List paramTypes, + int paramIndex, + boolean isMethodOrConstructor, + boolean isConstructor, + boolean isField, + boolean isClass) { + this.className = className; + this.memberName = memberName; + this.paramTypes = paramTypes; + this.paramIndex = paramIndex; + this.isMethodOrConstructor = isMethodOrConstructor; + this.isConstructor = isConstructor; + this.isField = isField; + this.isClass = isClass; + } + + /** + * Returns true if the item signature was malformed, so it names no program element. + * + * @return true if the item signature was malformed + */ + boolean isMalformed() { + return !isMethodOrConstructor && !isField && !isClass; + } + } + + /** + * Parses an IntelliJ item signature string into a {@link ParsedItemSignature}. + * + *

    Examples: + * + *

      + *
    • Class: {@code "java.lang.String"} + *
    • Field: {@code "java.lang.String CASE_INSENSITIVE_ORDER"} + *
    • Field with type: {@code "java.lang.String java.util.Comparator CASE_INSENSITIVE_ORDER"} + *
    • Method: {@code "java.lang.String java.lang.String substring(int, int)"} + *
    • Method param: {@code "java.lang.String java.lang.String concat(java.lang.String) 0"} + *
    • Constructor: {@code "java.lang.String java.lang.String(byte[], int)"} + *
    • Constructor param: {@code "java.lang.String java.lang.String(byte[], int) 1"} + *
    + * + *

    If {@code sig} is malformed, the result's {@link ParsedItemSignature#isMalformed} method + * returns true. + * + * @param sig the raw signature string from the XML item name attribute + * @return the parsed item signature + */ + /* package-private */ static ParsedItemSignature parseSignature(String sig) { + sig = sig.trim(); + int lastParen = sig.lastIndexOf(')'); + if (lastParen != -1) { + // Method, Constructor, or Parameter + int firstParen = sig.indexOf('('); + if (firstParen == -1 || firstParen > lastParen) { + return new ParsedItemSignature( + sig, null, Collections.emptyList(), -1, false, false, false, false); + } + String trailing = sig.substring(lastParen + 1).trim(); + // -1 means the signature names the method or constructor itself, not one of its + // parameters. + int paramIndex = -1; + if (!trailing.isEmpty()) { + try { + paramIndex = Integer.parseInt(trailing); + } catch (NumberFormatException e) { + // The signature is malformed: the only thing that may follow the parameter list is a + // parameter index. + return new ParsedItemSignature( + sig, null, Collections.emptyList(), -1, false, false, false, false); + } + if (paramIndex < 0) { + // The signature is malformed: a parameter index is non-negative. + return new ParsedItemSignature( + sig, null, Collections.emptyList(), -1, false, false, false, false); + } + } + + String paramListStr = sig.substring(firstParen + 1, lastParen).trim(); + List paramTypes = parseParameterTypes(paramListStr); + + String beforeParen = sig.substring(0, firstParen).trim(); + String[] parts = beforeParen.split("\\s+"); + + String className = parts[0]; + String memberName = null; + boolean isConstructor = false; + + String simpleClassName = getSimpleName(className); + + if (parts.length == 1) { + isConstructor = true; + memberName = simpleClassName; + } else if (parts.length == 2) { + String name = parts[1]; + if (name.equals(className) || name.equals(simpleClassName) || name.equals("")) { + isConstructor = true; + memberName = simpleClassName; + } else { + memberName = name; + } + } else { + isConstructor = false; + memberName = parts[parts.length - 1]; + } + + return new ParsedItemSignature( + className, memberName, paramTypes, paramIndex, true, isConstructor, false, false); + } else { + // Class or Field + String[] parts = sig.split("\\s+"); + if (parts.length == 1) { + return new ParsedItemSignature( + parts[0], null, Collections.emptyList(), -1, false, false, false, true); + } else { + return new ParsedItemSignature( + parts[0], + parts[parts.length - 1], + Collections.emptyList(), + -1, + false, + false, + true, + false); + } + } + } + + /** + * Parses the comma-separated parameter type list from inside parentheses. + * + * @param paramContent the raw string of parameter types + * @return a list of trimmed parameter type strings + */ + private static List parseParameterTypes(String paramContent) { + if (paramContent.isEmpty()) { + return Collections.emptyList(); + } + List paramTypes = new ArrayList<>(); + int depth = 0; + StringBuilder current = new StringBuilder(); + for (int i = 0; i < paramContent.length(); i++) { + char c = paramContent.charAt(i); + if (c == '<') { + depth++; + current.append(c); + } else if (c == '>') { + depth--; + current.append(c); + } else if (c == ',' && depth == 0) { + paramTypes.add(current.toString().trim()); + current.setLength(0); + } else { + current.append(c); + } + } + if (current.length() > 0) { + paramTypes.add(current.toString().trim()); + } + return paramTypes; + } + + /** + * Returns the simple class name from a binary or qualified class name. + * + * @param className the fully qualified or binary class name + * @return the simple class name + */ + private static String getSimpleName(String className) { + int lastDot = Math.max(className.lastIndexOf('.'), className.lastIndexOf('$')); + return lastDot != -1 ? className.substring(lastDot + 1) : className; + } + + /** + * Applies parsed annotations to the target element in {@code AnnotationFileAnnotations}. + * + * @param itemSignature the raw signature string from the XML item + * @param annotations the list of parsed AnnotationMirrors to apply + * @param atypeFactory the type factory + * @param processingEnv the processing environment + * @param annos the annotation container to populate + * @param filename the name or path of the file (for diagnostic reporting) + */ + private static void applyAnnotationsToElement( + String itemSignature, + List annotations, + AnnotatedTypeFactory atypeFactory, + ProcessingEnvironment processingEnv, + AnnotationFileAnnotations annos, + String filename) { + ParsedItemSignature parsed = parseSignature(itemSignature); + Elements elements = processingEnv.getElementUtils(); + SourceChecker checker = atypeFactory.getChecker(); + + if (parsed.isMalformed()) { + checker.message( + Diagnostic.Kind.WARNING, + String.format("Cannot parse item name '%s' in %s", itemSignature, filename)); + return; + } + + TypeElement classElem = getTypeElement(parsed.className, elements); + if (classElem == null) { + if (parsed.isClass) { + // IntelliJ IDEA writes an annotation on a package using the package name as the item + // name, as in . A package name is not syntactically + // distinguishable from a class name, so resolution determines which it is. + PackageElement packageElem = elements.getPackageElement(parsed.className); + if (packageElem != null) { + for (AnnotationMirror am : annotations) { + recordDeclAnnotationIfApplicable(packageElem, am, annos); + } + return; + } + } + warnClassNotFound(checker, "Class not found: " + parsed.className); + return; + } + + if (parsed.isMethodOrConstructor) { + ExecutableElement execElem = + findMatchingExecutable( + classElem, + parsed.memberName, + parsed.paramTypes, + parsed.isConstructor, + processingEnv.getTypeUtils()); + if (execElem == null) { + warnNotFound( + checker, + (parsed.isConstructor ? "Constructor" : "Method") + + " not found: " + + parsed.memberName + + " in " + + parsed.className); + return; + } + + // Do not create the AnnotatedExecutableType via `annos.atypes.computeIfAbsent`, and do not + // mark the method as being from an annotation file, until the item is known to be + // well-formed. Otherwise a rejected item would still install a bytecode-derived type that + // takes precedence over the annotated JDK, which is parsed later. + AnnotatedExecutableType methodType = (AnnotatedExecutableType) annos.atypes.get(execElem); + if (methodType == null) { + methodType = atypeFactory.fromElement(execElem); + } + + if (parsed.paramIndex >= methodType.getParameterTypes().size()) { + warnNotFound( + checker, + "Parameter index " + + parsed.paramIndex + + " out of bounds for " + + parsed.memberName + + " in " + + parsed.className); + return; + } + + markAsFromStubFile(execElem, processingEnv, annos); + if (parsed.paramIndex >= 0) { + AnnotatedTypeMirror paramType = methodType.getParameterTypes().get(parsed.paramIndex); + VariableElement paramElem = execElem.getParameters().get(parsed.paramIndex); + markAsFromStubFile(paramElem, processingEnv, annos); + for (AnnotationMirror am : annotations) { + if (atypeFactory.isSupportedQualifier(am)) { + paramType.replaceAnnotation(am); + } + recordDeclAnnotationIfApplicable(paramElem, am, annos); + } + annos.atypes.put(paramElem, paramType); + } else { + AnnotatedTypeMirror returnType = methodType.getReturnType(); + for (AnnotationMirror am : annotations) { + if (atypeFactory.isSupportedQualifier(am)) { + returnType.replaceAnnotation(am); + } + recordDeclAnnotationIfApplicable(execElem, am, annos); + } + } + annos.atypes.put(execElem, methodType); + } else if (parsed.isField && parsed.memberName != null) { + VariableElement fieldElem = null; + for (VariableElement f : ElementFilter.fieldsIn(classElem.getEnclosedElements())) { + if (f.getSimpleName().contentEquals(parsed.memberName)) { + fieldElem = f; + break; + } + } + if (fieldElem == null) { + warnNotFound(checker, "Field not found: " + parsed.memberName + " in " + parsed.className); + return; + } + + final VariableElement finalFieldElem = fieldElem; + markAsFromStubFile(finalFieldElem, processingEnv, annos); + AnnotatedTypeMirror fieldType = + annos.atypes.computeIfAbsent( + finalFieldElem, e -> atypeFactory.fromElement(finalFieldElem)); + for (AnnotationMirror am : annotations) { + if (atypeFactory.isSupportedQualifier(am)) { + fieldType.replaceAnnotation(am); + } + recordDeclAnnotationIfApplicable(finalFieldElem, am, annos); + } + annos.atypes.put(finalFieldElem, fieldType); + } else if (parsed.isClass) { + TypeElement finalClassElem = classElem; + markAsFromStubFile(finalClassElem, processingEnv, annos); + AnnotatedTypeMirror classType = + annos.atypes.computeIfAbsent( + finalClassElem, e -> atypeFactory.fromElement(finalClassElem)); + for (AnnotationMirror am : annotations) { + if (atypeFactory.isSupportedQualifier(am)) { + classType.replaceAnnotation(am); + } + recordDeclAnnotationIfApplicable(finalClassElem, am, annos); + } + annos.atypes.put(finalClassElem, classType); + } + } + + /** + * Marks the element with {@code @FromStubFile}. + * + * @param elt the element to mark + * @param processingEnv the processing environment + * @param annos the annotation storage + */ + private static void markAsFromStubFile( + Element elt, ProcessingEnvironment processingEnv, AnnotationFileAnnotations annos) { + AnnotationMirror fromStubFile = + AnnotationBuilder.fromClass(processingEnv.getElementUtils(), FromStubFile.class); + String eltName = ElementUtils.getQualifiedName(elt); + annos.declAnnos.computeIfAbsent(eltName, k -> new AnnotationMirrorSet()).add(fromStubFile); + } + + /** + * Records a declaration annotation on the given element if applicable. + * + * @param elt the element to annotate + * @param am the annotation mirror to record + * @param annos the annotation storage + */ + private static void recordDeclAnnotationIfApplicable( + Element elt, AnnotationMirror am, AnnotationFileAnnotations annos) { + Target target = am.getAnnotationType().asElement().getAnnotation(Target.class); + if (AnnotationUtils.getElementKindsForTarget(target).contains(elt.getKind())) { + String eltName = ElementUtils.getQualifiedName(elt); + annos.declAnnos.computeIfAbsent(eltName, k -> new AnnotationMirrorSet()).add(am); + } + } + + /** + * Finds the matching constructor or method in a given class. + * + * @param classElem the enclosing TypeElement + * @param methodName the method name, or null for constructors + * @param expectedParamTypes the list of expected parameter type strings + * @param isConstructor true if searching for a constructor + * @param types the type utilities + * @return the matching {@link ExecutableElement}, or null if not found + */ + private static @Nullable ExecutableElement findMatchingExecutable( + TypeElement classElem, + @Nullable String methodName, + List expectedParamTypes, + boolean isConstructor, + Types types) { + List candidates = + isConstructor + ? ElementFilter.constructorsIn(classElem.getEnclosedElements()) + : ElementFilter.methodsIn(classElem.getEnclosedElements()); + + // Pass 1: exact match + for (ExecutableElement candidate : candidates) { + if (matchesExecutable( + candidate, methodName, expectedParamTypes, isConstructor, false, types)) { + return candidate; + } + } + // Pass 2: fallback match allowing the erasure of type variables + for (ExecutableElement candidate : candidates) { + if (matchesExecutable( + candidate, methodName, expectedParamTypes, isConstructor, true, types)) { + return candidate; + } + } + return null; + } + + /** + * Tests whether an executable candidate matches the given method name and expected parameter + * types. + * + * @param candidate the executable element candidate + * @param methodName the expected method name, or null for constructors + * @param expectedParamTypes the expected parameter type names + * @param isConstructor true if searching for a constructor + * @param allowTypeVarErasure true if type variables are allowed to match their erasure + * @param types the type utilities + * @return true if candidate matches + */ + private static boolean matchesExecutable( + ExecutableElement candidate, + @Nullable String methodName, + List expectedParamTypes, + boolean isConstructor, + boolean allowTypeVarErasure, + Types types) { + if (!isConstructor + && methodName != null + && !candidate.getSimpleName().contentEquals(methodName)) { + return false; + } + List params = candidate.getParameters(); + if (params.size() != expectedParamTypes.size()) { + return false; + } + for (int i = 0; i < params.size(); i++) { + if (!typeMatches( + params.get(i).asType(), expectedParamTypes.get(i), allowTypeVarErasure, types)) { + return false; + } + } + return true; + } + + /** + * Compares a {@link TypeMirror} with an expected type signature string from IntelliJ. + * + * @param typeMirror the type mirror of the element parameter + * @param expectedTypeStr the expected type name string + * @param allowTypeVarErasure true if type variables should match their erasure as fallback + * @param types the type utilities + * @return true if the type matches + */ + private static boolean typeMatches( + TypeMirror typeMirror, String expectedTypeStr, boolean allowTypeVarErasure, Types types) { + expectedTypeStr = expectedTypeStr.trim(); + if (expectedTypeStr.endsWith("...")) { + expectedTypeStr = expectedTypeStr.substring(0, expectedTypeStr.length() - 3) + "[]"; + } + + if (typeMirror.getKind() == TypeKind.ARRAY) { + if (!expectedTypeStr.endsWith("[]")) { + return false; + } + return typeMatches( + ((ArrayType) typeMirror).getComponentType(), + expectedTypeStr.substring(0, expectedTypeStr.length() - 2), + allowTypeVarErasure, + types); + } + + if (expectedTypeStr.endsWith("[]")) { + return false; + } + + // Strip generics from expectedTypeStr for comparison (e.g. List -> List) + String rawExpected = expectedTypeStr.replaceAll("<.*>", "").trim(); + rawExpected = rawExpected.replace('$', '.'); + + if (typeMirror.getKind().isPrimitive()) { + return typeMirror.getKind().name().toLowerCase(Locale.ROOT).equals(rawExpected); + } + + if (typeMirror.getKind() == TypeKind.DECLARED) { + DeclaredType dt = (DeclaredType) typeMirror; + TypeElement te = (TypeElement) dt.asElement(); + String qualName = te.getQualifiedName().toString().replace('$', '.'); + return qualName.equals(rawExpected); + } + + if (typeMirror.getKind() == TypeKind.TYPEVAR) { + TypeVariable tv = (TypeVariable) typeMirror; + String tvName = tv.asElement().getSimpleName().toString(); + return tvName.equals(rawExpected) + || (allowTypeVarErasure && typeMatches(types.erasure(tv), rawExpected, false, types)); + } + + return false; + } +} diff --git a/framework/src/test/java/org/checkerframework/framework/stub/IntelliJAnnotationParserTest.java b/framework/src/test/java/org/checkerframework/framework/stub/IntelliJAnnotationParserTest.java new file mode 100644 index 000000000000..e4fccc1bc3d6 --- /dev/null +++ b/framework/src/test/java/org/checkerframework/framework/stub/IntelliJAnnotationParserTest.java @@ -0,0 +1,174 @@ +package org.checkerframework.framework.stub; + +import java.util.Arrays; +import java.util.Collections; +import org.checkerframework.framework.stub.IntelliJAnnotationParser.ParsedItemSignature; +import org.junit.Assert; +import org.junit.Test; + +/** Unit tests for the string-manipulation routines of {@link IntelliJAnnotationParser}. */ +public class IntelliJAnnotationParserTest { + + @Test + public void testParseSignatureClass() { + ParsedItemSignature parsed = IntelliJAnnotationParser.parseSignature("java.lang.String"); + Assert.assertTrue(parsed.isClass); + Assert.assertFalse(parsed.isMalformed()); + Assert.assertEquals("java.lang.String", parsed.className); + } + + @Test + public void testParseSignatureField() { + ParsedItemSignature parsed = + IntelliJAnnotationParser.parseSignature( + "java.lang.String java.util.Comparator CASE_INSENSITIVE_ORDER"); + Assert.assertTrue(parsed.isField); + Assert.assertEquals("java.lang.String", parsed.className); + Assert.assertEquals("CASE_INSENSITIVE_ORDER", parsed.memberName); + } + + @Test + public void testParseSignatureMethod() { + ParsedItemSignature parsed = + IntelliJAnnotationParser.parseSignature( + "java.lang.String java.lang.String substring(int, int)"); + Assert.assertTrue(parsed.isMethodOrConstructor); + Assert.assertFalse(parsed.isConstructor); + Assert.assertEquals("substring", parsed.memberName); + Assert.assertEquals(Arrays.asList("int", "int"), parsed.paramTypes); + Assert.assertEquals(-1, parsed.paramIndex); + } + + @Test + public void testParseSignatureMethodParameter() { + ParsedItemSignature parsed = + IntelliJAnnotationParser.parseSignature( + "java.lang.String java.lang.String concat(java.lang.String) 0"); + Assert.assertTrue(parsed.isMethodOrConstructor); + Assert.assertEquals(0, parsed.paramIndex); + } + + @Test + public void testParseSignatureConstructor() { + ParsedItemSignature parsed = + IntelliJAnnotationParser.parseSignature("java.lang.String java.lang.String(byte[], int)"); + Assert.assertTrue(parsed.isConstructor); + Assert.assertEquals("String", parsed.memberName); + Assert.assertEquals(Arrays.asList("byte[]", "int"), parsed.paramTypes); + } + + @Test + public void testParseSignatureMalformed() { + // The closing parenthesis has no matching opening parenthesis. + ParsedItemSignature parsed = + IntelliJAnnotationParser.parseSignature("java.lang.String substring int, int)"); + Assert.assertTrue(parsed.isMalformed()); + } + + @Test + public void testParseSignatureBadParameterIndex() { + // Only a parameter index may follow the parameter list. A non-numeric or negative trailer + // is not silently treated as naming the return type. + Assert.assertTrue( + IntelliJAnnotationParser.parseSignature( + "java.lang.String java.lang.String concat(java.lang.String) bogus") + .isMalformed()); + Assert.assertTrue( + IntelliJAnnotationParser.parseSignature( + "java.lang.String java.lang.String concat(java.lang.String) -1") + .isMalformed()); + Assert.assertTrue( + IntelliJAnnotationParser.parseSignature( + "java.lang.String java.lang.String concat(java.lang.String) 0 1") + .isMalformed()); + } + + @Test + public void testParseChar() { + Assert.assertEquals(Character.valueOf('a'), IntelliJAnnotationParser.parseChar("a")); + Assert.assertEquals(Character.valueOf('\n'), IntelliJAnnotationParser.parseChar("\\n")); + Assert.assertEquals(Character.valueOf('\''), IntelliJAnnotationParser.parseChar("\\'")); + Assert.assertEquals(Character.valueOf('\\'), IntelliJAnnotationParser.parseChar("\\\\")); + // stripQuotes has already interpreted the escape sequence in a quoted value such as '\\'. + Assert.assertEquals(Character.valueOf('\\'), IntelliJAnnotationParser.parseChar("\\")); + Assert.assertEquals(Character.valueOf(' '), IntelliJAnnotationParser.parseChar("\\s")); + // Unicode escapes, which may contain more than one 'u'. + Assert.assertEquals(Character.valueOf('A'), IntelliJAnnotationParser.parseChar("\\u0041")); + Assert.assertEquals(Character.valueOf('A'), IntelliJAnnotationParser.parseChar("\\uuu0041")); + // Octal escapes. + Assert.assertEquals(Character.valueOf('\0'), IntelliJAnnotationParser.parseChar("\\0")); + Assert.assertEquals(Character.valueOf('!'), IntelliJAnnotationParser.parseChar("\\041")); + Assert.assertEquals(Character.valueOf('\u00ff'), IntelliJAnnotationParser.parseChar("\\377")); + } + + @Test + public void testParseCharMalformed() { + // A value that is not a char literal is not silently treated as some char. + Assert.assertNull(IntelliJAnnotationParser.parseChar("")); + Assert.assertNull(IntelliJAnnotationParser.parseChar("ab")); + // Not a Java escape sequence. + Assert.assertNull(IntelliJAnnotationParser.parseChar("\\q")); + // Malformed unicode escapes. + Assert.assertNull(IntelliJAnnotationParser.parseChar("\\u")); + Assert.assertNull(IntelliJAnnotationParser.parseChar("\\u041")); + Assert.assertNull(IntelliJAnnotationParser.parseChar("\\u004g")); + Assert.assertNull(IntelliJAnnotationParser.parseChar("\\u00041")); + // Malformed octal escapes. + Assert.assertNull(IntelliJAnnotationParser.parseChar("\\400")); + Assert.assertNull(IntelliJAnnotationParser.parseChar("\\0000")); + Assert.assertNull(IntelliJAnnotationParser.parseChar("\\08")); + } + + @Test + public void testStripQuotes() { + Assert.assertEquals("abc", IntelliJAnnotationParser.stripQuotes("\"abc\"")); + Assert.assertEquals("abc", IntelliJAnnotationParser.stripQuotes(" \"abc\" ")); + Assert.assertEquals("abc", IntelliJAnnotationParser.stripQuotes("'abc'")); + Assert.assertEquals( + "java.lang.String.class", IntelliJAnnotationParser.stripQuotes("java.lang.String.class")); + } + + @Test + public void testStripQuotesEscapes() { + Assert.assertEquals("a\nb", IntelliJAnnotationParser.stripQuotes("\"a\\nb\"")); + Assert.assertEquals("a\tb", IntelliJAnnotationParser.stripQuotes("\"a\\tb\"")); + Assert.assertEquals("a\\b", IntelliJAnnotationParser.stripQuotes("\"a\\\\b\"")); + Assert.assertEquals("a\"b", IntelliJAnnotationParser.stripQuotes("\"a\\\"b\"")); + Assert.assertEquals("a'b", IntelliJAnnotationParser.stripQuotes("\"a\\'b\"")); + Assert.assertEquals("aAb", IntelliJAnnotationParser.stripQuotes("\"a\\u0041b\"")); + Assert.assertEquals("aAb", IntelliJAnnotationParser.stripQuotes("\"a\\uuu0041b\"")); + Assert.assertEquals("a\0b", IntelliJAnnotationParser.stripQuotes("\"a\\0b\"")); + Assert.assertEquals("a!b", IntelliJAnnotationParser.stripQuotes("\"a\\041b\"")); + // A string that ends with a backslash. + Assert.assertEquals("a\\", IntelliJAnnotationParser.stripQuotes("\"a\\\\\"")); + // An unterminated string literal is left alone. + Assert.assertEquals("\"a\\\"", IntelliJAnnotationParser.stripQuotes("\"a\\\"")); + } + + @Test + public void testParseBoolean() { + Assert.assertEquals(Boolean.TRUE, IntelliJAnnotationParser.parseBoolean("true")); + Assert.assertEquals(Boolean.TRUE, IntelliJAnnotationParser.parseBoolean("TRUE")); + Assert.assertEquals(Boolean.FALSE, IntelliJAnnotationParser.parseBoolean("false")); + // A string that is not a boolean literal is not silently treated as false. + Assert.assertNull(IntelliJAnnotationParser.parseBoolean("ture")); + Assert.assertNull(IntelliJAnnotationParser.parseBoolean("1")); + Assert.assertNull(IntelliJAnnotationParser.parseBoolean("yes")); + Assert.assertNull(IntelliJAnnotationParser.parseBoolean("")); + } + + @Test + public void testParseArrayLiteral() { + Assert.assertEquals(Collections.emptyList(), IntelliJAnnotationParser.parseArrayLiteral("{}")); + Assert.assertEquals( + Arrays.asList("\"a\"", "\"b\""), + IntelliJAnnotationParser.parseArrayLiteral("{\"a\", \"b\"}")); + // A comma within a string literal does not separate array elements. + Assert.assertEquals( + Arrays.asList("\"a,b\"", "\"c\""), + IntelliJAnnotationParser.parseArrayLiteral("{\"a,b\", \"c\"}")); + // A single value need not be surrounded by braces. + Assert.assertEquals( + Arrays.asList("\"a\""), IntelliJAnnotationParser.parseArrayLiteral("\"a\"")); + } +} diff --git a/framework/src/test/java/org/checkerframework/framework/test/junit/IntellijAnnotationValuesJUnitTest.java b/framework/src/test/java/org/checkerframework/framework/test/junit/IntellijAnnotationValuesJUnitTest.java new file mode 100644 index 000000000000..be7644570e3a --- /dev/null +++ b/framework/src/test/java/org/checkerframework/framework/test/junit/IntellijAnnotationValuesJUnitTest.java @@ -0,0 +1,35 @@ +package org.checkerframework.framework.test.junit; + +import java.io.File; +import java.util.List; +import org.checkerframework.framework.test.CheckerFrameworkPerDirectoryTest; +import org.checkerframework.framework.testchecker.testaccumulation.TestAccumulationChecker; +import org.junit.runners.Parameterized.Parameters; + +/** + * JUnit test for annotation element values in an IntelliJ IDEA annotations file. It uses an + * accumulation checker because that checker's qualifiers have elements. + */ +public class IntellijAnnotationValuesJUnitTest extends CheckerFrameworkPerDirectoryTest { + + /** + * @param testFiles the files containing test code, which will be type-checked + */ + public IntellijAnnotationValuesJUnitTest(List testFiles) { + super( + testFiles, + TestAccumulationChecker.class, + "intellij-annotation-values", + "-AintellijAnnotations=tests/intellijannotationvalues"); + } + + /** + * Returns the test directories for this test suite. + * + * @return array of test directory names + */ + @Parameters + public static String[] getTestDirs() { + return new String[] {"intellijannotationvalues"}; + } +} diff --git a/framework/src/test/java/org/checkerframework/framework/test/junit/IntellijAnnotationsJUnitTest.java b/framework/src/test/java/org/checkerframework/framework/test/junit/IntellijAnnotationsJUnitTest.java new file mode 100644 index 000000000000..2a2c77167676 --- /dev/null +++ b/framework/src/test/java/org/checkerframework/framework/test/junit/IntellijAnnotationsJUnitTest.java @@ -0,0 +1,32 @@ +package org.checkerframework.framework.test.junit; + +import java.io.File; +import java.util.List; +import org.checkerframework.framework.test.CheckerFrameworkPerDirectoryTest; +import org.checkerframework.framework.testchecker.h1h2checker.H1H2Checker; +import org.junit.runners.Parameterized.Parameters; + +/** JUnit test for IntelliJ IDEA annotations file support. */ +public class IntellijAnnotationsJUnitTest extends CheckerFrameworkPerDirectoryTest { + + /** + * @param testFiles the files containing test code, which will be type-checked + */ + public IntellijAnnotationsJUnitTest(List testFiles) { + super( + testFiles, + H1H2Checker.class, + "intellij-annotations", + "-AintellijAnnotations=tests/intellijannotations"); + } + + /** + * Returns the test directories for this test suite. + * + * @return array of test directory names + */ + @Parameters + public static String[] getTestDirs() { + return new String[] {"intellijannotations"}; + } +} diff --git a/framework/tests/intellijannotations/IntellijAnnotationsTest.java b/framework/tests/intellijannotations/IntellijAnnotationsTest.java new file mode 100644 index 000000000000..949369ac39f4 --- /dev/null +++ b/framework/tests/intellijannotations/IntellijAnnotationsTest.java @@ -0,0 +1,39 @@ +package intellijannotations; + +import java.util.Comparator; +import org.checkerframework.framework.testchecker.h1h2checker.quals.*; + +public class IntellijAnnotationsTest { + + void testReturn(String s) { + @H1S1 String s1 = s.trim(); + @H1Top String s2 = s.trim(); + // :: error: [assignment] + @H1S2 String s3 = s.trim(); + } + + void testParam(String s, @H1Top String top, @H1S2 String s2) { + s.concat(s2); + // :: error: [argument] + s.concat(top); + } + + void testField() { + @H1S1 Comparator c1 = String.CASE_INSENSITIVE_ORDER; + // :: error: [assignment] + @H1S2 Comparator c2 = String.CASE_INSENSITIVE_ORDER; + } + + void testConstructor(char[] chars) { + @H1S1 String s1 = new String(chars); + // :: error: [assignment] + @H1S2 String s2 = new String(chars); + // The no-argument constructor is not annotated, so it retains the default qualifier. + // :: error: [assignment] + @H1S1 String s3 = new String(); + } + + void testClass(StringBuilder sb) { + @H1S1 StringBuilder sb1 = sb; + } +} diff --git a/framework/tests/intellijannotations/java/lang/annotations.xml b/framework/tests/intellijannotations/java/lang/annotations.xml new file mode 100644 index 000000000000..2db85aa9f8b2 --- /dev/null +++ b/framework/tests/intellijannotations/java/lang/annotations.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/framework/tests/intellijannotationvalues/IntellijAnnotationValuesTest.java b/framework/tests/intellijannotationvalues/IntellijAnnotationValuesTest.java new file mode 100644 index 000000000000..2b80598a0fcf --- /dev/null +++ b/framework/tests/intellijannotationvalues/IntellijAnnotationValuesTest.java @@ -0,0 +1,42 @@ +package intellijannotationvalues; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Target; +import org.checkerframework.framework.testchecker.lib.UncheckedByteCode; +import org.checkerframework.framework.testchecker.testaccumulation.qual.TestAccumulation; +import org.checkerframework.framework.testchecker.testaccumulation.qual.TestAccumulationPredicate; + +/** An annotation with class-literal element values. */ +@Target(ElementType.METHOD) +@interface ClassLiteralValues { + Class primitive(); + + Class noType(); + + Class array(); +} + +/** Tests that annotation element values are read from an {@code annotations.xml} file. */ +public class IntellijAnnotationValuesTest { + + void testArrayValue(String s) { + @TestAccumulation({"alpha", "beta"}) String s1 = s.trim(); + @TestAccumulation({"alpha"}) String s2 = s.trim(); + // :: error: [assignment] + @TestAccumulation({"gamma"}) String s3 = s.trim(); + } + + void testStringValue(String s) { + @TestAccumulationPredicate("alpha") String s1 = s.strip(); + @TestAccumulation({"alpha"}) String s2 = s.strip(); + // :: error: [assignment] + @TestAccumulation({"beta"}) String s3 = s.strip(); + } + + void testBoundedTypeVariable() { + UncheckedByteCode lib = new UncheckedByteCode<>(); + @TestAccumulation({"bounded"}) CharSequence s1 = lib.getI(""); + // :: error: [assignment] + @TestAccumulation({"other"}) CharSequence s2 = lib.getI(""); + } +} diff --git a/framework/tests/intellijannotationvalues/java/lang/annotations.xml b/framework/tests/intellijannotationvalues/java/lang/annotations.xml new file mode 100644 index 000000000000..104cdd27ece5 --- /dev/null +++ b/framework/tests/intellijannotationvalues/java/lang/annotations.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +