Repository: groovy Updated Branches: refs/heads/GROOVY_2_6_X 77dde9ced -> 4e6f3cc71
GROOVY-7956: Provide an AST transformation which improves named parameter support Project: http://git-wip-us.apache.org/repos/asf/groovy/repo Commit: http://git-wip-us.apache.org/repos/asf/groovy/commit/4e6f3cc7 Tree: http://git-wip-us.apache.org/repos/asf/groovy/tree/4e6f3cc7 Diff: http://git-wip-us.apache.org/repos/asf/groovy/diff/4e6f3cc7 Branch: refs/heads/GROOVY_2_6_X Commit: 4e6f3cc71c725ec781bce607ca7f2640c0c3193b Parents: 77dde9c Author: paulk <pa...@asert.com.au> Authored: Tue Feb 20 10:09:59 2018 +1000 Committer: paulk <pa...@asert.com.au> Committed: Tue Feb 20 12:33:07 2018 +1000 ---------------------------------------------------------------------- .../groovy/groovy/transform/NamedDelegate.java | 29 +++ .../groovy/groovy/transform/NamedParam.java | 34 +++ .../groovy/groovy/transform/NamedParams.java | 30 +++ .../groovy/groovy/transform/NamedVariant.java | 34 +++ .../codehaus/groovy/ast/tools/GeneralUtils.java | 4 + .../transform/AbstractASTTransformation.java | 4 +- .../NamedVariantASTTransformation.java | 223 +++++++++++++++++++ .../transform/NamedVariantTransformTest.groovy | 124 +++++++++++ 8 files changed, 480 insertions(+), 2 deletions(-) ---------------------------------------------------------------------- http://git-wip-us.apache.org/repos/asf/groovy/blob/4e6f3cc7/src/main/groovy/groovy/transform/NamedDelegate.java ---------------------------------------------------------------------- diff --git a/src/main/groovy/groovy/transform/NamedDelegate.java b/src/main/groovy/groovy/transform/NamedDelegate.java new file mode 100644 index 0000000..baf54ff --- /dev/null +++ b/src/main/groovy/groovy/transform/NamedDelegate.java @@ -0,0 +1,29 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package groovy.transform; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.SOURCE) +@Target(ElementType.PARAMETER) +public @interface NamedDelegate { +} http://git-wip-us.apache.org/repos/asf/groovy/blob/4e6f3cc7/src/main/groovy/groovy/transform/NamedParam.java ---------------------------------------------------------------------- diff --git a/src/main/groovy/groovy/transform/NamedParam.java b/src/main/groovy/groovy/transform/NamedParam.java new file mode 100644 index 0000000..856e3e2 --- /dev/null +++ b/src/main/groovy/groovy/transform/NamedParam.java @@ -0,0 +1,34 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package groovy.transform; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Repeatable; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.PARAMETER) +@Repeatable(NamedParams.class) +public @interface NamedParam { + String value(); + Class type() default Object.class; + boolean required() default false; +} http://git-wip-us.apache.org/repos/asf/groovy/blob/4e6f3cc7/src/main/groovy/groovy/transform/NamedParams.java ---------------------------------------------------------------------- diff --git a/src/main/groovy/groovy/transform/NamedParams.java b/src/main/groovy/groovy/transform/NamedParams.java new file mode 100644 index 0000000..646b66d --- /dev/null +++ b/src/main/groovy/groovy/transform/NamedParams.java @@ -0,0 +1,30 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package groovy.transform; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.PARAMETER) +public @interface NamedParams { + NamedParam[] value(); +} http://git-wip-us.apache.org/repos/asf/groovy/blob/4e6f3cc7/src/main/groovy/groovy/transform/NamedVariant.java ---------------------------------------------------------------------- diff --git a/src/main/groovy/groovy/transform/NamedVariant.java b/src/main/groovy/groovy/transform/NamedVariant.java new file mode 100644 index 0000000..8db0529 --- /dev/null +++ b/src/main/groovy/groovy/transform/NamedVariant.java @@ -0,0 +1,34 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package groovy.transform; + +import org.apache.groovy.lang.annotation.Incubating; +import org.codehaus.groovy.transform.GroovyASTTransformationClass; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Incubating +@Retention(RetentionPolicy.SOURCE) +@Target({ElementType.METHOD, ElementType.CONSTRUCTOR}) +@GroovyASTTransformationClass("org.codehaus.groovy.transform.NamedVariantASTTransformation") +public @interface NamedVariant { +} \ No newline at end of file http://git-wip-us.apache.org/repos/asf/groovy/blob/4e6f3cc7/src/main/java/org/codehaus/groovy/ast/tools/GeneralUtils.java ---------------------------------------------------------------------- diff --git a/src/main/java/org/codehaus/groovy/ast/tools/GeneralUtils.java b/src/main/java/org/codehaus/groovy/ast/tools/GeneralUtils.java index 875cc48..b45952e 100644 --- a/src/main/java/org/codehaus/groovy/ast/tools/GeneralUtils.java +++ b/src/main/java/org/codehaus/groovy/ast/tools/GeneralUtils.java @@ -193,6 +193,10 @@ public class GeneralUtils { return new CastExpression(type, expression); } + public static BooleanExpression boolX(Expression boolExpr) { + return new BooleanExpression(boolExpr); + } + public static CastExpression castX(ClassNode type, Expression expression, boolean ignoreAutoboxing) { return new CastExpression(type, expression, ignoreAutoboxing); } http://git-wip-us.apache.org/repos/asf/groovy/blob/4e6f3cc7/src/main/java/org/codehaus/groovy/transform/AbstractASTTransformation.java ---------------------------------------------------------------------- diff --git a/src/main/java/org/codehaus/groovy/transform/AbstractASTTransformation.java b/src/main/java/org/codehaus/groovy/transform/AbstractASTTransformation.java index 8c1de6c..947c7f1 100644 --- a/src/main/java/org/codehaus/groovy/transform/AbstractASTTransformation.java +++ b/src/main/java/org/codehaus/groovy/transform/AbstractASTTransformation.java @@ -259,8 +259,8 @@ public abstract class AbstractASTTransformation implements Opcodes, ASTTransform return true; } - public static boolean hasAnnotation(ClassNode cNode, ClassNode annotation) { - List annots = cNode.getAnnotations(annotation); + public static boolean hasAnnotation(AnnotatedNode node, ClassNode annotation) { + List annots = node.getAnnotations(annotation); return (annots != null && !annots.isEmpty()); } http://git-wip-us.apache.org/repos/asf/groovy/blob/4e6f3cc7/src/main/java/org/codehaus/groovy/transform/NamedVariantASTTransformation.java ---------------------------------------------------------------------- diff --git a/src/main/java/org/codehaus/groovy/transform/NamedVariantASTTransformation.java b/src/main/java/org/codehaus/groovy/transform/NamedVariantASTTransformation.java new file mode 100644 index 0000000..3bd0e8f --- /dev/null +++ b/src/main/java/org/codehaus/groovy/transform/NamedVariantASTTransformation.java @@ -0,0 +1,223 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.codehaus.groovy.transform; + +import groovy.transform.NamedDelegate; +import groovy.transform.NamedParam; +import groovy.transform.NamedVariant; +import org.codehaus.groovy.ast.ASTNode; +import org.codehaus.groovy.ast.AnnotationNode; +import org.codehaus.groovy.ast.ClassHelper; +import org.codehaus.groovy.ast.ClassNode; +import org.codehaus.groovy.ast.ConstructorNode; +import org.codehaus.groovy.ast.MethodNode; +import org.codehaus.groovy.ast.Parameter; +import org.codehaus.groovy.ast.PropertyNode; +import org.codehaus.groovy.ast.expr.ArgumentListExpression; +import org.codehaus.groovy.ast.expr.ConstantExpression; +import org.codehaus.groovy.ast.expr.Expression; +import org.codehaus.groovy.ast.expr.MapEntryExpression; +import org.codehaus.groovy.ast.expr.MapExpression; +import org.codehaus.groovy.ast.stmt.AssertStatement; +import org.codehaus.groovy.ast.stmt.BlockStatement; +import org.codehaus.groovy.ast.stmt.ForStatement; +import org.codehaus.groovy.ast.tools.GenericsUtils; +import org.codehaus.groovy.control.CompilePhase; +import org.codehaus.groovy.control.SourceUnit; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.apache.groovy.ast.tools.ClassNodeUtils.isInnerClass; +import static org.codehaus.groovy.ast.ClassHelper.STRING_TYPE; +import static org.codehaus.groovy.ast.ClassHelper.make; +import static org.codehaus.groovy.ast.ClassHelper.makeWithoutCaching; +import static org.codehaus.groovy.ast.tools.GeneralUtils.args; +import static org.codehaus.groovy.ast.tools.GeneralUtils.boolX; +import static org.codehaus.groovy.ast.tools.GeneralUtils.callThisX; +import static org.codehaus.groovy.ast.tools.GeneralUtils.callX; +import static org.codehaus.groovy.ast.tools.GeneralUtils.castX; +import static org.codehaus.groovy.ast.tools.GeneralUtils.classX; +import static org.codehaus.groovy.ast.tools.GeneralUtils.constX; +import static org.codehaus.groovy.ast.tools.GeneralUtils.ctorX; +import static org.codehaus.groovy.ast.tools.GeneralUtils.getAllProperties; +import static org.codehaus.groovy.ast.tools.GeneralUtils.list2args; +import static org.codehaus.groovy.ast.tools.GeneralUtils.param; +import static org.codehaus.groovy.ast.tools.GeneralUtils.plusX; +import static org.codehaus.groovy.ast.tools.GeneralUtils.propX; +import static org.codehaus.groovy.ast.tools.GeneralUtils.stmt; +import static org.codehaus.groovy.ast.tools.GeneralUtils.varX; + +@GroovyASTTransformation(phase = CompilePhase.SEMANTIC_ANALYSIS) +public class NamedVariantASTTransformation extends AbstractASTTransformation { + private static final Class MY_CLASS = NamedVariant.class; + private static final ClassNode MY_TYPE = make(MY_CLASS); + private static final String MY_TYPE_NAME = "@" + MY_TYPE.getNameWithoutPackage(); + private static final ClassNode NAMED_PARAM_TYPE = makeWithoutCaching(NamedParam.class, false); + private static final ClassNode NAMED_DELEGATE_TYPE = makeWithoutCaching(NamedDelegate.class, false); + private static final ClassNode MAP_TYPE = makeWithoutCaching(Map.class, false); + + @Override + public void visit(ASTNode[] nodes, SourceUnit source) { + init(nodes, source); + MethodNode mNode = (MethodNode) nodes[1]; + AnnotationNode anno = (AnnotationNode) nodes[0]; + if (!MY_TYPE.equals(anno.getClassNode())) return; + + Parameter[] fromParams = mNode.getParameters(); + if (fromParams.length == 0) { + addError("Error during " + MY_TYPE_NAME + " processing. No-args method not supported.", mNode); + return; + } + + Parameter mapParam = param(GenericsUtils.nonGeneric(ClassHelper.MAP_TYPE), "__namedArgs"); + List<Parameter> genParams = new ArrayList<Parameter>(); + genParams.add(mapParam); + ClassNode cNode = mNode.getDeclaringClass(); + final BlockStatement inner = new BlockStatement(); + ArgumentListExpression args = new ArgumentListExpression(); + final List<String> propNames = new ArrayList<String>(); + + // first pass, just check for absence of annotations of interest + boolean annoFound = false; + for (Parameter fromParam : fromParams) { + if (hasAnnotation(fromParam, NAMED_PARAM_TYPE) || hasAnnotation(fromParam, NAMED_DELEGATE_TYPE)) { + annoFound = true; + } + } + + if (!annoFound) { + // assume the first param is the delegate by default + processDelegateParam(mNode, mapParam, args, propNames, fromParams[0]); + } else { + for (Parameter fromParam : fromParams) { + if (hasAnnotation(fromParam, NAMED_PARAM_TYPE)) { + AnnotationNode namedParam = fromParam.getAnnotations(NAMED_PARAM_TYPE).get(0); + boolean required = memberHasValue(namedParam, "required", true); + if (getMemberValue(namedParam, "name") == null) { + namedParam.addMember("value", constX(fromParam.getName())); + } + String name = getMemberStringValue(namedParam, "value"); + if (getMemberValue(namedParam, "type") == null) { + namedParam.addMember("type", classX(fromParam.getType())); + } + if (!checkDuplicates(mNode, propNames, name)) return; + // TODO check specified type is assignable from declared param type? + // ClassNode type = getMemberClassValue(namedParam, "type"); + if (required) { + if (fromParam.hasInitialExpression()) { + addError("Error during " + MY_TYPE_NAME + " processing. A required parameter can't have an initial value.", mNode); + return; + } + inner.addStatement(new AssertStatement(boolX(callX(varX(mapParam), "containsKey", args(constX(name)))), + plusX(new ConstantExpression("Missing required named argument '" + name + "'. Keys found: "), callX(varX(mapParam), "keySet")))); + } + args.addExpression(propX(varX(mapParam), name)); + mapParam.addAnnotation(namedParam); + fromParam.getAnnotations().remove(namedParam); + } else if (hasAnnotation(fromParam, NAMED_DELEGATE_TYPE)) { + if (!processDelegateParam(mNode, mapParam, args, propNames, fromParam)) return; + } else { + args.addExpression(varX(fromParam)); + if (!checkDuplicates(mNode, propNames, fromParam.getName())) return; + genParams.add(fromParam); + } + } + } + Parameter namedArgKey = param(STRING_TYPE, "namedArgKey"); + inner.addStatement( + new ForStatement( + namedArgKey, + callX(varX(mapParam), "keySet"), + new AssertStatement(boolX(callX(list2args(propNames), "contains", varX(namedArgKey))), + plusX(new ConstantExpression("Unrecognized namedArgKey: "), varX(namedArgKey))) + )); + + Parameter[] genParamsArray = genParams.toArray(new Parameter[genParams.size()]); + // TODO account for default params giving multiple signatures + if (cNode.hasMethod(mNode.getName(), genParamsArray)) { + addError("Error during " + MY_TYPE_NAME + " processing. Class " + cNode.getNameWithoutPackage() + + " already has a named-arg " + (mNode instanceof ConstructorNode ? "constructor" : "method") + + " of type " + genParams, mNode); + return; + } + + final BlockStatement body = new BlockStatement(); + if (mNode instanceof ConstructorNode) { + body.addStatement(stmt(ctorX(ClassNode.THIS, args))); + body.addStatement(inner); + cNode.addConstructor( + mNode.getModifiers(), + genParamsArray, + mNode.getExceptions(), + body + ); + } else { + body.addStatement(inner); + body.addStatement(stmt(callThisX(mNode.getName(), args))); + cNode.addMethod( + mNode.getName(), + mNode.getModifiers(), + mNode.getReturnType(), + genParamsArray, + mNode.getExceptions(), + body + ); + } + } + + private boolean processDelegateParam(MethodNode mNode, Parameter mapParam, ArgumentListExpression args, List<String> propNames, Parameter fromParam) { + if (isInnerClass(fromParam.getType())) { + if (mNode.isStatic()) { + addError("Error during " + MY_TYPE_NAME + " processing. Delegate type '" + fromParam.getType().getNameWithoutPackage() + "' is an inner class which is not supported.", mNode); + return false; + } + } + + Set<String> names = new HashSet<String>(); + List<PropertyNode> props = getAllProperties(names, fromParam.getType(), true, false, false, true, false, true); + for (String next : names) { + if (!checkDuplicates(mNode, propNames, next)) return false; + } + List<MapEntryExpression> entries = new ArrayList<MapEntryExpression>(); + for (PropertyNode pNode : props) { + String name = pNode.getName(); + entries.add(new MapEntryExpression(constX(name), propX(varX(mapParam), name))); + AnnotationNode namedParam = new AnnotationNode(NAMED_PARAM_TYPE); + namedParam.addMember("value", constX(name)); + namedParam.addMember("type", classX(pNode.getType())); + mapParam.addAnnotation(namedParam); + } + Expression delegateMap = new MapExpression(entries); + args.addExpression(castX(fromParam.getType(), delegateMap)); + return true; + } + + private boolean checkDuplicates(MethodNode mNode, List<String> propNames, String next) { + if (propNames.contains(next)) { + addError("Error during " + MY_TYPE_NAME + " processing. Duplicate property '" + next + "' found.", mNode); + return false; + } + propNames.add(next); + return true; + } +} \ No newline at end of file http://git-wip-us.apache.org/repos/asf/groovy/blob/4e6f3cc7/src/test/org/codehaus/groovy/transform/NamedVariantTransformTest.groovy ---------------------------------------------------------------------- diff --git a/src/test/org/codehaus/groovy/transform/NamedVariantTransformTest.groovy b/src/test/org/codehaus/groovy/transform/NamedVariantTransformTest.groovy new file mode 100644 index 0000000..6f0bea4 --- /dev/null +++ b/src/test/org/codehaus/groovy/transform/NamedVariantTransformTest.groovy @@ -0,0 +1,124 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.codehaus.groovy.transform + +/** + * Tests for the {@code @NamedVariant} transformation. + */ +class NamedVariantTransformTest extends GroovyShellTestCase { + + void testNamedParam() { + assertScript ''' + import groovy.transform.* + + class Animal { + String type + String name + } + + @ToString(includeNames=true, includeFields=true) + class Color { + Integer r, g + private Integer b + Integer setB(Integer b) { this.b = b } + } + + @NamedVariant + String foo(a, @NamedParam String b2, @NamedDelegate Color shade, int c, @NamedParam(required=true) d, @NamedDelegate Animal pet) { + "$a $b2 $c $d ${pet.type?.toUpperCase()}:$pet.name $shade" + } + + def result = foo(b2: 'b param', g: 12, b: 42, r: 12, 'foo', 42, d:true, type: 'Dog', name: 'Rover') + assert result == 'foo b param 42 true DOG:Rover Color(r:12, g:12, b:42)' + ''' + } + + void testNamedDelegate() { + assertScript """ + import groovy.transform.* + + @ToString(includeNames=true, includeFields=true) + class Color { + Integer r, g, b + } + + @NamedVariant + String foo(Color shade) { + shade + } + + def result = foo(g: 12, b: 42, r: 12) + assert result == 'Color(r:12, g:12, b:42)' + """ + } + + void testNamedParamConstructor() { + assertScript """ + import groovy.transform.* + + @ToString(includeNames=true, includeFields=true) + class Color { + @NamedVariant + Color(@NamedParam int r, @NamedParam int g, @NamedParam int b) { + this.r = r + this.g = g + this.b = b + } + private int r, g, b + } + + assert new Color(r: 10, g: 20, b: 30).toString() == 'Color(r:10, g:20, b:30)' + """ + } + + void testNamedParamInnerClass() { + assertScript ''' + import groovy.transform.* + + class Foo { + int adjust + @ToString(includeNames = true) + class Bar { + @NamedVariant + Bar(@NamedParam int x, @NamedParam int y) { + this.x = x + adjust + this.y = y + adjust + } + int x, y + @NamedVariant + def update(@NamedParam int x, @NamedParam int y) { + this.x = x + adjust + this.y = y + adjust + } + } + def makeBar() { + new Bar(x: 0, y: 0) + } + } + + def b = new Foo(adjust: 1).makeBar() + assert b.toString() == 'Foo$Bar(x:1, y:1)' + b.update(10, 10) + assert b.toString() == 'Foo$Bar(x:11, y:11)' + b.update(x:15, y:25) + assert b.toString() == 'Foo$Bar(x:16, y:26)' + ''' + } + +}