You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@groovy.apache.org by pa...@apache.org on 2023/07/12 03:53:20 UTC

[groovy] branch master updated: GROOVY-11118: Partial JEP 445 compatibility

This is an automated email from the ASF dual-hosted git repository.

paulk pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/groovy.git


The following commit(s) were added to refs/heads/master by this push:
     new 344e7aa3d1 GROOVY-11118: Partial JEP 445 compatibility
344e7aa3d1 is described below

commit 344e7aa3d17e7a38dac54c5a8f2ebd5be3e6a5f3
Author: Paul King <pa...@asert.com.au>
AuthorDate: Tue Jul 4 13:37:21 2023 +1000

    GROOVY-11118: Partial JEP 445 compatibility
---
 build.gradle                                       |   3 +
 src/main/java/groovy/lang/GroovyShell.java         |  79 +++++++++-----
 .../java/org/codehaus/groovy/ast/ModuleNode.java   | 106 +++++++++++++++----
 src/spec/doc/core-program-structure.adoc           | 113 +++++++++++++++++++--
 src/spec/test/ScriptsAndClassesSpecTest.groovy     |  53 ++++++++++
 src/test/groovy/bugs/Groovy3749Bug.groovy          |  77 +++++++++++---
 6 files changed, 364 insertions(+), 67 deletions(-)

diff --git a/build.gradle b/build.gradle
index 1fd8de5a24..e31746b1b5 100644
--- a/build.gradle
+++ b/build.gradle
@@ -119,6 +119,9 @@ dependencies {
     testImplementation "org.apache.logging.log4j:log4j-core:${versions.log4j2}"
     testImplementation "org.slf4j:jcl-over-slf4j:${versions.slf4j}"
     testImplementation "com.thoughtworks.qdox:qdox:${versions.qdox}"
+    testImplementation "com.fasterxml.jackson.core:jackson-databind:${versions.jacksonDatabind}"
+    testImplementation "com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:${versions.jackson}"
+    testImplementation "com.fasterxml.jackson.core:jackson-annotations:${versions.jackson}"
 
     testFixturesImplementation projects.groovyXml
     testFixturesImplementation projects.groovyTest
diff --git a/src/main/java/groovy/lang/GroovyShell.java b/src/main/java/groovy/lang/GroovyShell.java
index 1498857b03..c6bd035ffe 100644
--- a/src/main/java/groovy/lang/GroovyShell.java
+++ b/src/main/java/groovy/lang/GroovyShell.java
@@ -32,6 +32,8 @@ import java.io.IOException;
 import java.io.Reader;
 import java.lang.reflect.Constructor;
 import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.lang.reflect.Modifier;
 import java.net.URI;
 import java.security.PrivilegedAction;
 import java.security.PrivilegedActionException;
@@ -40,6 +42,7 @@ import java.util.List;
 import java.util.concurrent.atomic.AtomicInteger;
 
 import static org.codehaus.groovy.control.ResolveVisitor.EMPTY_STRING_ARRAY;
+import static org.codehaus.groovy.runtime.InvokerHelper.EMPTY_ARGS;
 import static org.codehaus.groovy.runtime.InvokerHelper.MAIN_METHOD_NAME;
 
 /**
@@ -290,36 +293,62 @@ public class GroovyShell extends GroovyObjectSupport {
             }
         }
         try {
-            // let's find a main method
-            scriptClass.getMethod(MAIN_METHOD_NAME, String[].class);
-            // if that main method exist, invoke it
-            return InvokerHelper.invokeMethod(scriptClass, MAIN_METHOD_NAME, new Object[]{args});
-        } catch (NoSuchMethodException e) {
-            // if it implements Runnable, try to instantiate it
-            if (Runnable.class.isAssignableFrom(scriptClass)) {
-                return runRunnable(scriptClass, args);
+            // let's find a String[] main method
+            Method stringArrayMain = scriptClass.getMethod(MAIN_METHOD_NAME, String[].class);
+            // if that main method exists, invoke it
+            if (Modifier.isStatic(stringArrayMain.getModifiers())) {
+                return InvokerHelper.invokeStaticMethod(scriptClass, MAIN_METHOD_NAME, new Object[]{args});
+            } else {
+                Object script = InvokerHelper.invokeNoArgumentsConstructorOf(scriptClass);
+                return InvokerHelper.invokeMethod(script, MAIN_METHOD_NAME, args);
             }
-            GroovyRunnerRegistry runnerRegistry = GroovyRunnerRegistry.getInstance();
-            for (GroovyRunner runner : runnerRegistry) {
-                if (runner.canRun(scriptClass, this.loader)) {
-                    return runner.run(scriptClass, this.loader);
-                }
+        } catch (NoSuchMethodException ignore) { }
+        try {
+            // let's find an Object main method
+            Method stringArrayMain = scriptClass.getMethod(MAIN_METHOD_NAME, Object.class);
+            // if that main method exists, invoke it
+            if (Modifier.isStatic(stringArrayMain.getModifiers())) {
+                return InvokerHelper.invokeStaticMethod(scriptClass, MAIN_METHOD_NAME, new Object[]{args});
+            } else {
+                Object script = InvokerHelper.invokeNoArgumentsConstructorOf(scriptClass);
+                return InvokerHelper.invokeMethod(script, MAIN_METHOD_NAME, new Object[]{args});
             }
-            StringBuilder message = new StringBuilder("This script or class could not be run.\n" +
-                    "It should either:\n" +
-                    "- have a main method,\n" +
-                    "- be a JUnit test or extend GroovyTestCase,\n" +
-                    "- implement the Runnable interface,\n" +
-                    "- or be compatible with a registered script runner. Known runners:\n");
-            if (runnerRegistry.isEmpty()) {
-                message.append("  * <none>");
+        } catch (NoSuchMethodException ignore) { }
+        try {
+            // let's find a no-arg main method
+            Method noArgMain = scriptClass.getMethod(MAIN_METHOD_NAME);
+            // if that main method exists, invoke it
+            if (Modifier.isStatic(noArgMain.getModifiers())) {
+                return InvokerHelper.invokeStaticNoArgumentsMethod(scriptClass, MAIN_METHOD_NAME);
             } else {
-                for (String key : runnerRegistry.keySet()) {
-                    message.append("  * ").append(key).append("\n");
-                }
+                Object script = InvokerHelper.invokeNoArgumentsConstructorOf(scriptClass);
+                return InvokerHelper.invokeMethod(script, MAIN_METHOD_NAME, EMPTY_ARGS);
+            }
+        } catch (NoSuchMethodException ignore) { }
+        // if it implements Runnable, try to instantiate it
+        if (Runnable.class.isAssignableFrom(scriptClass)) {
+            return runRunnable(scriptClass, args);
+        }
+        GroovyRunnerRegistry runnerRegistry = GroovyRunnerRegistry.getInstance();
+        for (GroovyRunner runner : runnerRegistry) {
+            if (runner.canRun(scriptClass, this.loader)) {
+                return runner.run(scriptClass, this.loader);
+            }
+        }
+        StringBuilder message = new StringBuilder("This script or class could not be run.\n" +
+                "It should either:\n" +
+                "- have a main method,\n" +
+                "- be a JUnit test or extend GroovyTestCase,\n" +
+                "- implement the Runnable interface,\n" +
+                "- or be compatible with a registered script runner. Known runners:\n");
+        if (runnerRegistry.isEmpty()) {
+            message.append("  * <none>");
+        } else {
+            for (String key : runnerRegistry.keySet()) {
+                message.append("  * ").append(key).append("\n");
             }
-            throw new GroovyRuntimeException(message.toString());
         }
+        throw new GroovyRuntimeException(message.toString());
     }
 
     private static Object runRunnable(Class scriptClass, String[] args) {
diff --git a/src/main/java/org/codehaus/groovy/ast/ModuleNode.java b/src/main/java/org/codehaus/groovy/ast/ModuleNode.java
index 5e22024b79..7d0b44acde 100644
--- a/src/main/java/org/codehaus/groovy/ast/ModuleNode.java
+++ b/src/main/java/org/codehaus/groovy/ast/ModuleNode.java
@@ -18,7 +18,12 @@
  */
 package org.codehaus.groovy.ast;
 
+import org.codehaus.groovy.ast.expr.DeclarationExpression;
+import org.codehaus.groovy.ast.expr.Expression;
+import org.codehaus.groovy.ast.expr.ListExpression;
+import org.codehaus.groovy.ast.expr.VariableExpression;
 import org.codehaus.groovy.ast.stmt.BlockStatement;
+import org.codehaus.groovy.ast.stmt.ExpressionStatement;
 import org.codehaus.groovy.ast.stmt.Statement;
 import org.codehaus.groovy.classgen.GeneratorContext;
 import org.codehaus.groovy.control.SourceUnit;
@@ -343,6 +348,49 @@ public class ModuleNode extends ASTNode {
 
         MethodNode existingMain = handleMainMethodIfPresent(methods);
 
+        boolean hasUncontainedStatements = false;
+        List<FieldNode> fields = new ArrayList<>();
+        // check for uncontained statements (excluding decl statements)
+        for (Statement statement : statementBlock.getStatements()) {
+            if (!(statement instanceof ExpressionStatement)) {
+                hasUncontainedStatements = true;
+                break;
+            }
+            ExpressionStatement es = (ExpressionStatement) statement;
+            Expression expression = es.getExpression();
+            if (!(expression instanceof DeclarationExpression)) {
+                hasUncontainedStatements = true;
+                break;
+            }
+            DeclarationExpression de = (DeclarationExpression) expression;
+            if (de.isMultipleAssignmentDeclaration()) {
+                List<Expression> variables = de.getTupleExpression().getExpressions();
+                if (!(de.getRightExpression() instanceof ListExpression)) break;
+                List<Expression> values = ((ListExpression)de.getRightExpression()).getExpressions();
+                for (int i = 0; i < variables.size(); i++) {
+                    VariableExpression var = (VariableExpression) variables.get(i);
+                    Expression val = i >= values.size() ? null : values.get(i);
+                    fields.add(new FieldNode(var.getName(), var.getModifiers(), var.getType(), null, val));
+                }
+            } else {
+                VariableExpression ve = de.getVariableExpression();
+                fields.add(new FieldNode(ve.getName(), ve.getModifiers(), ve.getType(), null, de.getRightExpression()));
+            }
+        }
+
+        if (existingMain != null && !hasUncontainedStatements) {
+            ClassNode result = new ClassNode(classNode.getName(), 0, ClassHelper.OBJECT_TYPE);
+            result.addAnnotations(existingMain.getAnnotations());
+            result.putNodeMetaData("_SKIPPABLE_ANNOTATIONS", Boolean.TRUE);
+            existingMain.putNodeMetaData("_SKIPPABLE_ANNOTATIONS", Boolean.TRUE);
+//            result.getAnnotations().forEach(a -> {
+//                // TODO handle AST transform annotations
+//            });
+            methods.forEach(result::addMethod);
+            fields.forEach(result::addField);
+            return result;
+        }
+
         classNode.addMethod(
             new MethodNode(
                 "main",
@@ -360,12 +408,22 @@ public class ModuleNode extends ASTNode {
             )
         );
 
-        MethodNode methodNode = new MethodNode("run", ACC_PUBLIC, ClassHelper.OBJECT_TYPE, Parameter.EMPTY_ARRAY, ClassNode.EMPTY_ARRAY, statementBlock);
-        methodNode.setIsScriptBody();
-        if (existingMain != null) {
-            methodNode.addAnnotations(existingMain.getAnnotations());
+        // we add the run method unless we find a no-arg instance run method
+        // and there are no uncontained statements
+        MethodNode existingRun = hasUncontainedStatements ? null : findRun();
+        if (existingRun == null) {
+            MethodNode methodNode = new MethodNode("run", ACC_PUBLIC, ClassHelper.OBJECT_TYPE, Parameter.EMPTY_ARRAY, ClassNode.EMPTY_ARRAY, statementBlock);
+            methodNode.setIsScriptBody();
+            if (existingMain != null) {
+                methodNode.addAnnotations(existingMain.getAnnotations());
+            }
+            classNode.addMethod(methodNode);
+        } else {
+            fields.forEach(classNode::addField);
+            classNode.addAnnotations(existingRun.getAnnotations());
+            classNode.putNodeMetaData("_SKIPPABLE_ANNOTATIONS", Boolean.TRUE);
+            existingRun.putNodeMetaData("_SKIPPABLE_ANNOTATIONS", Boolean.TRUE);
         }
-        classNode.addMethod(methodNode);
 
         classNode.addConstructor(ACC_PUBLIC, Parameter.EMPTY_ARRAY, ClassNode.EMPTY_ARRAY, new BlockStatement());
 
@@ -392,34 +450,42 @@ public class ModuleNode extends ASTNode {
         return classNode;
     }
 
+    private MethodNode findRun() {
+        for (MethodNode node : methods) {
+            if (node.getName().equals("run") && node.getParameters().length == 0) {
+                return node;
+            }
+        }
+        return null;
+    }
+
     /*
      * If a main method is provided by user, account for it under run() as scripts generate their own 'main' so they can run.
      */
     private MethodNode handleMainMethodIfPresent(final List<MethodNode> methods) {
-        boolean found = false;
+        boolean foundInstance = false;
+        boolean foundStatic = false;
         MethodNode result = null;
         for (Iterator<MethodNode> iter = methods.iterator(); iter.hasNext(); ) {
             MethodNode node = iter.next();
-            if (node.getName().equals("main")) {
-                if (node.isStatic() && node.getParameters().length == 1) {
-                    boolean retTypeMatches, argTypeMatches;
-                    ClassNode argType = node.getParameters()[0].getType();
+            if (node.getName().equals("main") && !node.isPrivate()) {
+                int numParams = node.getParameters().length;
+                if (numParams < 2) {
+                    ClassNode argType = numParams > 0 ? node.getParameters()[0].getType() : null;
                     ClassNode retType = node.getReturnType();
 
-                    argTypeMatches = (ClassHelper.isObjectType(argType) || argType.getName().contains("String[]"));
-                    retTypeMatches = (ClassHelper.isPrimitiveVoid(retType) || ClassHelper.isObjectType(retType));
+                    boolean argTypeMatches = argType == null || ClassHelper.isObjectType(argType) || argType.getName().contains("String[]");
+                    boolean retTypeMatches = ClassHelper.isPrimitiveVoid(retType) || ClassHelper.isObjectType(retType);
                     if (retTypeMatches && argTypeMatches) {
-                        if (found) {
+                        if ((foundStatic && node.isStatic()) || (foundInstance && !node.isStatic())) {
                             throw new RuntimeException("Repetitive main method found.");
-                        } else {
-                            found = true;
-                            result = node;
                         }
-                        // if script has both loose statements as well as main(), then main() is ignored
-                        if (statementBlock.isEmpty()) {
-                            addStatement(node.getCode());
+                        if (!foundStatic) { // static trumps instance
+                            result = node;
                         }
-                        iter.remove();
+
+                        if (node.isStatic()) foundStatic = true;
+                        else foundInstance = true;
                     }
                 }
             }
diff --git a/src/spec/doc/core-program-structure.adoc b/src/spec/doc/core-program-structure.adoc
index baded9edce..a4d9c766fd 100644
--- a/src/spec/doc/core-program-structure.adoc
+++ b/src/spec/doc/core-program-structure.adoc
@@ -166,8 +166,12 @@ include::../test/PackageTest.groovy[tags=alias_import,indent=0]
 
 == Scripts versus classes
 
-=== public static void main vs script
-Groovy supports both scripts and classes. Take the following code for example:
+Groovy supports both scripts and classes. From Groovy 5,
+Groovy also supports https://openjdk.org/jeps/445[JEP 445] compatible scripts.
+
+=== Motivation for scripts
+
+Take the following code for example:
 
 [source,groovy]
 .Main.groovy
@@ -187,9 +191,11 @@ Groovy makes it easier, the following code is equivalent:
 include::../test/ScriptsAndClassesSpecTest.groovy[tags=groovy_script,indent=0]
 ----
 
-A script can be considered as a class without needing to declare it, with some differences.
+A script can be considered as a class without needing to explicitly declare it.
+There are some differences which we'll cover next. First, we'll cover Groovy's
+main `Script` class. Then, we'll cover JEP 445 compatible classes.
 
-=== Script class
+=== `Script` class
 
 A gapi:groovy.lang.Script[script] is always compiled into a class. The Groovy compiler will compile the class for you,
 with the body of the script copied into a `run` method. The previous example is therefore compiled as if it was the
@@ -229,13 +235,17 @@ include::../test/ScriptsAndClassesSpecTest.groovy[tags=multiple_methods_assembly
 <2> a method is defined within the script body
 <3> and script continues
 
-This code is internally converted into:
+Statements 1 and 3 are sometimes referred to as "loose" statements.
+They are not contained within an explicit enclosing method or class.
+Loose statements are assembled sequentially into the `run` method.
+
+So, the above code is internally converted into:
 
 [source,groovy]
 ----
 include::../test/ScriptsAndClassesSpecTest.groovy[tags=multiple_methods_assembly_equiv,indent=0]
 ----
-<1> the `power` method is copied as is into the generated script class
+<1> the `power` method is copied as-is into the generated script class
 <2> first statement is copied into the `run` method
 <3> second statement is copied into the `run` method
 
@@ -273,5 +283,94 @@ TIP: Another approach to making a variable visible to all methods, is to use the
 <<{core-metaprogramming}#xform-Field,@Field annotation>>.
 A variable annotated this way will become a field of the generated script class and,
 as for local variables, access won't involve the script `Binding`.
-While not recommended, if you have a local variable or script field with the same name as a binding variable,
+If you have a local variable or script field with the same name as a binding variable,
+we recommend renaming one of them to avoid potential confusion. If that's not possible,
 you can use `binding.varName` to access the binding variable.
+
+=== Convenience variations
+
+As mentioned previously, normally, `public static void main` and `run` methods
+are automatically added to your script, so it is normally illegal to add your own versions
+of either of those; you would see a duplicate method compiler error if you tried.
+
+However, there are some exceptions where the above rules don't apply.
+If your script contains _only_ a compatible main method and no other loose statements,
+or _only_ a no-arg `run` instance method (from Groovy 5), then it is allowed.
+In this case, no loose statements (because there aren't any) are collected into the `run` method.
+The method you supplied is used instead of Groovy adding the respective method(s).
+
+This can be useful if you need to add an annotation to the otherwise implicitly added
+`main` or `run` methods as this example shows:
+
+[source,groovy]
+----
+include::../test/ScriptsAndClassesSpecTest.groovy[tags=script_with_explicit_static_main,indent=0]
+----
+
+To be recognised as a convenience variation, as well as having no loose statements,
+the parameter for the `main` method should be:
+
+* untyped as above (`Object` type),
+* or of type `String[]`,
+* or have no arguments (from Groovy 5).
+
+From Groovy 5, a no-arg instance `run` variant is also supported.
+This also allows annotations to be added.
+The `run` variant follows the JEP 445 rules for field declarations
+(hence doesn't need to use the `@Field` annotation)
+as this example involving Jackson JSON serialization shows:
+
+[source,groovy]
+----
+include::../test/ScriptsAndClassesSpecTest.groovy[tags=script_with_explicit_instance_run,indent=0]
+----
+
+The `run` variant is recommended if you need your script to extend the `Script` class
+and have access to the script context and bindings. If you don't have that requirement,
+providing one of the `main` variants will create a JEP 445 compatible class which won't
+extend `Script`. We'll cover JEP 445 compatible scripts in more detail next.
+
+== JEP 445 compatible scripts
+
+From Groovy 5, support has been added for JEP 445 compatible scripts containing
+a `main` method. Such scripts have several differences to normal Groovy `Script` classes:
+
+* they won't have a `public static void main` method added
+* they won't extend the `Script` class and hence won't have access to the script
+context or binding variables
+* allows additional class-level _fields_ and _methods_ to be defined in addition to `main`
+* can't have "loose" statements outside the `main` method (excluding any field definitions)
+
+A simple example might look like:
+
+[source,groovy]
+----
+include::../test/ScriptsAndClassesSpecTest.groovy[tags=jep445_barescript,indent=0]
+----
+
+An example with additional fields and methods might look like:
+
+[source,groovy]
+----
+include::../test/ScriptsAndClassesSpecTest.groovy[tags=jep445_script,indent=0]
+----
+<1> Note that multi-assignment syntax is supported and results in separate field definitions for each component.
+
+=== Differences with Java JEP 445 behavior
+
+There are some differences with Groovy's JEP 445 support and that offered by Java:
+
+* Java supports either a no-arg `main` method or one containing a single `String[]` parameter.
+Groovy also adds support for a single untyped (`Object`) parameter, e.g. `def main(args) { ... }`.
+This addition is known by the Groovy runner but would not be known by the
+Java launch protocol for a JDK supporting JEP 445.
+* Java supports `void` main methods. Groovy also adds support for untyped `def` (`Object`) methods,
+e.g. `def main(...)` as well as `void main(...)`.
+This addition is known by the Groovy runner but would not be known by the Java launch protocol
+for a JDK supporting JEP 445.
+* For static `main` variants, Groovy _promotes_ the no-arg or untyped variants to have the
+standard `public static void main(String[] args)` signature. This is for compatibility
+with versions of Groovy prior to Groovy 5 (where JEP 445 support was added).
+As a consequence, such classes are compatible with the Java launch protocol prior to JEP 445 support.
+* Groovy's runner has been made aware of JEP 445 compatible classes and can run all variations
+for JDK11 and above and without the need for preview mode to be enabled.
diff --git a/src/spec/test/ScriptsAndClassesSpecTest.groovy b/src/spec/test/ScriptsAndClassesSpecTest.groovy
index e36eaf23b8..a97f3f42b2 100644
--- a/src/spec/test/ScriptsAndClassesSpecTest.groovy
+++ b/src/spec/test/ScriptsAndClassesSpecTest.groovy
@@ -48,6 +48,55 @@ class ScriptsAndClassesSpecTest extends GroovyTestCase {
             }
             // end::groovy_script_equiv[]
         '''
+
+        assertScript '''
+            import groovy.transform.CompileStatic
+            // tag::script_with_explicit_static_main[]
+            @CompileStatic
+            static main(args) {
+                println 'Groovy world!'
+            }
+            // end::script_with_explicit_static_main[]
+        '''
+
+        assertScript '''
+            import groovy.transform.*
+            import com.fasterxml.jackson.annotation.*
+            import com.fasterxml.jackson.databind.ObjectMapper
+
+            // tag::script_with_explicit_instance_run[]
+            @JsonIgnoreProperties(["binding"])
+            def run() {
+                var mapper = new ObjectMapper()
+                assert mapper.writeValueAsString(this) == '{"pets":["cat","dog"]}'
+            }
+
+            public pets = ['cat', 'dog']
+            // end::script_with_explicit_instance_run[]
+        '''
+    }
+
+    void testJep445Definition() {
+        runScript '''
+            // tag::jep445_barescript[]
+            void main(args) {
+                println new Date()
+            }
+            // end::jep445_barescript[]
+        '''
+
+        runScript '''
+            // tag::jep445_script[]
+            def main() {
+                assert upper(foo) + lower(bar) == 'FOObar'
+            }
+
+            def upper(s) { s.toUpperCase() }
+
+            def lower = String::toLowerCase
+            def (foo, bar) = ['Foo', 'Bar']      // <1>
+            // end::jep445_script[]
+        '''
     }
 
     void testMethodDefinition() {
@@ -103,4 +152,8 @@ class ScriptsAndClassesSpecTest extends GroovyTestCase {
             // end::script_with_untyped_variables[]
         '''
     }
+
+    private static void runScript(String scriptText) {
+        new GroovyShell().run(scriptText, 'ScriptSnippet', [] as String[])
+    }
 }
diff --git a/src/test/groovy/bugs/Groovy3749Bug.groovy b/src/test/groovy/bugs/Groovy3749Bug.groovy
index d7cada5ec1..0b3adbdaa9 100644
--- a/src/test/groovy/bugs/Groovy3749Bug.groovy
+++ b/src/test/groovy/bugs/Groovy3749Bug.groovy
@@ -24,48 +24,48 @@ class Groovy3749Bug extends GroovyTestCase {
     void testScriptsProvidingStaticMainMethod() {
         def scriptStr
 
-        // test various signatures of main()
+        // test various signatures of static main()
         scriptStr = """
             static main(args) {
                 throw new RuntimeException('main called')
             }
         """
-        verifyScriptRun(scriptStr, "RuntimeException")
+        assertScriptFails(scriptStr, "RuntimeException")
 
         scriptStr = """
             static def main(args) {
                 throw new RuntimeException('main called')
             }
         """
-        verifyScriptRun(scriptStr, "RuntimeException")
+        assertScriptFails(scriptStr, "RuntimeException")
 
         scriptStr = """
             static void main(args) {
                 throw new RuntimeException('main called')
             }
         """
-        verifyScriptRun(scriptStr, "RuntimeException")
+        assertScriptFails(scriptStr, "RuntimeException")
 
         scriptStr = """
             static main(String[] args) {
                 throw new RuntimeException('main called')
             }
         """
-        verifyScriptRun(scriptStr, "RuntimeException")
+        assertScriptFails(scriptStr, "RuntimeException")
 
         scriptStr = """
             static def main(String[] args) {
                 throw new RuntimeException('main called')
             }
         """
-        verifyScriptRun(scriptStr, "RuntimeException")
+        assertScriptFails(scriptStr, "RuntimeException")
 
         scriptStr = """
             static void main(String[] args) {
                 throw new RuntimeException('main called')
             }
         """
-        verifyScriptRun(scriptStr, "RuntimeException")
+        assertScriptFails(scriptStr, "RuntimeException")
 
         // if both main() and the loose statements are provided, then the loose statements should run and not main
         scriptStr = """
@@ -74,20 +74,67 @@ class Groovy3749Bug extends GroovyTestCase {
             }
             throw new Error()
         """
-        verifyScriptRun(scriptStr, "Error")
+        assertScriptFails(scriptStr, "Error")
 
-        assertScript """
-            def main(args) {
+        scriptStr = """
+            static void main() {
+                throw new RuntimeException('main called')
+            }
+        """
+        assertScriptFails(scriptStr, "RuntimeException")
+
+        // if param type doesn't match, this main won't execute
+        runScript """
+            static main(Date args) {
                 throw new RuntimeException('main called')
             }
         """
     }
 
-    void verifyScriptRun(scriptText, expectedFailure) {
-        try{
-            assertScript(scriptText)
-        }catch(Throwable ex) {
-            assertTrue ex.class.name.contains(expectedFailure) 
+    void testScriptsProvidingInstanceMainMethod() {
+        def scriptStr
+
+        // test various signatures of instance main()
+        scriptStr = """
+            def main(String[] args) {
+                throw new RuntimeException('main called')
+            }
+        """
+        assertScriptFails(scriptStr, "RuntimeException")
+
+        scriptStr = """
+            void main(args) {
+                throw new RuntimeException('main called')
+            }
+        """
+        assertScriptFails(scriptStr, "RuntimeException")
+
+        scriptStr = """
+            void main() {
+                throw new RuntimeException('main called')
+            }
+        """
+        assertScriptFails(scriptStr, "RuntimeException")
+
+        // if param type doesn't match, this main won't execute
+        runScript """
+            def main(Date args) {
+                throw new RuntimeException('main called')
+            }
+        """
+    }
+
+    static void assertScriptFails(scriptText, expectedFailure) {
+        try {
+            runScript(scriptText)
+        } catch (Throwable ex) {
+            assert ex.class.name.contains(expectedFailure)
+            return
         }
+        fail("Expected script to fail with '$expectedFailure' but passed.")
+    }
+
+    private static void runScript(String scriptText) {
+        new GroovyShell().run(scriptText, 'Groovy3749Snippet', [] as String[])
     }
 }