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.jspecify.annotations.Nullable;
025import org.junit.jupiter.api.extension.AfterAllCallback;
026import org.junit.jupiter.api.extension.AfterEachCallback;
027import org.junit.jupiter.api.extension.BeforeAllCallback;
028import org.junit.jupiter.api.extension.BeforeEachCallback;
029import org.junit.jupiter.api.extension.Extension;
030import org.junit.jupiter.api.extension.ExtensionContext;
031import org.slf4j.Logger;
032import org.slf4j.LoggerFactory;
033
034/**
035 * JUnit5 extension that will manage the lifecycle of {@link Managed}-annotated {@link Workspace}
036 * fields within JUnit5 test classes.
037 *
038 * <pre><code>
039 * {@literal @ExtendWith(JctExtension.class)}
040 * class MyTest {
041 *   {@literal @Managed}
042 *   Workspace workspace;
043 *
044 *   {@literal @JavacCompilerTest}
045 *   void myTest(JctCompiler compiler) {
046 *     // Given
047 *     workspace
048 *        .createSourcePathPackage()
049 *        ...;
050 *
051 *     // When
052 *     var compilation = compiler.compile(workspace);
053 *
054 *     // Then
055 *     ...
056 *   }
057 * }
058 * </code></pre>
059 *
060 * @author Ashley Scopes
061 * @since 0.4.0
062 */
063public final class JctExtension
064    implements Extension, BeforeEachCallback, BeforeAllCallback, AfterEachCallback, AfterAllCallback {
065
066  private static final Logger log = LoggerFactory.getLogger(JctExtension.class);
067
068  /**
069   * Initialise this extension.
070   *
071   * <p>You shouldn't ever need to call this directly. See the class description for an example
072   * of how to use this.
073   */
074  public JctExtension() {
075    // Nothing to do.
076  }
077
078  @Override
079  public void beforeAll(ExtensionContext context) throws Exception {
080    for (var field : getManagedWorkspaceFields(context.getRequiredTestClass(), true)) {
081      initWorkspaceForField(field, null);
082    }
083  }
084
085  @Override
086  public void beforeEach(ExtensionContext context) throws Exception {
087    for (var instance : context.getRequiredTestInstances().getAllInstances()) {
088      for (var field : getManagedWorkspaceFields(instance.getClass(), false)) {
089        initWorkspaceForField(field, instance);
090      }
091    }
092  }
093
094  @Override
095  public void afterAll(ExtensionContext context) throws Exception {
096    for (var field : getManagedWorkspaceFields(context.getRequiredTestClass(), true)) {
097      closeWorkspaceForField(field, null);
098    }
099  }
100
101  @Override
102  public void afterEach(ExtensionContext context) throws Exception {
103    for (var instance : context.getRequiredTestInstances().getAllInstances()) {
104      for (var field : getManagedWorkspaceFields(instance.getClass(), false)) {
105        closeWorkspaceForField(field, instance);
106      }
107    }
108  }
109
110  private List<Field> getManagedWorkspaceFields(Class<?> clazz, boolean wantStatic) {
111    var fields = new ArrayList<Field>();
112
113    Class<?> currentClass = clazz;
114
115    do {
116      for (var field : currentClass.getDeclaredFields()) {
117        var isWorkspace = field.getType().equals(Workspace.class);
118        var isManaged = field.isAnnotationPresent(Managed.class);
119        var isDesiredScope = Modifier.isStatic(field.getModifiers()) == wantStatic;
120
121        if (isWorkspace && isManaged && isDesiredScope) {
122          field.setAccessible(true);
123          fields.add(field);
124        }
125      }
126
127      // Only recurse if we are checking instance scope. We don't manage annotated fields
128      // in superclasses that are static as we cannot guarantee they are not shared with a
129      // different class running in parallel.
130      currentClass = wantStatic
131          ? null
132          : currentClass.getSuperclass();
133  
134    } while (currentClass != null);
135
136    return fields;
137  }
138
139  private void initWorkspaceForField(Field field, @Nullable Object instance) throws Exception {
140    log.atTrace()
141        .setMessage("Initialising workspace for field in {}: {} {} on instance {}")
142        .addArgument(() -> field.getDeclaringClass().getSimpleName())
143        .addArgument(() -> field.getType().getSimpleName())
144        .addArgument(field::getName)
145        .addArgument(instance)
146        .log();
147
148    var managedWorkspace = field.getAnnotation(Managed.class);
149    var workspace = Workspaces.newWorkspace(managedWorkspace.pathStrategy());
150    field.set(instance, workspace);
151  }
152
153  private void closeWorkspaceForField(Field field, @Nullable Object instance) throws Exception {
154    log.atTrace()
155        .setMessage("Closing 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    var workspace = (Workspace) field.get(instance);
163    workspace.close();
164  }
165}