Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/release_notes.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ include::include.adoc[]

=== Enhancements

* Add support for statically compiled specifications: conditions, interactions, `old()`, and data tables referencing previous columns now work in specs or feature methods annotated with `@CompileStatic` or `@TypeChecked`, see <<spock_primer.adoc#statically-compiled-specifications,Statically Compiled Specifications>> spockPull:2397[]
* Add support for `final` local variables in `where:` blocks, declared at their beginning and evaluated once per feature, scoped to the where-block spockIssue:138[]
* Improve `TooManyInvocationsError` now reports unsatisfied interactions with argument mismatch details, making it easier to diagnose why invocations didn't match expected interactions spockPull:2315[]

Expand Down
29 changes: 29 additions & 0 deletions docs/spock_primer.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -788,6 +788,35 @@ will be rendered as
include::{snapshotdir}/primer/VerifyEachDocSpec/verifyEach_with_index_method.txt[]
----

[[statically-compiled-specifications]]
== Statically Compiled Specifications

Specifications can be compiled with Groovy's static compilation by annotating the specification class or individual
feature methods with `@CompileStatic` or `@TypeChecked`. All major features work under static compilation: conditions
including their failure rendering, exception conditions, interactions and stubbing, `old()`, `with`, `verifyAll`, and
data driven features.

Since the static type checker analyzes the code after Spock has rewritten it, a few rules apply:

* Mock creation must use a typed variable declaration, e.g. `List list = Mock()` or `List list = Mock(List)`.
With `def list = Mock(List)` the variable is typed as `Object`, so calls on it will not pass type checking.
* Data variables in `where:` blocks default to `Object`. Declare typed feature method parameters to use them
with methods or operators, e.g. `def "maximum of two numbers"(int a, int b, int c)`.
* Precondition closures of `@IgnoreIf` and `@Requires` need an explicitly typed parameter, e.g.
`@IgnoreIf({ PreconditionContext it -> it.os.windows })`.

Some limitations remain:

* Global Groovy mocks (e.g. `GroovySpy(Foo, global: true)`) rely on Groovy's meta-object protocol and therefore
cannot intercept calls made from statically compiled code. The real method is called instead.
* Dynamic language features, like dynamic method names or `methodMissing`, are not available, as in any
statically compiled Groovy code.
* Spock detects static compilation via the annotations. If static compilation is instead applied globally through
a compiler configuration script, Spock does not see it and conditions will fail to compile.
* Condition rendering can differ in minor ways. For example, the target class of a static method call like
`Math.max(a, b)` is not rendered, because statically compiled code never evaluates the receiver of a static
method call, so there is no value to record.

[[specifications-as-documentation]]
== Specifications as Documentation

Expand Down
39 changes: 39 additions & 0 deletions spock-core/src/main/java/org/spockframework/compiler/AstUtil.java
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,45 @@ public static boolean isJointCompiled(ClassNode clazz) {
return clazz.getModule().getUnit().getConfig().getJointCompilationOptions() != null;
}

/**
* Tells whether the given method is subject to static type checking, i.e. it is
* annotated with {@code @CompileStatic} or {@code @TypeChecked}, or declared in a
* class that is. {@code @CompileDynamic} and {@code TypeCheckingMode.SKIP} opt
* out again. Static type checking applied by other means, e.g. a compiler
* configuration script, is not detected.
*/
public static boolean isStaticallyTypeChecked(MethodNode method) {
Boolean methodLevel = staticTypeCheckingState(method.getAnnotations());
if (methodLevel != null) return methodLevel;
for (ClassNode clazz = method.getDeclaringClass(); clazz != null; clazz = clazz.getOuterClass()) {
Boolean classLevel = staticTypeCheckingState(clazz.getAnnotations());
if (classLevel != null) return classLevel;
}
return false;
}

private static Boolean staticTypeCheckingState(List<AnnotationNode> annotations) {
for (AnnotationNode annotation : annotations) {
String name = annotation.getClassNode().getName();
if ("groovy.transform.CompileDynamic".equals(name)) return false;
if ("groovy.transform.CompileStatic".equals(name) || "groovy.transform.TypeChecked".equals(name)) {
Expression typeCheckingMode = annotation.getMember("value");
return typeCheckingMode == null || !typeCheckingMode.getText().endsWith("SKIP");
}
}
return null;
}

/**
* Creates a fresh expression referencing {@code this}. Generated code must not share
* {@link VariableExpression#THIS_EXPRESSION} between use sites, because the static type
* checker stores inferred types as per-node metadata; a singleton node used both inside
* and outside of closures ends up with conflicting casts in the generated bytecode.
*/
public static VariableExpression createThisExpression() {
return new VariableExpression("this");
}

public static MethodCallExpression createMethodCall(Expression target, String methodName, Expression arguments) {
MethodCallExpression result = new MethodCallExpression(target, methodName, arguments);
// and https://github.com/spockframework/spock/issues/1200
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,12 +73,16 @@ public class ConditionRewriter extends AbstractExpressionConverter<Expression> i

private final String errorCollectorName;

private final boolean staticallyTypeChecked;

private int recordCount = 0;

private ConditionRewriter(IRewriteResources resources, String valueRecorderSuffix, String errorCollectorSuffix) {
this.resources = resources;
valueRecorderName = SpockNames.VALUE_RECORDER + valueRecorderSuffix;
errorCollectorName = SpockNames.ERROR_COLLECTOR + errorCollectorSuffix;
org.spockframework.compiler.model.Method currentMethod = resources.getCurrentMethod();
staticallyTypeChecked = currentMethod != null && AstUtil.isStaticallyTypeChecked(currentMethod.getAst());
}

public static Statement rewriteExplicitCondition(AssertStatement stat, IRewriteResources resources) {
Expand Down Expand Up @@ -116,22 +120,47 @@ public void visitMethodCallExpression(MethodCallExpression expr) {
&& (!AstUtil.hasPlausibleSourcePosition(expr.getMethod()) // before GROOVY-4344 fix
|| (expr.getMethod().getColumnNumber() == expr.getObjectExpression().getColumnNumber())); // after GROOVY-4344 fix

Expression method = expr.getMethod();
MethodCallExpression conversion =
new MethodCallExpression(
expr.isImplicitThis() ?
expr.getObjectExpression() :
convert(expr.getObjectExpression()),
objectExprSeenAsMethodNameAtRuntime ?
expr.getMethod() :
convert(expr.getMethod()),
method :
convertMethodName(method),
convert(expr.getArguments()));

conversion.setSafe(expr.isSafe());
conversion.setSpreadSafe(expr.isSpreadSafe());
if (staticallyTypeChecked) {
// must be kept in sync, otherwise a qualified call with a constant method
// name would be dispatched as an implicit-this call, e.g. to a local
// closure variable of the same name; only done for statically checked code
// to keep the dispatch of dynamic code untouched
conversion.setImplicitThis(expr.isImplicitThis());
}
conversion.setSourcePosition(expr);
result = record(conversion);
}

/**
* Wrapping a method name in record() turns the call into a dynamic method name
* call, which does not pass static type checking. Method names synthesized by an
* earlier rewrite step (e.g. old() -> oldImpl()) and, in statically type checked
* code, all constant method names therefore stay as they are; their value slot is
* still allocated to keep the numbering intact, it just stays N/A, which the
* renderer never displays for method names anyway. Dynamic code keeps the recorded
* method name to leave its dispatch semantics untouched.
*/
private Expression convertMethodName(Expression method) {
if (method instanceof ConstantExpression
&& (staticallyTypeChecked || !AstUtil.hasPlausibleSourcePosition(method))) {
return recordNa(method);
}
return convert(method);
}

// only used for statically imported methods called by their simple name
@Override
public void visitStaticMethodCallExpression(StaticMethodCallExpression expr) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -470,7 +470,7 @@ private MethodCallExpression createBlockListenerCall(Block block, MethodNode blo
new ClassExpression(nodeCache.SpockRuntime),
blockListenerMethod,
new ArgumentListExpression(
VariableExpression.THIS_EXPRESSION,
AstUtil.createThisExpression(),
new ConstantExpression(block.getBlockMetaDataIndex(), true)
));
}
Expand Down Expand Up @@ -693,7 +693,7 @@ public VariableExpression captureOldValue(Expression oldValue) {
}

public MethodCallExpression getSpecificationContext() {
return createDirectMethodCall(VariableExpression.THIS_EXPRESSION,
return createDirectMethodCall(AstUtil.createThisExpression(),
nodeCache.Specification_GetSpecificationContext, ArgumentListExpression.EMPTY_ARGUMENTS);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ public ClosureExpression getClosureExpr() {
@Override
public void expand() {
List<Expression> args = new ArrayList<>();
args.add(VariableExpression.THIS_EXPRESSION);
args.add(AstUtil.createThisExpression());
args.add(inferredName);
args.add(inferredType);
args.addAll(AstUtil.getArgumentList(methodCallExpr));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -804,18 +804,36 @@ private void turnIntoSimpleParameterization(List<Expression> column) throws Inva
private void generatePreviousColumnExtractorStatements(Set<String> referencedPreviousVariables, int row,
List<Statement> statements) {
for (String referencedPreviousVariable : referencedPreviousVariables) {
ClassNode declaredType = getDeclaredDataVariableType(referencedPreviousVariable);
Expression rowValue = createDirectMethodCall(
new VariableExpression(getDataTableParameterName(referencedPreviousVariable)),
resources.getAstNodeCache().List_Get,
new ConstantExpression(row));
if (declaredType != null) {
// use the declared feature parameter type, so that cell expressions
// referencing previous columns pass static type checking
CastExpression coercedRowValue = new CastExpression(declaredType, rowValue);
coercedRowValue.setCoerce(true);
rowValue = coercedRowValue;
}
statements.add(new ExpressionStatement(
// Type x = $spock_p_x.get(row) as Type
// or, without a declared parameter type
// def x = $spock_p_x.get(row)
new DeclarationExpression(
new VariableExpression(referencedPreviousVariable),
new VariableExpression(referencedPreviousVariable, declaredType == null ? ClassHelper.DYNAMIC_TYPE : declaredType),
Token.newSymbol(Types.ASSIGN, -1, -1),
createDirectMethodCall(
new VariableExpression(getDataTableParameterName(referencedPreviousVariable)),
resources.getAstNodeCache().List_Get,
new ConstantExpression(row)))));
rowValue)));
}
}

private ClassNode getDeclaredDataVariableType(String name) {
for (Parameter parameter : whereBlock.getParent().getAst().getParameters()) {
if (parameter.getName().equals(name) && !parameter.isDynamicTyped()) return parameter.getType();
}
return null;
}

private List<VariableExpression> getReferencedPreviousVariables(List<String> previousVariables, Expression providerExpression) {
return previousVariables
.stream()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,10 @@ public ValueRecorder reset() {
/**
* Records and returns the specified value. Hence an expression can be replaced
* with record(expression) without impacting evaluation of the expression.
* The generic return type preserves the static type of the wrapped expression,
* allowing conditions to pass static type checking (e.g. under {@code @CompileStatic}).
*/
public Object record(int index, Object value) {
public <T> T record(int index, T value) {
realizeNas(index + 1, null);
values.set(index, value);

Expand Down Expand Up @@ -72,8 +74,9 @@ public int startRecordingValue(int index){

/**
* Materializes N/A values without recording a new value.
* Generic for the same reason as {@link #record(int, Object)}.
*/
public Object realizeNas(int index, Object value) {
public <T> T realizeNas(int index, T value) {
for (int i = values.size(); i < index; i++)
values.add(ExpressionInfo.VALUE_NOT_AVAILABLE);
return value;
Expand Down
Loading
Loading