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.filemanagers;
017
018import io.github.ascopes.jct.utils.LoomPolyfill;
019import io.github.ascopes.jct.utils.ToStringBuilder;
020import java.lang.reflect.InvocationHandler;
021import java.lang.reflect.Method;
022import java.lang.reflect.Parameter;
023import java.lang.reflect.Proxy;
024import java.util.Objects;
025import java.util.function.Supplier;
026import java.util.stream.Collectors;
027import java.util.stream.Stream;
028import org.apiguardian.api.API;
029import org.apiguardian.api.API.Status;
030import org.jspecify.annotations.Nullable;
031import org.slf4j.Logger;
032import org.slf4j.LoggerFactory;
033
034/**
035 * A proxy that wraps a {@link JctFileManager} in a proxy that can log all interactions with the
036 * JavaFileManager, along with a corresponding stacktrace.
037 *
038 * <p>This is useful for diagnosing difficult-to-find errors being produced by {@code javac}
039 * during testing, however, it may produce a hefty performance overhead when in use.
040 *
041 * <p>All logs are emitted with the {@code DEBUG} logging level.
042 *
043 * @author Ashley Scopes
044 * @since 0.0.1
045 */
046@API(since = "0.0.1", status = Status.STABLE)
047public final class LoggingFileManagerProxy implements InvocationHandler {
048
049  private final Logger logger;
050  private final JctFileManager inner;
051  private final boolean stackTraces;
052  private final ThreadLocal<Integer> stackDepth;
053
054  private LoggingFileManagerProxy(JctFileManager inner, boolean stackTraces) {
055    // Instance scoped for testing purposes.
056    logger = LoggerFactory.getLogger(LoggingFileManagerProxy.class);
057    this.inner = inner;
058    this.stackTraces = stackTraces;
059    stackDepth = ThreadLocal.withInitial(() -> 0);
060  }
061
062  /**
063   * Invoke the given call.
064   *
065   * @param proxy  the proxy.
066   * @param method the method.
067   * @param args   the arguments.
068   * @return the result.
069   * @throws Throwable any exception that is thrown.
070   */
071  @Override
072  public Object invoke(Object proxy, Method method, Object @Nullable ... args) throws Throwable {
073    if (method.getName().equals("toString")) {
074      return toString();
075    }
076
077    var thread = LoomPolyfill.getCurrentThread();
078    var threadId = LoomPolyfill.getThreadId(thread);
079
080    var depth = incrementStackDepth();
081    var returnType = method.getReturnType().getSimpleName();
082    var methodName = method.getName();
083    var paramStr = Stream
084        .of(method.getParameters())
085        .map(Parameter::getType)
086        .map(Class::getSimpleName)
087        .collect(Collectors.joining(", "));
088
089    // When no arguments are passed, the args array is
090    // null rather than a zero length array (i.e. Object[0]).
091    var argsStr = args == null ? "" : Stream
092        .of(args)
093        .map(Objects::toString)
094        .collect(Collectors.joining(", "));
095
096    logger
097        .atDebug()
098        .setMessage(">>> [thread={}, depth={}] {} {}({}) called with ({}){}")
099        .addArgument(threadId)
100        .addArgument(depth)
101        .addArgument(returnType)
102        .addArgument(methodName)
103        .addArgument(paramStr)
104        .addArgument(argsStr)
105        .addArgument(stackTraceFormatter(thread.getStackTrace()))
106        .log();
107
108    try {
109      var result = method.invoke(inner, args);
110
111      if (method.getReturnType().equals(void.class)) {
112        logger
113            .atDebug()
114            .setMessage("<<< [thread={}, depth={}] {} {}({}) completed")
115            .addArgument(threadId)
116            .addArgument(depth)
117            .addArgument(returnType)
118            .addArgument(methodName)
119            .addArgument(paramStr)
120            .log();
121      } else {
122        logger
123            .atDebug()
124            .setMessage("<<< [thread={}, depth={}] {} {}({}) returned {}")
125            .addArgument(threadId)
126            .addArgument(depth)
127            .addArgument(returnType)
128            .addArgument(methodName)
129            .addArgument(paramStr)
130            .addArgument(result)
131            .log();
132      }
133
134      return result;
135
136    } catch (ReflectiveOperationException ex) {
137
138      logger
139          .atDebug()
140          .setMessage("!!! [thread={}, depth={}] {} {}({}) threw exception")
141          .addArgument(threadId)
142          .addArgument(depth)
143          .addArgument(returnType)
144          .addArgument(methodName)
145          .addArgument(paramStr)
146          .addArgument(ex.getCause())
147          .log();
148
149      throw ex.getCause();
150
151    } finally {
152      decrementStackDepth();
153    }
154  }
155
156  @Override
157  public String toString() {
158    return new ToStringBuilder(this)
159        .attribute("inner", inner)
160        .attribute("stackTraces", stackTraces)
161        .toString();
162  }
163
164  private int incrementStackDepth() {
165    var depth = stackDepth.get() + 1;
166    stackDepth.set(depth);
167    return depth;
168  }
169
170  private void decrementStackDepth() {
171    var depth = stackDepth.get() - 1;
172    stackDepth.set(depth);
173  }
174
175  private Supplier<String> stackTraceFormatter(StackTraceElement[] stackTrace) {
176    if (!stackTraces) {
177      return () -> "";
178    }
179
180    return () -> Stream
181        .of(stackTrace)
182        .map(frame -> "\n\t" + frame)
183        .collect(Collectors.joining());
184  }
185
186  /**
187   * Wrap the given {@link JctFileManager} in a proxy that logs any calls.
188   *
189   * @param manager     the manager to wrap.
190   * @param stackTraces {@code true} to dump stacktraces on each interception, or {@code false} to
191   *                    omit them.
192   * @return the proxy {@link JctFileManager} to use.
193   */
194  public static JctFileManager wrap(JctFileManager manager, boolean stackTraces) {
195    return (JctFileManager) Proxy.newProxyInstance(
196        JctFileManager.class.getClassLoader(),
197        new Class<?>[]{JctFileManager.class},
198        new LoggingFileManagerProxy(manager, stackTraces)
199    );
200  }
201}