001/* 002 * Copyright (C) 2022 - 2024, the original author or authors. 003 * 004 * Licensed under the Apache License, Version 2.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * 008 * http://www.apache.org/licenses/LICENSE-2.0 009 * 010 * Unless required by applicable law or agreed to in writing, software 011 * distributed under the License is distributed on an "AS IS" BASIS, 012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 013 * See the License for the specific language governing permissions and 014 * limitations under the License. 015 */ 016package io.github.ascopes.jct.junit; 017 018import io.github.ascopes.jct.workspaces.Workspace; 019import io.github.ascopes.jct.workspaces.Workspaces; 020import java.lang.reflect.Field; 021import java.lang.reflect.Modifier; 022import java.util.ArrayList; 023import java.util.List; 024import org.apiguardian.api.API; 025import org.apiguardian.api.API.Status; 026import org.jspecify.annotations.Nullable; 027import org.junit.jupiter.api.extension.AfterAllCallback; 028import org.junit.jupiter.api.extension.AfterEachCallback; 029import org.junit.jupiter.api.extension.BeforeAllCallback; 030import org.junit.jupiter.api.extension.BeforeEachCallback; 031import org.junit.jupiter.api.extension.Extension; 032import org.junit.jupiter.api.extension.ExtensionContext; 033import org.slf4j.Logger; 034import org.slf4j.LoggerFactory; 035 036/** 037 * JUnit5 extension that will manage the lifecycle of {@link Managed}-annotated {@link Workspace} 038 * fields within JUnit5 test classes. 039 * 040 * <pre><code> 041 * {@literal @ExtendWith(JctExtension.class)} 042 * class MyTest { 043 * {@literal @Managed} 044 * Workspace workspace; 045 * 046 * {@literal @JavacCompilerTest} 047 * void myTest(JctCompiler compiler) { 048 * // Given 049 * workspace 050 * .createSourcePathPackage() 051 * ...; 052 * 053 * // When 054 * var compilation = compiler.compile(workspace); 055 * 056 * // Then 057 * ... 058 * } 059 * } 060 * </code></pre> 061 * 062 * @author Ashley Scopes 063 * @since 0.4.0 064 */ 065@API(since = "0.4.0", status = Status.STABLE) 066public final class JctExtension 067 implements Extension, BeforeEachCallback, BeforeAllCallback, AfterEachCallback, AfterAllCallback { 068 069 private static final Logger LOGGER = LoggerFactory.getLogger(JctExtension.class); 070 071 /** 072 * Initialise this extension. 073 * 074 * <p>You shouldn't ever need to call this directly. See the class description for an example 075 * of how to use this. 076 */ 077 public JctExtension() { 078 // Nothing to do. 079 } 080 081 @Override 082 public void beforeAll(ExtensionContext context) throws Exception { 083 for (var field : getManagedWorkspaceFields(context.getRequiredTestClass(), true)) { 084 initWorkspaceForField(field, null); 085 } 086 } 087 088 @Override 089 public void beforeEach(ExtensionContext context) throws Exception { 090 for (var instance : context.getRequiredTestInstances().getAllInstances()) { 091 for (var field : getManagedWorkspaceFields(instance.getClass(), false)) { 092 initWorkspaceForField(field, instance); 093 } 094 } 095 } 096 097 @Override 098 public void afterAll(ExtensionContext context) throws Exception { 099 for (var field : getManagedWorkspaceFields(context.getRequiredTestClass(), true)) { 100 closeWorkspaceForField(field, null); 101 } 102 } 103 104 @Override 105 public void afterEach(ExtensionContext context) throws Exception { 106 for (var instance : context.getRequiredTestInstances().getAllInstances()) { 107 for (var field : getManagedWorkspaceFields(instance.getClass(), false)) { 108 closeWorkspaceForField(field, instance); 109 } 110 } 111 } 112 113 private List<Field> getManagedWorkspaceFields(Class<?> clazz, boolean wantStatic) { 114 var fields = new ArrayList<Field>(); 115 116 @Nullable 117 Class<?> currentClass = clazz; 118 119 do { 120 for (var field : currentClass.getDeclaredFields()) { 121 var isWorkspace = field.getType().equals(Workspace.class); 122 var isManaged = field.isAnnotationPresent(Managed.class); 123 var isDesiredScope = Modifier.isStatic(field.getModifiers()) == wantStatic; 124 125 if (isWorkspace && isManaged && isDesiredScope) { 126 field.setAccessible(true); 127 fields.add(field); 128 } 129 } 130 131 // Only recurse if we are checking instance scope. We don't manage annotated fields 132 // in superclasses that are static as we cannot guarantee they are not shared with a 133 // different class running in parallel. 134 currentClass = wantStatic 135 ? null 136 : currentClass.getSuperclass(); 137 138 } while (currentClass != null); 139 140 return fields; 141 } 142 143 private void initWorkspaceForField(Field field, @Nullable Object instance) throws Exception { 144 LOGGER 145 .atTrace() 146 .setMessage("Initialising workspace for field in {}: {} {} on instance {}") 147 .addArgument(() -> field.getDeclaringClass().getSimpleName()) 148 .addArgument(() -> field.getType().getSimpleName()) 149 .addArgument(field::getName) 150 .addArgument(instance) 151 .log(); 152 153 var managedWorkspace = field.getAnnotation(Managed.class); 154 var workspace = Workspaces.newWorkspace(managedWorkspace.pathStrategy()); 155 field.set(instance, workspace); 156 } 157 158 private void closeWorkspaceForField(Field field, @Nullable Object instance) throws Exception { 159 LOGGER 160 .atTrace() 161 .setMessage("Closing workspace for field in {}: {} {} on instance {}") 162 .addArgument(() -> field.getDeclaringClass().getSimpleName()) 163 .addArgument(() -> field.getType().getSimpleName()) 164 .addArgument(field::getName) 165 .addArgument(instance) 166 .log(); 167 168 var workspace = (Workspace) field.get(instance); 169 workspace.close(); 170 } 171}