001/*
002 * Copyright (C) 2022 - 2023, 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&lt;?, ?&gt; 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 implements
067    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 : getManagedStaticWorkspaceFields(context.getRequiredTestClass())) {
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 : getManagedInstanceWorkspaceFields(instance.getClass())) {
092        initWorkspaceForField(field, instance);
093      }
094    }
095  }
096
097  @Override
098  public void afterAll(ExtensionContext context) throws Exception {
099    for (var field : getManagedStaticWorkspaceFields(context.getRequiredTestClass())) {
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 : getManagedInstanceWorkspaceFields(instance.getClass())) {
108        closeWorkspaceForField(field, instance);
109      }
110    }
111  }
112
113  private List<Field> getManagedStaticWorkspaceFields(Class<?> clazz) {
114    // Do not recurse for static fields, as the state of any parent classes may be shared
115    // with other classes running in parallel. Need to look up how JUnit expects us to handle that
116    // case, if at all.
117
118    var fields = new ArrayList<Field>();
119
120    for (var field : clazz.getDeclaredFields()) {
121      if (isWorkspaceField(field) && Modifier.isStatic(field.getModifiers())) {
122        fields.add(field);
123      }
124    }
125
126    return fields;
127  }
128
129  private List<Field> getManagedInstanceWorkspaceFields(Class<?> clazz) {
130    // For instances, discover all the fields recursively in superclasses as well that are
131    // non-static.
132
133    var fields = new ArrayList<Field>();
134
135    while (clazz != null) {
136      for (var field : clazz.getDeclaredFields()) {
137        if (isWorkspaceField(field) && !Modifier.isStatic(field.getModifiers())) {
138          fields.add(field);
139        }
140      }
141      clazz = clazz.getSuperclass();
142    }
143
144    return fields;
145  }
146
147  private boolean isWorkspaceField(Field field) {
148    return field.getType().equals(Workspace.class)
149        && field.isAnnotationPresent(Managed.class);
150  }
151
152  private void initWorkspaceForField(Field field, @Nullable Object instance) throws Exception {
153    LOGGER
154        .atTrace()
155        .setMessage("Initialising workspace for field in {}: {} {} on instance {}")
156        .addArgument(() -> field.getDeclaringClass().getSimpleName())
157        .addArgument(() -> field.getType().getSimpleName())
158        .addArgument(field::getName)
159        .addArgument(instance)
160        .log();
161
162    field.setAccessible(true);
163    var managedWorkspace = field.getAnnotation(Managed.class);
164    var workspace = Workspaces.newWorkspace(managedWorkspace.pathStrategy());
165    field.set(instance, workspace);
166  }
167
168  private void closeWorkspaceForField(Field field, @Nullable Object instance) throws Exception {
169    LOGGER
170        .atTrace()
171        .setMessage("Closing workspace for field in {}: {} {} on instance {}")
172        .addArgument(() -> field.getDeclaringClass().getSimpleName())
173        .addArgument(() -> field.getType().getSimpleName())
174        .addArgument(field::getName)
175        .addArgument(instance)
176        .log();
177
178    field.setAccessible(true);
179    ((Workspace) field.get(instance)).close();
180  }
181}