Search code examples
javareflectioncompiler-errorsannotationscompile-time

Replace (annotated methods/methods which meet condition) on compile-time in Java


I can imagine this is bad practice, but this is purely for a private project and as an exercise for myself.

I have a Class, which has a lot different static methods. They are used to test some stuff about Java (I am still learning Java, so I put different things there which I want to test myself like how Inheritance works etc.).

Some of these methods will generate compile-time errors, so my Project will not build. I want to create an annotation like @DoNotCompile, which will mark the method, and before compiling the method will be replaced with empty body or something like throw new RuntimeError("This method should not be called").

Currently I just have @Test for methods which will be executed and @NotTest for methods which should not be executed, then I put the body in comments. My goal with this is learning - I want to see, which error would be produced in my Editor, but ignore it when compiling.

I am coding in Java 17, on IntelliJ IDEA 2023.1 Community Edition if this helps. I use gradle, but I am not very experienced in it, but maybe there is some gradle script which can achieve this?

Any help is appreciated, and once again: I know this is bad practice, I am just very curious if this can be achieved.

Edit: It would also be great, if I can somehow record the compile-time error in this process and maybe replace the body with something like sout("Did not compile: <errors or warnings from compiler>");

Edit 2: In the end I just wrote a node.js script for preprocessing and added it as external tool to my run configuration in IntelliJ. In addition I removed the normal build command and replaced the classpath with my processed file path. Now works like a charm, but it has some disadvantages: I only test for a specific annotation, when running the code, so it is not flexible. Also I do not use a parser to get the method, so it just assumes, that the code style is kept. I will upload the script as an answer shortly after.


Solution

  • As a workaround I was only able to create a dirty pre-processor, here is the setup you will need: (Note: I am using IntelliJ, this might differ in your installation if you use another IDE)

    1. Install NodeJS to run Javascript (It just happened that I already had it, also you can theoretically use any other language, even Java itself to re-write that pre-processor)
    2. Create a file with following content somewhere accessible:
    const fs = require("fs");
    const path = require("path");
    const { exec } = require("child_process");
    
    function *fromDir(startPath, filter) {
    
        if (!fs.existsSync(startPath)) {
            console.log("no dir", startPath);
            return;
        }
    
        const files = fs.readdirSync(startPath);
        for (let i = 0; i < files.length; i++) {
            const filename = path.join(startPath, files[i]);
            const stat = fs.lstatSync(filename);
            if (stat.isDirectory()) {
                for (const f of fromDir(filename, filter)) {
                    yield f;
                }
            } else if (filename.endsWith(filter)) {
                yield filename;
            };
        };
    };
    
    (async () => {
        const classpath = process.argv[2] || "";
        const files = [...fromDir("src", ".java")];
    
        const output = await new Promise(res => {
            exec("javac -cp \"" + classpath + "\" -d tmp " + files.join(" "), (err, out, serr) => res(serr));
        });
        
        const errors = output.trim().split("\r\n").filter(line => files.some(file => line.includes(file)));
    
        if (fs.existsSync("processed")) {
            fs.rmSync("processed", { "recursive": true, "force": true });
        }
        fs.mkdirSync("processed");
        fs.cpSync("src", "processed/src", { "recursive": true });
        
        for (const file of files) {
            // contains errors then process
            const ferr = errors.filter(error => error.includes(file));
            if (ferr.length) {
                const content = fs.readFileSync(file, "utf8").split(/\r?\n/);
                const methods = new Set();
                const lookup = { };
                for (const error of ferr) {
                    const line = parseInt(error.slice(file.length + 1).split(":")[0]);
                    const issue = error.split("error: ").slice(1).join("error: ");
                    for (let i = line;; i --) {
                        if (content[i].trim().endsWith("@Test")) {
                            methods.add(i + 1);
                            if (!((i+1) in lookup)) lookup[i + 1] = [];
                            lookup[i + 1].push(issue);
                            break;
                        }
                    }
                }
                for (const method of methods) {
                    let search = true;
                    let tabs;
                    for (let i = method;; i ++) {
                        if (search) {
                            if (content[i].endsWith("{")) search = false;
                            tabs = content[i].match(/^[ ]*/)[0].length;
                            
                            const issues = lookup[method].join("\\n").replaceAll("\"", "\\\"");
                            content[i] = content[i] + " throw new RuntimeException(\"Compile time error was encountered:\\n" + issues + "\");"
                        } else {
                            const start = content[i].match(/^[ ]*/)[0];
                            if (content[i].endsWith("}") && start.length == tabs) break;
                            if (!content[i].length) continue;
                            content[i] = start + "//" + content[i].slice(start.length);
                        }
                    }
                }
                fs.writeFileSync(path.join("processed", file), content.join("\n"));
            }
        }
        if (fs.existsSync("tmp")) {
            fs.rmSync("tmp", { "recursive": true, "force": true });
        }
        
        await new Promise(res => {
            exec("javac -cp \"" + classpath + "\" -d processed/out " + files.map(file => path.join("processed", file)).join(" "), (err, out, serr) => res(serr));
        });
    })();
    
    1. Create an external tool in IntelliJ: tool creation intellij Don't forget to add the classPath as an argument; optionally you can enable "Open Console" if you add some debug lines to your script
    2. Add this tool in run configuration, remove "build" and also modify your classPath like shown here: run configuration You can access this options by clicking on "Modify options". If you cannot add the classpath, because it does not yet exist - create an empty folder with that name and repeat the process.

    Caveats:

    • Dirty syntax parsing. Since Javac cannot provide some useful information except the error, I use whitespace at the beginning of the line to determine where a method starts and ends, so only this format is allowed
    • Right now I use hardcoded annotation @Test to find such methods, you will have to modify it yourself if you want to use another annotation
    • Methods containing errors without annotation will break the processor
    • The @Test annotation does not have to be the last one, but it is advised to still make it the last one

    The procedure of the pre-processor:

    1. Tries to compile with given settings
    2. Records any occured errors
    3. Resolves methods and comments the body out, adding throw new RuntimeException("Compile time error was encountered: <error(s)>")
    4. Saves all the code into processed/src folder
    5. Compiles the code into processed/out folder
    6. Since we modified the classPath in IntelliJ, it now uses our processed compiled code to run -> no errors are shown on compilation, the errors remain in the code as I originally wanted.