How to verify that all own thrown runtime exceptions are covered in Javadoc?
Asked Answered
L

4

10

I throw a bunch of custom runtime exceptions in my code and I want to make sure that in all public methods, I document which runtime exception might be thrown (by myself) and why. This would be very hulpful since I'm maintaining a library which is used by many projects and I want it to be upfront and predictable regarding thrown (runtime) exceptions.

Is there a compiler option, maven plugin, Intellij plugin or custom tool that can help me find missed throws clauses? With checked exceptions it's easy, the compiler will just complain if I missed one, but for runtime exceptions both throws and @throws are not enforced.

One thing I thought of was to temporarily make all my own runtime exceptions checked exceptions (they already share a super class), but that would be a one-off exercise. I would like to verify my code/documentation each time I make changes so I can never forget to document my runtime exceptions.

Another way could be to actually have checked exceptions throughout the code and convert them to runtime only in the public api:

class Foo {
    // oops, throws not documented with @throws
    public void publicMethod() {
        try {
            privateMethod1();
        } catch (CheckedFooException e) {
            throw new RuntimeFooException(e);
        }
    }

    private void privateMethod1() throws CheckedFooException {
        privateMethod2();
    }

    private void privateMethod2() throws CheckedFooException {
        throw new CheckedFooException();
    }
}

This approach would force me to think about CheckedFooException in all public methods. Then to check if I missed documenting one (ie. @throws RuntimeFooException), I would simply do a regex search on catch.*CheckedFooException and check for missing @throws entries. Rather unwieldy process though (and there's a lot of public api that would get peppered with try...catch statements).


Answer: There is some discussion about whether you should document (your own thrown) runtime exceptions at all (the summary so far: it depends), but as far as a direct answer to my question, the accepted answer answers it adequately; I can take that approach, implement my use case and even make a maven plugin with it, given some time and effort. I uploaded a cleaned up start project for this.

Leonaleonanie answered 30/5, 2019 at 13:41 Comment(9)
What are you trying to achieve by declaring your Runtime exceptions in throws clauses? If it's because you're expecting people to handle them then they shouldn't be Runtime exceptions at all. If you're not, then why are you declaring them if you're not expecting the caller to handle them?Marauding
@Marauding it's actually quite common to document thrown unchecked exceptions. For example the Java API documentation for numerous methods specifies that the method throws IllegalArgumentException or IndexOutOfBoundsException, both of which extend RuntimeException.Joselow
The title asks about JavaDoc, but the contents seem to be only about the throws clause. 1. do you really also want to check the Javadocs for missing @throws ... documentation 2. How far do you go? Throwing throws NullPointerException at a method seems odd, at least (even though it can be very reasonable in the JavaDocs!), and when you are calling a third-party method, you could have a hard time figuring out what could be thrown, because this is usually not declared via throws, and too often not even with the @throws JavaDocs...Critique
@Critique I mentioned it in the bounty note, but now also I added in bold that I'm only interested in documenting my own thrown exceptions. And if I catch a 3rd party exception and wrap it in my own, at least I can document where the cause-exception comes from.Leonaleonanie
Possible duplicate of #3747384Launceston
@Launceston that question is more about the extend/scope of which runtime exceptions should be thrown. This question however is about how to automate checking missed documentation.Leonaleonanie
are you looking for some advice or do you need a complete working solution?Refutative
@AndrewTobilko I'm willing to put in the work, but currently there is no solution at all, other than writing my own Java code parser to perform the check.Leonaleonanie
>"other than writing my own Java code parser" Surprisingly, this turns out to be easier that I thought, courtesy of the bounty-answer.Leonaleonanie
S
5

After understanding your question and researching this subject, I finally found what I thought to be one of the best tools to do this job. With this not only you can find each throws instance that you haven't documented, but you can also find where you don't throw anything but accidentally document a throw value.

The idea behind this is to parse the code into an abstract syntax tree. Then look for methods and look for throws statement in the methods. If a method have any throw statement, extract the exception name from those statements. Then get the Javadoc for that method. Check the Javadoc for all the @throw tags and get the name of the exception that been documented. After that, compare the exception throws versus the one that been documented. The last, you kind of have to figure that out on your own depend on your usage circumstance.

The tool I used for this is JavaParser. You can find them on Github at https://github.com/javaparser/javaparser. I downloaded their latest version. Their website is at https://javaparser.org/. They wrote a book on this subject and they mentioned that you can pay $0 dollar for the book. However, I didn't read that as they also have a Javadoc version for their program which can be found at https://www.javadoc.io/doc/com.github.javaparser/javaparser-core/3.15.1.

I wrote a demonstrate code below. In no mean that this code is final. It is just an example. You have to fix it into making it work for your case. I didn't take into consideration of nested classes, nested method, or methods within classes that are within a method. Also, the example code was written for class only and not interface. However, it is easy to adapt the code to change to able to handle interfaces.

For this, you would need to download javaParser, build it, and have their javaparser-core-3.15.1.jar or whichever version in your classpath.

The demonstrated code is below and the test.java is a file from a project that I wrote but you could use any. I also included comments in the example code.

import com.github.javaparser.*;
import com.github.javaparser.ast.CompilationUnit;
import com.github.javaparser.ast.comments.*;
import com.github.javaparser.ast.stmt.*;
import com.github.javaparser.ast.body.*;
import com.github.javaparser.javadoc.*;

import java.io.IOException;
import java.nio.file.*;
import java.nio.charset.Charset;
import java.util.*;
import java.util.stream.Collectors;

class Main{
    public static void main(String[] args) throws IOException {
        // Set file path  
        Path path = Paths.get("test.java");

        // Set configuration
        ParserConfiguration parseConfig = new ParserConfiguration();
        parseConfig.setCharacterEncoding(Charset.forName("UTF-8"));
        parseConfig.setTabSize(4);
        parseConfig.setLanguageLevel(ParserConfiguration.LanguageLevel.JAVA_8);

        // Get the parser
        JavaParser jvParser = new JavaParser(parseConfig);

        // Parse the result
        ParseResult<CompilationUnit> parseResult = jvParser.parse(path);

        // Check for problem
        if ( !parseResult.isSuccessful() ) {
            System.out.print("Parsing java code fail with the following problems:");
            List<Problem> problems = parseResult.getProblems();
            for ( Problem problem : problems ){
                System.out.println(problem.getMessage());
            }
            return;
        }

        // Get the compilationUnit
        // No optional checking for Optional<CompilationUnit> due to already check above.
        CompilationUnit compilationUnit = parseResult.getResult().get();

        // Get Classes
        List<ClassOrInterfaceDeclaration> classes = compilationUnit.findAll(ClassOrInterfaceDeclaration.class).stream()
                                                    .filter(c -> !c.isInterface())
                                                    .collect(Collectors.toList());

        // Traverse through each class to get method
        for ( ClassOrInterfaceDeclaration c : classes ) {
            // Get methods
            List<MethodDeclaration> methods = c.getMethods();
            for ( MethodDeclaration method : methods ) {
                // Get the body statement
                Optional <BlockStmt> body = method.getBody();
                // if no body continue
                if ( !body.isPresent() ) continue;
                // After getting the body of the method code
                // Search for the throw statements.
                List<ThrowStmt> throwStatements = body.get().findAll(ThrowStmt.class);
                // No throw statements, skip
                if ( throwStatements.size() == 0 ) continue;

                // Storing name of exceptions thrown into this list.
                List<String> exceptionsThrown = new ArrayList<String>();

                for ( ThrowStmt stmt : throwStatements ){
                    // Convert the throw expression to object creation expression and get the type.
                    String exceptionName = stmt.getExpression().asObjectCreationExpr().getType().toString();
                    if ( !exceptionsThrown.contains(exceptionName) ) exceptionsThrown.add(exceptionName);
                }

                /* 
                 * Debug block for up to this point 
                System.out.println(method.getName());
                System.out.println(exceptionsThrown);
                System.out.println();
                * 
                **/ 

                // Get The Javadoc
                Optional<Javadoc> javadoc = method.getJavadoc();
                // To store the throws Tags
                List<JavadocBlockTag> throwTags;
                // A list of thrown exception that been documented.
                List<String> exceptionsDocumented = new ArrayList<String>();

                if ( javadoc.isPresent() ) {
                    throwTags = javadoc.get()
                                       .getBlockTags()
                                       .stream()
                                       .filter(t -> t.getType() == JavadocBlockTag.Type.THROWS)
                                       .collect(Collectors.toList());
                    for ( JavadocBlockTag tag : throwTags ) {
                        /* 
                         * This may be buggy as
                         * the code assumed @throw exception 
                         * to be on its own line. Therefore
                         * it will just take the first line as the exception name.
                         */
                        String exceptionName = tag.getContent().toText()
                                                  .split("\n")[0];  // Use system line separator or change
                                                                    // line accordingly.

                        if ( !exceptionsDocumented.contains(exceptionName) ) 
                            exceptionsDocumented.add(exceptionName);
                    }
                }

                // getBegin can extract the line out. But evaluating the optional would take some more code
                // and is just for example so this was done like this without any checking.
                System.out.println("Method: " + method.getName() + " at line " + method.getBegin());
                System.out.println("Throws Exceptions: ");
                System.out.println(exceptionsThrown);
                System.out.println("Documented Exceptions:");
                System.out.println(exceptionsDocumented);

                System.out.println(System.lineSeparator() + System.lineSeparator());
            }
        }
    }
}

test.java content:

package host.fai.lib.faiNumber;
/*
 * Copyright 2019 Khang Hoang Nguyen
 *
 * Permission is hereby granted, free of charge, to any person obtaining
 * a copy of this software and associated documentation files
 * (the "Software"), to deal in the Software without restriction,
 * including without limitation the rights to use, copy, modify, merge,
 * publish, distribute, sublicense, and/or sell copies of the Software,
 * and to permit persons to whom the Software is furnished to do so,
 * subject to the following conditions
 *
 * The above copyright notice and this permission notice shall be
 * included in all copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
 * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
 * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
 * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
 * BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
 * ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
 * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 * SOFTWARE.
 **/
/**
 * <p>The <code>Base2Util</code> class is a final class that provides
 * static methods for converting base 2 numbering system values in
 * string representation to a Java's Primitive Data Type.
 *
 * <p>Currently this class supports converting base 2 numbers values
 * in string representation to integer int values and integer
 * long values.
 *
 * <p>This class can parse unsigned base 2 numbers to a supported
 * integer signed type as if the integer type is unsigned. However,
 * some of the values must be interprete properly to get the correct
 * result.
 *
 * <p>Example for interpreting signed value as unsigned value.
 *
 * <p>It is possible to store the value of 18446744073709551615L
 * into a long(signed) value. However, if that value is stored into a
 * signed long integer type and if we were to interprete the value
 * normally, we would get a -1L value. However, if the -1L value is
 * pass to LongUtil.toStringAsUnsigned, we would get
 * 18446744073709551615 in string format.
 *
 * <p>The following example is to get to -1L. First, we assign a value
 * of 9223372036854775807L to an interger long variable, multiply that
 * variable to 2L, and add 1L to it.
 * <pre>
 *     long a = 9223372036854775807L * 2L + 1L;
 *     System.out.println(a);
 *     System.out.println(LongUtil.toStringAsUnsigned(a));
 * </pre>
 *
 * <p>Example methods for interprete signed type as unsigned type
 * in a decimal strings value are
 * {@link IntUtil#toStringAsUnsigned(int) IntUtil.toStringAsUnsigned}
 * and {@link LongUtil#toStringAsUnsigned(long) LongUtil.toStringAsUnsigned}.
 * </p>
 *
 * @author  Khang Hoang Nguyen
 *
 * @since  1.0.0.f
 **/
public final class Base2Util{
    private Base2Util(){};
    /**
     * Parse the input string as signed base 2 digits representation
     * into an integer int value.
     *
     * @param  input
     *         A string to be parsed as signed base 2 number to an
     *         integer int value.
     *
     * @return  An integer int value of the signed base 2 number
     *          {@code input} string.
     *
     * @throws  NumberFormatException
     *          If the {@code input} string contains invalid signed
     *          base 2 digits, if the {@code input} string contains a
     *          value that is smaller than the value of Integer.MIN_VALUE(
     *          {@value java.lang.Integer#MIN_VALUE}),
     *          or if the {@code input} string contains a value that
     *          is larger than the value of Integer.MAX_VALUE(
     *          {@value java.lang.Integer#MAX_VALUE}).
     *
     * @throws  EmptyStringException
     *          If the {@code input} string is empty.
     *
     * @since  1.0.0.f
     **/
    public static final int toInt(final String input){
        final int length = input.length();
        if ( length == 0 ) throw new EmptyStringException();
        final char ch1 = input.charAt(0); int start;

        if ( ch1 == '-' || ch1 == '+' ){
            if ( length == 1 ) throw new NumberFormatException(input);
            start = 1;
        } else {
            start = 0;
        }

        int out = 0, c;
        while ( start < length && input.charAt(start) == '0' ) start++;
        final int runlen = length - start;

        if ( runlen > 31 ){
            if ( runlen > 32 ) throw new NumberFormatException(input);
            if ( ch1 != '-' ) throw new NumberFormatException(input);

            if ( input.charAt(start++) != '1') throw new NumberFormatException(input);

            for ( ; start < length; start++){
                 if ( input.charAt(start) != '0' ) throw new NumberFormatException(input);
            }

            return -2147483648;
        }

        for ( ; start < length; start++){
            c = (input.charAt(start) ^ '0');
            if ( c > 1 ) throw new NumberFormatException(input);
            out = (out << 1) | c;
        }

        if ( ch1 == '-' ) return ~out + 1;
        return out;
    }

    /**
     * Parse the input string as unsigned base 2 number representation
     * into an integer int value as if the integer int is an unsigned
     * type. For values that need to be interpreted correctly, see the
     * {@link IntUtil#toStringAsUnsigned(int) toStringAsUnsigned} method
     * of the {@link IntUtil IntUtil} class.
     *
     * @param  input
     *         A string to be parsed as unsigned base 2 number to an
     *         integer int value as if the integer int is an unsigned
     *         type.
     *
     * @return  An int value that represents an unsigned integer int
     *          value of the unsigned base 2 number {@code input} string.
     *
     * @throws  NumberFormatException
     *          If the {@code input} string contains invalid unsigned
     *          base 2 digits, if the {@code input} string contains a
     *          value that is beyond the capacity of the integer int
     *          data type.
     *
     * @throws  EmptyStringException
     *          If the {@code input} string is empty.
     *
     * @since  1.0.0.f
     **/
    public static final int toIntAsUnsigned(final String input){
        final int length = input.length();
        if ( length == 0 ) throw new EmptyStringException();
        int start = 0;

        int out = 0, c;
        while ( start < length && input.charAt(start) == '0' ) start++;
        if ( length - start > 32 ) throw new NumberFormatException(input);

        for ( ; start < length; start++){
            c = (input.charAt(start) ^ '0');
            if ( c > 1 ) throw new NumberFormatException(input);
            out = (out << 1) | c;
        }

        return out;
    }

    /**
     * Parse the input string as signed base 2 number representation
     * into an integer long value.
     *
     * @param  input
     *         A string to be parsed as signed base 2 number to an
     *         integer long value.
     *
     * @return  An integer long value of the signed base 2 number
     *          {@code input} string.
     *
     * @throws  NumberFormatException
     *          If the {@code input} string contains invalid signed
     *          base 2 digits, if the {@code input} string contains a
     *          value that is smaller than the value of Long.MIN_VALUE(
     *          {@value java.lang.Long#MIN_VALUE}), or if
     *          the {@code input} string contains a value that is larger
     *          than the value of Long.MAX_VALUE(
     *          {@value java.lang.Long#MAX_VALUE}).
     *
     * @throws  EmptyStringException
     *          If the {@code input} string is empty.
     *
     * @since  1.0.0.f
     **/
    public static final long toLong(final String input){
        final int length = input.length();
        if ( length == 0 ) throw new EmptyStringException();
        final char ch1 = input.charAt(0); int start = 0;

        if ( ch1 == '-' || ch1 == '+' ){
            if ( length == 1 ) throw new NumberFormatException(input);
            start = 1;
        }

        long out = 0, c;
        while ( start < length && input.charAt(start) == '0' ) start++;
        final int runlen = length - start;

        if ( runlen > 63 ){
            if ( runlen > 64 ) throw new NumberFormatException(input);
            if ( ch1 != '-' ) throw new NumberFormatException(input);

            if ( input.charAt(start++) != '1') throw new NumberFormatException(input);

            for ( ; start < length; start++){
                 if ( input.charAt(start) != '0' ) throw new NumberFormatException(input);
            }

            return -9223372036854775808L;
        }

        for ( ; start < length; start++){
            c = (input.charAt(start) ^ '0');
            if ( c > 1L ) throw new NumberFormatException(input);
            out = (out << 1) | c;
        }

        if ( ch1 == '-' ) return ~out + 1L;
        return out;
    }

    /**
     * Parse the input string as unsigned base 2 number representation
     * into an integer long value as if the integer long is an unsigned
     * type. For values that need to be interpreted correctly, see the
     * {@link LongUtil#toStringAsUnsigned(long) toStringAsUnsigned} method
     * of the {@link LongUtil LongUtil} class.
     *
     * @param  input
     *         A string to be parsed as unsigned base 2 number to an
     *         integer long value as if the integer long is an unsigned
     *         type.
     *
     * @return  An integer long value represent the unsigned integer
     *          long value of the unsigned base 2 number {@code input}
     *          string.
     *
     * @throws  NumberFormatException
     *          If the {@code input} string contains invalid unsigned
     *          base 2 digits, or if the {code input} string
     *          contains a value that is beyond the capacity of the
     *          long data type.
     *
     * @throws  EmptyStringException
     *          If the {@code input} string is empty.
     *
     * @since  1.0.0.f
     **/
    public static final long toLongAsUnsigned(final String input){
        final int length = input.length();
        if ( length == 0 ) throw new EmptyStringException();
        int start = 0;

        long out = 0, c;
        while ( start < length && input.charAt(start) == '0' ) start++;
        if ( length - start > 64 ) throw new NumberFormatException(input);

        for ( ; start < length; start++){
            c = (input.charAt(start) ^ '0');
            if ( c > 1L ) throw new NumberFormatException(input);
            out = (out << 1) | c;
        }

        return out;
    }
}
Scapolite answered 15/10, 2019 at 4:4 Comment(3)
Hey that's pretty neat! I tried the example and it works. I'm not sure anymore if conceptually runtime exceptions should be documented at all, but this solution is definitely a good start if I decide to go that route. The bounty is yours!Leonaleonanie
I cleaned up your example to start with here (consolidated all the streams and separated some concerns): github.com/bbottema/analyse-method-throwsLeonaleonanie
@BennyBottema - I am glad my answer helped. Thank you for the bounty. Also, I saw your code and that is pretty neat.Scapolite
O
2

If I understand your question correctly, you are violating the purpose of RuntimeException.

As explained in the thread here, RuntimeException(s) are the one's that are not supposed to be handled by the client. Rather it is a situation, where the client cannot recover. In such case, all he can do is either abandon the application or throw back the error. If you are adding documentation to cover these exceptions, that means you very well know why this exception is occurring. In such cases it should be checked exception, and not unchecked.

So Technically speaking, no library will provide the functionality you are looking for, as runtime exceptions are not expected to be documented. It's a design smell. so better you correct the design, than adding documentation.

If it is not possible, and you insist to use RuntimeException only, then I would recommend to look at this answer and build your own Findbugs/checkstyle rule that will do the trick.

Osmanli answered 18/10, 2019 at 17:26 Comment(8)
And yet, since I am throwing subclassed runtime exceptions myself I do know very well why. It's just not something I expect the user to be able to solve. That's why I'm looking to add @throws, not throws. Why shouldn't I document possible known failure scenarios even they can't fix it in a catch?Leonaleonanie
@AnandVaidya - There is actually no proper use of RuntimeException. It all depends on usage cases. For example, IOException is a "checked exception" even though I would assume most of the programs that are required to open a file would not be able to continue without a file. However, on the other hand, when you parse a number you would get a NumberFormatException and that actually is an extended exception of RuntimeException. But when you parse a number and it is not a number most of the time you don't need to end the program. So what are the differences?Scapolite
Files don't need to be open that much. It worth it to let the user choose what to do in the event where that fail. However, numbers usually needed to be parsed more often. Thus, it is up to the user to catch it or not depend on their usage case. Otherwise, you would have a ton of catch clause for every number parse. Also, you would have other tools to check whether if the numbers to be parsed is a valid number. Thus, it is more efficient for everyone else when the code or library framework lets the user choose to catch or not to catch a fail parse.Scapolite
Sometimes there are other things to give consideration too. For example, because number parsing is a repeating cycle. Some of us could leave bugs in our code. Thus, sometimes having RuntimeException is also good to detect those bugs when it comes to simple things. Also, when we go back to files opening, sometimes a program need to clean up thing when it can't open a file. So in summary, to be a checked or unchecked exception is all depended on the framework design. What you intend your library to do, what are the general usage case, etc...Scapolite
@kavin RuntimeExceptions are better to be avoided as there is no trace of why those occurred at first place. More explanation comingOsmanli
But I agree to what you are saying. Exceptions like number format are unchecked only for the convenience. But on the other side, if someone knows that the message he is sending can possibly have illegal number format, he always has an option to validate the input and report the format error as a validation error. That's where the difference comes in. Moreover, as I said earlier RuntimeExceptions are better to be avoided, because they are usually not reported through the throws clause. This makes them extreme difficult to make corrective action, and usually result in abrupt termination of proOsmanli
I do agree with you too. In conclusion, my opinion is that, even if you want to throw a RuntimeException don't directly throw a RuntimeException. I think it is best to throw an exception that is extending from RuntimeException so that it easy to understand what was wrong and that also gives the client of your program the flexibility in catching it or not. It just because, sometimes you think that if that error occur, there is nothing else that the program can do. However, sometimes when another program incorporates your code into their, failure to do one part, may not be a failure to all.Scapolite
But ultimately, I would still say that to use or not to use RuntimeExceptions is all depends on the design of the code, the framework, what is it used for, and any other points that are relevant.Scapolite
G
0

Let all your exceptions inherit from one exception superclass:

public class MySuperException extends RuntimeException {
}

public class MyException extends MySuperException {
}

To validate, that all exceptions are documented, simply exchange your super class (for example by providing another version of the file at a later position of your classpath):

// temporary class, only for compile time checks
// do not export this into jar
public class MySuperException extends Exception {
} 
Gaylegayleen answered 15/10, 2019 at 4:24 Comment(3)
This would make my library very cumbersome to use. I want to document runtime exceptions, not force my users into try...catchs because all my exceptions are now checked ones, which is also an anti-pattern.Leonaleonanie
Only deliver the "RuntimeException"-jar file to your users. At best, they don't even know, that a CheckedException version exists. Only use the CheckedException to validate your code.Gaylegayleen
Fair enough, but how to automate this? I already kind of proposed what you described in my question, as a one-off/manual exercise. I would like to check each build and it part of my work flow that way.Leonaleonanie
G
-1

Please check Semmle code analysis, which has a query "Missing Javadoc for thrown exception"

Semmle has plugins LGTM and QL that could be used from IDE like Eclipse.

or as an alternative approach please use something similar to Eclipse plugin JAutodoc to complete existing Javadoc.

Gio answered 15/10, 2019 at 21:49 Comment(3)
Will it report missing @throws based on declared throws or actually check the code for throw new MyRuntimeException? Because the throws clause of a method signature is entirely optional just like @throws.Leonaleonanie
As per there Semmle documentation for Missing Javadoc for thrown exception states "A public method or constructor that throws an exception but does not have a Javadoc tag for the exception makes an API more difficult to understand and maintain. This includes checked exceptions in throws clauses and unchecked exceptions that are explicitly thrown in throw statements."Gio
Exactly, so it doesn't solve the problem unfortunately.Leonaleonanie

© 2022 - 2024 — McMap. All rights reserved.