diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 519a02d8..b909fd40 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -4,6 +4,7 @@ jagr = "org.sourcegrade:jagr-launcher:0.10.3"
mockito = "org.mockito:mockito-core:5.14.1"
junit = "org.junit.jupiter:junit-jupiter:5.11.2"
asm = "org.ow2.asm:asm:9.7.1"
+asmTree = "org.ow2.asm:asm-tree:9.7.1"
dokkaKotlinAsJavaPlugin = "org.jetbrains.dokka:kotlin-as-java-plugin:1.9.20"
dokkaBase = "org.jetbrains.dokka:dokka-base:1.9.20"
[plugins]
diff --git a/student/src/main/java/org/tudalgo/algoutils/student/annotation/ForceSignature.java b/student/src/main/java/org/tudalgo/algoutils/student/annotation/ForceSignature.java
new file mode 100644
index 00000000..38b6aff7
--- /dev/null
+++ b/student/src/main/java/org/tudalgo/algoutils/student/annotation/ForceSignature.java
@@ -0,0 +1,73 @@
+package org.tudalgo.algoutils.student.annotation;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * Forces the annotated type or member to be mapped to the specified one.
+ * Mappings must be 1:1, meaning multiple annotated members (or types) may not map to the same target and
+ * all members and types may be targeted by at most one annotation.
+ *
+ * @author Daniel Mangold
+ */
+@Target({ElementType.TYPE, ElementType.FIELD, ElementType.CONSTRUCTOR, ElementType.METHOD})
+@Retention(RetentionPolicy.CLASS)
+public @interface ForceSignature {
+
+ /**
+ * The identifier of the annotated type / member.
+ * The value must be as follows:
+ *
+ *
Types: the fully qualified name of the type (e.g., {@code java.lang.Object})
+ *
Fields: the name / identifier of the field
+ *
Constructors: Always {@code }, regardless of the class
+ *
Methods: the name / identifier of the method
+ *
+ *
+ * @return the type / member identifier
+ */
+ String identifier();
+
+ /**
+ * The method descriptor as specified by
+ * Chapter 4.3
+ * of the Java Virtual Machine Specification.
+ * If a value is set, it takes precedence over {@link #returnType()} and {@link #parameterTypes()}.
+ *
+ *
+ * Note: Setting this value has no effect for types or fields.
+ *
+ *
+ * @return the method's descriptor
+ */
+ String descriptor() default "";
+
+ /**
+ * The class object specifying the method's return type.
+ * If a value is set, it will be overwritten if {@link #descriptor()} is also set.
+ * Default is no return type (void).
+ *
+ *
+ * Note: Setting this value has no effect for types or fields.
+ *
+ *
+ * @return the method's return type
+ */
+ Class> returnType() default void.class;
+
+ /**
+ * An array of class objects specifying the method's parameter types.
+ * The classes need to be given in the same order as they are declared by the targeted method.
+ * If a value is set, it will be overwritten if {@link #descriptor()} is also set.
+ * Default is no parameters.
+ *
+ *
+ * Note: Setting this value has no effect for types or fields.
+ *
+ *
+ * @return the method's parameter types
+ */
+ Class>[] parameterTypes() default {};
+}
diff --git a/tutor/build.gradle.kts b/tutor/build.gradle.kts
index 5c096d2c..59a8870b 100644
--- a/tutor/build.gradle.kts
+++ b/tutor/build.gradle.kts
@@ -14,6 +14,7 @@ dependencies {
api(libs.jagr)
api(libs.mockito)
api(libs.asm)
+ api(libs.asmTree)
testImplementation(libs.junit)
dokkaPlugin(libs.dokkaKotlinAsJavaPlugin)
}
diff --git a/tutor/src/main/java/org/tudalgo/algoutils/transform/SolutionClassNode.java b/tutor/src/main/java/org/tudalgo/algoutils/transform/SolutionClassNode.java
new file mode 100644
index 00000000..b641acbf
--- /dev/null
+++ b/tutor/src/main/java/org/tudalgo/algoutils/transform/SolutionClassNode.java
@@ -0,0 +1,131 @@
+package org.tudalgo.algoutils.transform;
+
+import org.tudalgo.algoutils.transform.util.ClassHeader;
+import org.tudalgo.algoutils.transform.util.FieldHeader;
+import org.tudalgo.algoutils.transform.util.MethodHeader;
+import org.objectweb.asm.FieldVisitor;
+import org.objectweb.asm.Handle;
+import org.objectweb.asm.MethodVisitor;
+import org.objectweb.asm.Opcodes;
+import org.objectweb.asm.tree.ClassNode;
+import org.objectweb.asm.tree.FieldNode;
+import org.objectweb.asm.tree.MethodNode;
+
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.objectweb.asm.Opcodes.ACC_SYNTHETIC;
+import static org.objectweb.asm.Opcodes.ASM9;
+
+/**
+ * A class node for recording bytecode instructions of solution classes.
+ * @author Daniel Mangold
+ */
+public class SolutionClassNode extends ClassNode {
+
+ private final String className;
+ private ClassHeader classHeader;
+ private final Map fields = new HashMap<>();
+ private final Map methods = new HashMap<>();
+
+ /**
+ * Constructs a new {@link SolutionClassNode} instance.
+ *
+ * @param className the name of the solution class
+ */
+ public SolutionClassNode(String className) {
+ super(Opcodes.ASM9);
+ this.className = className;
+ }
+
+ public ClassHeader getClassHeader() {
+ return classHeader;
+ }
+
+ /**
+ * Returns the mapping of field headers to field nodes for this solution class.
+ *
+ * @return the field header => field node mapping
+ */
+ public Map getFields() {
+ return fields;
+ }
+
+ /**
+ * Returns the mapping of method headers to method nodes for this solution class.
+ *
+ * @return the method header => method node mapping
+ */
+ public Map getMethods() {
+ return methods;
+ }
+
+ @Override
+ public void visit(int version, int access, String name, String signature, String superName, String[] interfaces) {
+ classHeader = new ClassHeader(access, name, signature, superName, interfaces);
+ super.visit(version, access, name, signature, superName, interfaces);
+ }
+
+ @Override
+ public FieldVisitor visitField(int access, String name, String descriptor, String signature, Object value) {
+ FieldHeader fieldHeader = new FieldHeader(className, access, name, descriptor, signature);
+ FieldNode fieldNode = (FieldNode) super.visitField(access, name, descriptor, signature, value);
+ fields.put(fieldHeader, fieldNode);
+ return fieldNode;
+ }
+
+ @Override
+ public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) {
+ MethodNode methodNode;
+ if ((access & ACC_SYNTHETIC) != 0 && name.startsWith("lambda$")) { // if method is lambda
+ methodNode = getMethodNode(access, name + "$solution", descriptor, signature, exceptions);
+ } else {
+ methodNode = getMethodNode(access, name, descriptor, signature, exceptions);
+ methods.put(new MethodHeader(className, access, name, descriptor, signature, exceptions), methodNode);
+ }
+ return methodNode;
+ }
+
+ /**
+ * Constructs a new method node with the given information.
+ * The returned method node ensures that lambda methods of the solution class don't interfere
+ * with the ones defined in the submission class.
+ *
+ * @param access the method's modifiers
+ * @param name the method's name
+ * @param descriptor the method's descriptor
+ * @param signature the method's signature
+ * @param exceptions the method's declared exceptions
+ * @return a new {@link MethodNode}
+ */
+ private MethodNode getMethodNode(int access, String name, String descriptor, String signature, String[] exceptions) {
+ return new MethodNode(ASM9, access, name, descriptor, signature, exceptions) {
+ @Override
+ public void visitMethodInsn(int opcodeAndSource, String owner, String name, String descriptor, boolean isInterface) {
+ super.visitMethodInsn(opcodeAndSource,
+ owner,
+ name + (name.startsWith("lambda$") ? "$solution" : ""),
+ descriptor,
+ isInterface);
+ }
+
+ @Override
+ public void visitInvokeDynamicInsn(String name, String descriptor, Handle bootstrapMethodHandle, Object... bootstrapMethodArguments) {
+ super.visitInvokeDynamicInsn(name, descriptor, bootstrapMethodHandle, Arrays.stream(bootstrapMethodArguments)
+ .map(o -> {
+ if (o instanceof Handle handle && handle.getName().startsWith("lambda$")) {
+ return new Handle(handle.getTag(),
+ handle.getOwner(),
+ handle.getName() + "$solution",
+ handle.getDesc(),
+ handle.isInterface());
+ } else {
+ return o;
+ }
+ })
+ .toArray());
+ }
+ };
+ }
+}
diff --git a/tutor/src/main/java/org/tudalgo/algoutils/transform/SolutionMergingClassTransformer.java b/tutor/src/main/java/org/tudalgo/algoutils/transform/SolutionMergingClassTransformer.java
new file mode 100644
index 00000000..b4cd8366
--- /dev/null
+++ b/tutor/src/main/java/org/tudalgo/algoutils/transform/SolutionMergingClassTransformer.java
@@ -0,0 +1,101 @@
+package org.tudalgo.algoutils.transform;
+
+import org.tudalgo.algoutils.student.annotation.ForceSignature;
+import org.tudalgo.algoutils.transform.util.TransformationContext;
+import org.tudalgo.algoutils.transform.util.TransformationUtils;
+import org.objectweb.asm.ClassReader;
+import org.objectweb.asm.ClassWriter;
+import org.objectweb.asm.Type;
+import org.sourcegrade.jagr.api.testing.ClassTransformer;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * A class transformer that allows logging, substitution and delegation of method invocations.
+ * This transformer uses two source sets: solution classes and submission classes.
+ *
+ * Solution classes
+ *
+ * Solution classes are compiled java classes located in the {@code resources/classes/} directory.
+ * Their class structure must match the one defined in the exercise sheet but they may define additional
+ * members such as fields and methods.
+ * Additional types (classes, interfaces, enums, etc.) may also be defined.
+ *
+ * The directory structure must match the one in the output / build directory.
+ * Due to limitations of {@link Class#getResourceAsStream(String)} the compiled classes cannot have
+ * the {@code .class} file extension, so {@code .bin} is used instead.
+ * For example, a class {@code MyClass} with an inner class {@code Inner} in package {@code my.package}
+ * would be compiled to {@code my/package/MyClass.class} and {@code my/package/MyClass$Inner.class}.
+ * In the solution classes directory they would be located at {@code resources/classes/my/package/MyClass.bin}
+ * and {@code resources/classes/my/package/MyClass$Inner.bin}, respectively.
+ *
+ *
+ * Submission classes
+ *
+ * Submission classes are the original java classes in the main module.
+ * They are compiled externally and processed one by one using {@link #transform(ClassReader, ClassWriter)}.
+ * In case classes or members are misnamed, this transformer will attempt to map them to the closest
+ * matching solution class / member.
+ * If both the direct and similarity matching approach fail, the intended target can be explicitly
+ * specified using the {@link ForceSignature} annotation.
+ *
+ *
+ * Implementation details:
+ *
+ *
+ * Unless otherwise specified, this transformer and its tools use the internal name as specified by
+ * {@link Type#getInternalName()} when referring to class names.
+ *
+ *
+ * The term "descriptor" refers to the bytecode-level representation of types, such as
+ * field types, method return types or method parameter types as specified by
+ * Chapter 4.3
+ * of the Java Virtual Machine Specification.
+ *
+ *
+ *
+ * @see SubmissionClassVisitor
+ * @see SubmissionExecutionHandler
+ * @author Daniel Mangold
+ */
+public class SolutionMergingClassTransformer implements ClassTransformer {
+
+ /**
+ * An object providing context throughout the transformation processing chain.
+ */
+ private final TransformationContext transformationContext;
+
+ /**
+ * Constructs a new {@link SolutionMergingClassTransformer} instance.
+ *
+ * @param projectPrefix the root package containing all submission classes, usually the sheet number
+ * @param availableSolutionClasses the list of solution class names (fully qualified) to use
+ */
+ public SolutionMergingClassTransformer(String projectPrefix, List availableSolutionClasses) {
+ Map solutionClasses = new HashMap<>();
+ Map submissionClasses = new ConcurrentHashMap<>();
+ this.transformationContext = new TransformationContext(projectPrefix, solutionClasses, submissionClasses);
+ availableSolutionClasses.stream()
+ .map(s -> s.replace('.', '/'))
+ .forEach(className -> solutionClasses.put(className, TransformationUtils.readSolutionClass(className)));
+ }
+
+ @Override
+ public String getName() {
+ return SolutionMergingClassTransformer.class.getSimpleName();
+ }
+
+ @Override
+ public int getWriterFlags() {
+ return ClassWriter.COMPUTE_MAXS;
+ }
+
+ @Override
+ public void transform(ClassReader reader, ClassWriter writer) {
+ String submissionClassName = reader.getClassName();
+ reader.accept(new SubmissionClassVisitor(writer, transformationContext, submissionClassName), 0);
+ }
+}
diff --git a/tutor/src/main/java/org/tudalgo/algoutils/transform/SubmissionClassInfo.java b/tutor/src/main/java/org/tudalgo/algoutils/transform/SubmissionClassInfo.java
new file mode 100644
index 00000000..b5ca8254
--- /dev/null
+++ b/tutor/src/main/java/org/tudalgo/algoutils/transform/SubmissionClassInfo.java
@@ -0,0 +1,298 @@
+package org.tudalgo.algoutils.transform;
+
+import org.tudalgo.algoutils.transform.util.*;
+import kotlin.Pair;
+import kotlin.Triple;
+import org.objectweb.asm.*;
+import org.tudalgo.algoutils.tutor.general.match.MatchingUtils;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.lang.reflect.Modifier;
+import java.util.*;
+
+import static org.objectweb.asm.Opcodes.ACC_SYNTHETIC;
+
+/**
+ * A class that holds information on a submission class.
+ * This class will attempt to find a corresponding solution class and map its members
+ * to the ones defined in the solution class.
+ * If no solution class can be found, for example because the submission class was added
+ * as a utility class, it will map its members to itself to remain usable.
+ *
+ * @author Daniel Mangold
+ */
+public class SubmissionClassInfo extends ClassVisitor {
+
+ private final TransformationContext transformationContext;
+ private final String originalClassName;
+ private final String computedClassName;
+ private final Set, Map>> superClassMembers = new HashSet<>();
+ private final ForceSignatureAnnotationProcessor fsAnnotationProcessor;
+ private final SolutionClassNode solutionClass;
+
+ private String superClass;
+ private String[] interfaces;
+
+ private ClassHeader submissionClassHeader;
+
+ // Mapping of fields in submission => usable fields
+ private final Map fields = new HashMap<>();
+
+ // Mapping of methods in submission => usable methods
+ private final Map methods = new HashMap<>();
+
+ /**
+ * Constructs a new {@link SubmissionClassInfo} instance.
+ *
+ * @param transformationContext a {@link TransformationContext} object
+ * @param className the name of the submission class
+ * @param fsAnnotationProcessor a {@link ForceSignatureAnnotationProcessor} for the submission class
+ */
+ public SubmissionClassInfo(TransformationContext transformationContext,
+ String className,
+ ForceSignatureAnnotationProcessor fsAnnotationProcessor) {
+ super(Opcodes.ASM9);
+ this.transformationContext = transformationContext;
+ this.originalClassName = className;
+ this.fsAnnotationProcessor = fsAnnotationProcessor;
+
+ if (fsAnnotationProcessor.classIdentifierIsForced()) {
+ this.computedClassName = fsAnnotationProcessor.forcedClassIdentifier();
+ } else {
+ // If not forced, get the closest matching solution class (at least 90% similarity)
+ this.computedClassName = transformationContext.solutionClasses()
+ .keySet()
+ .stream()
+ .map(s -> new Pair<>(s, MatchingUtils.similarity(originalClassName, s)))
+ .filter(pair -> pair.getSecond() >= 0.90)
+ .max(Comparator.comparing(Pair::getSecond))
+ .map(Pair::getFirst)
+ .orElse(originalClassName);
+ }
+ this.solutionClass = transformationContext.solutionClasses().get(computedClassName);
+ }
+
+ /**
+ * Returns the original class header.
+ *
+ * @return the original class header
+ */
+ public ClassHeader getOriginalClassHeader() {
+ return submissionClassHeader;
+ }
+
+ /**
+ * Returns the computed class name.
+ * The computed name is the name of the associated solution class, if one is present.
+ * If no solution class is present, the computed names equals the original submission class name.
+ *
+ * @return the computed class name
+ */
+ public String getComputedClassName() {
+ return computedClassName;
+ }
+
+ /**
+ * Returns the solution class associated with this submission class.
+ *
+ * @return an {@link Optional} object wrapping the associated solution class
+ */
+ public Optional getSolutionClass() {
+ return Optional.ofNullable(solutionClass);
+ }
+
+ /**
+ * Returns the original field headers for this class.
+ *
+ * @return the original field headers
+ */
+ public Set getOriginalFieldHeaders() {
+ return fields.keySet();
+ }
+
+ /**
+ * Returns the computed field header for the given field name.
+ * The computed field header is the field header of the corresponding field in the solution class,
+ * if one is present.
+ * If no solution class is present, the computed field header equals the original field header
+ * in the submission class.
+ *
+ * @param name the field name
+ * @return the computed field header
+ */
+ public FieldHeader getComputedFieldHeader(String name) {
+ return fields.entrySet()
+ .stream()
+ .filter(entry -> entry.getKey().name().equals(name))
+ .findAny()
+ .map(Map.Entry::getValue)
+ .orElseThrow();
+ }
+
+ /**
+ * Return the original method headers for this class.
+ *
+ * @return the original method headers
+ */
+ public Set getOriginalMethodHeaders() {
+ return methods.keySet();
+ }
+
+ /**
+ * Returns the computed method header for the given method signature.
+ * The computed method header is the method header of the corresponding method in the solution class,
+ * if one is present.
+ * If no solution class is present, the computed method header equals the original method header
+ * in the submission class.
+ *
+ * @param name the method name
+ * @param descriptor the method descriptor
+ * @return the computed method header
+ */
+ public MethodHeader getComputedMethodHeader(String name, String descriptor) {
+ return methods.entrySet()
+ .stream()
+ .filter(entry -> entry.getKey().name().equals(name) && entry.getKey().descriptor().equals(descriptor))
+ .findAny()
+ .map(Map.Entry::getValue)
+ .orElseThrow();
+ }
+
+ @Override
+ public void visit(int version, int access, String name, String signature, String superName, String[] interfaces) {
+ submissionClassHeader = new ClassHeader(access, name, signature, superName, interfaces);
+ resolveSuperClassMembers(superClassMembers, this.superClass = superName, this.interfaces = interfaces);
+ }
+
+ @Override
+ public FieldVisitor visitField(int access, String name, String descriptor, String signature, Object value) {
+ FieldHeader submissionFieldHeader = new FieldHeader(originalClassName, access, name, descriptor, signature);
+ FieldHeader solutionFieldHeader;
+ if (fsAnnotationProcessor.fieldIdentifierIsForced(name)) {
+ solutionFieldHeader = fsAnnotationProcessor.forcedFieldHeader(name);
+ } else if (solutionClass != null) {
+ solutionFieldHeader = solutionClass.getFields()
+ .keySet()
+ .stream()
+ .map(fieldHeader -> new Pair<>(fieldHeader, MatchingUtils.similarity(name, fieldHeader.name())))
+ .filter(pair -> pair.getSecond() >= 0.90)
+ .max(Comparator.comparing(Pair::getSecond))
+ .map(Pair::getFirst)
+ .orElse(submissionFieldHeader);
+ } else {
+ solutionFieldHeader = submissionFieldHeader;
+ }
+
+ fields.put(submissionFieldHeader, solutionFieldHeader);
+ return null;
+ }
+
+ @Override
+ public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) {
+ MethodHeader submissionMethodHeader = new MethodHeader(originalClassName, access, name, descriptor, signature, exceptions);
+ if ((access & ACC_SYNTHETIC) != 0 && name.startsWith("lambda$")) {
+ methods.put(submissionMethodHeader, submissionMethodHeader);
+ return null;
+ }
+
+ MethodHeader solutionMethodHeader;
+ if (fsAnnotationProcessor.methodSignatureIsForced(name, descriptor)) {
+ solutionMethodHeader = fsAnnotationProcessor.forcedMethodHeader(name, descriptor);
+ } else if (solutionClass != null) {
+ solutionMethodHeader = solutionClass.getMethods()
+ .keySet()
+ .stream()
+ .map(methodHeader -> new Triple<>(methodHeader,
+ MatchingUtils.similarity(name, methodHeader.name()),
+ MatchingUtils.similarity(descriptor, methodHeader.descriptor())))
+ .filter(triple -> triple.getSecond() >= 0.90 && triple.getThird() >= 0.90)
+ .max(Comparator.comparing(Triple::getSecond).thenComparing(Triple::getThird))
+ .map(Triple::getFirst)
+ .orElse(submissionMethodHeader);
+ } else {
+ solutionMethodHeader = submissionMethodHeader;
+ }
+
+ methods.put(submissionMethodHeader, solutionMethodHeader);
+ return null;
+ }
+
+ @Override
+ public void visitEnd() {
+ for (Triple, Map> triple : superClassMembers) {
+ triple.getSecond().forEach(fields::putIfAbsent);
+ triple.getThird().forEach(methods::putIfAbsent);
+ }
+ }
+
+ /**
+ * Recursively resolves the members of superclasses and interfaces.
+ *
+ * @param superClassMembers a set for recording class members
+ * @param superClass the name of the superclass to process
+ * @param interfaces the names of the interfaces to process
+ */
+ private void resolveSuperClassMembers(Set, Map>> superClassMembers,
+ String superClass,
+ String[] interfaces) {
+ resolveSuperClassMembers(superClassMembers, superClass);
+ if (interfaces != null) {
+ for (String interfaceName : interfaces) {
+ resolveSuperClassMembers(superClassMembers, interfaceName);
+ }
+ }
+ }
+
+ /**
+ * Recursively resolves the members of the given class.
+ *
+ * @param superClassMembers a set for recording class members
+ * @param className the name of the class / interface to process
+ */
+ private void resolveSuperClassMembers(Set, Map>> superClassMembers,
+ String className) {
+ if (className.startsWith(transformationContext.projectPrefix())) {
+ SubmissionClassInfo submissionClassInfo = transformationContext.getSubmissionClassInfo(className);
+ superClassMembers.add(new Triple<>(className, submissionClassInfo.fields, submissionClassInfo.methods));
+ resolveSuperClassMembers(superClassMembers, submissionClassInfo.superClass, submissionClassInfo.interfaces);
+ } else {
+ try {
+ Class> clazz = Class.forName(className.replace('/', '.'));
+ Map fieldHeaders = new HashMap<>();
+ for (Field field : clazz.getDeclaredFields()) {
+ if ((field.getModifiers() & Modifier.PRIVATE) != 0) continue;
+ FieldHeader fieldHeader = new FieldHeader(
+ className,
+ field.getModifiers(),
+ field.getName(),
+ Type.getDescriptor(field.getType()),
+ null
+ );
+ fieldHeaders.put(fieldHeader, fieldHeader);
+ }
+ Map methodHeaders = new HashMap<>();
+ for (Method method : clazz.getDeclaredMethods()) {
+ if ((method.getModifiers() & Modifier.PRIVATE) != 0) continue;
+ MethodHeader methodHeader = new MethodHeader(
+ className,
+ method.getModifiers(),
+ method.getName(),
+ Type.getMethodDescriptor(method),
+ null,
+ Arrays.stream(method.getExceptionTypes()).map(Type::getInternalName).toArray(String[]::new)
+ );
+ methodHeaders.put(methodHeader, methodHeader);
+ }
+ superClassMembers.add(new Triple<>(className, fieldHeaders, methodHeaders));
+ if (clazz.getSuperclass() != null) {
+ resolveSuperClassMembers(superClassMembers,
+ Type.getInternalName(clazz.getSuperclass()),
+ Arrays.stream(clazz.getInterfaces()).map(Type::getInternalName).toArray(String[]::new));
+ }
+ } catch (ClassNotFoundException e) {
+ throw new RuntimeException(e);
+ }
+ }
+ }
+}
diff --git a/tutor/src/main/java/org/tudalgo/algoutils/transform/SubmissionClassVisitor.java b/tutor/src/main/java/org/tudalgo/algoutils/transform/SubmissionClassVisitor.java
new file mode 100644
index 00000000..742a4e2b
--- /dev/null
+++ b/tutor/src/main/java/org/tudalgo/algoutils/transform/SubmissionClassVisitor.java
@@ -0,0 +1,503 @@
+package org.tudalgo.algoutils.transform;
+
+import org.tudalgo.algoutils.transform.util.*;
+import org.objectweb.asm.*;
+import org.objectweb.asm.tree.FieldNode;
+import org.objectweb.asm.tree.MethodNode;
+
+import java.util.*;
+import java.util.stream.Collectors;
+
+import static org.tudalgo.algoutils.transform.util.TransformationUtils.*;
+import static org.objectweb.asm.Opcodes.*;
+
+/**
+ * A class visitor merging a submission class with its corresponding solution class, should one exist.
+ * The heart piece of the {@link SolutionMergingClassTransformer} processing chain.
+ *
+ * Main features:
+ *
+ *
+ * Method invocation logging
+ * Logs the parameter values the method was called with.
+ * This allows the user to verify that a method was called and also that it was called with
+ * the right parameters.
+ * If the target method is not static or a constructor, the object the method was invoked on
+ * is logged as well.
+ *
+ *
+ * Method substitution
+ * Allows for "replacement" of a method at runtime.
+ * While the method itself must still be invoked, it will hand over execution to the provided
+ * substitution.
+ * This can be useful when a method should always return a certain value, regardless of object state
+ * or for making a non-deterministic method (e.g., RNG) return deterministic values.
+ * Replacing constructors is currently not supported.
+ * Can be combined with invocation logging.
+ *
+ *
+ * Method delegation
+ * Will effectively "replace" the code of the original submission with the one from the solution.
+ * While the instructions from both submission and solution are present in the merged method, only
+ * one can be active at a time.
+ * This allows for improved unit testing by not relying on submission code transitively.
+ * If this mechanism is used and no solution class is associated with this submission class or
+ * the solution class does not contain a matching method, the submission code will be used
+ * as a fallback.
+ * Can be combined with invocation logging.
+ *
+ *
+ * All of these options can be enabled / disabled via {@link SubmissionExecutionHandler}.
+ *
+ * @see SubmissionExecutionHandler
+ * @author Daniel Mangold
+ */
+class SubmissionClassVisitor extends ClassVisitor {
+
+ private final boolean defaultTransformationsOnly;
+ private final TransformationContext transformationContext;
+ private final String className;
+ private final SubmissionClassInfo submissionClassInfo;
+
+ private final Set visitedFields = new HashSet<>();
+ private final Map solutionFieldNodes;
+
+ private final Set visitedMethods = new HashSet<>();
+ private final Map solutionMethodNodes;
+
+ SubmissionClassVisitor(ClassVisitor classVisitor,
+ TransformationContext transformationContext,
+ String submissionClassName) {
+ super(ASM9, classVisitor);
+ this.transformationContext = transformationContext;
+ this.className = transformationContext.getSubmissionClassInfo(submissionClassName).getComputedClassName();
+ this.submissionClassInfo = transformationContext.getSubmissionClassInfo(submissionClassName);
+
+ Optional solutionClass = submissionClassInfo.getSolutionClass();
+ if (solutionClass.isPresent()) {
+ this.defaultTransformationsOnly = false;
+ this.solutionFieldNodes = solutionClass.get().getFields();
+ this.solutionMethodNodes = solutionClass.get().getMethods();
+ } else {
+ System.err.printf("No corresponding solution class found for %s. Only applying default transformations.%n", submissionClassName);
+ this.defaultTransformationsOnly = true;
+ this.solutionFieldNodes = Collections.emptyMap();
+ this.solutionMethodNodes = Collections.emptyMap();
+ }
+ }
+
+ /**
+ * Visits the header of the class, replacing it with the solution class' header, if one is present.
+ */
+ @Override
+ public void visit(int version, int access, String name, String signature, String superName, String[] interfaces) {
+ submissionClassInfo.getSolutionClass()
+ .map(SolutionClassNode::getClassHeader)
+ .orElse(submissionClassInfo.getOriginalClassHeader())
+ .visitClass(getDelegate(), version);
+// .visitClass(getDelegate(), version, Type.getInternalName(SubmissionClassMetadata.class));
+ }
+
+ /**
+ * Visits a field of the submission class and transforms it if a solution class is present.
+ */
+ @Override
+ public FieldVisitor visitField(int access, String name, String descriptor, String signature, Object value) {
+ if (defaultTransformationsOnly) {
+ return super.visitField(access, name, descriptor, signature, value);
+ }
+
+ FieldHeader fieldHeader = submissionClassInfo.getComputedFieldHeader(name);
+ visitedFields.add(fieldHeader);
+ return fieldHeader.toFieldVisitor(getDelegate(), value);
+ }
+
+ /**
+ * Visits a method of a submission class and transforms it.
+ * Enables invocation logging, substitution and, if a solution class is present, delegation.
+ */
+ @Override
+ public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) {
+ MethodHeader methodHeader = submissionClassInfo.getComputedMethodHeader(name, descriptor);
+ visitedMethods.add(methodHeader);
+ boolean isStatic = (methodHeader.access() & ACC_STATIC) != 0;
+ boolean isConstructor = methodHeader.name().equals("");
+
+ // calculate length of locals array, including "this" if applicable
+ int submissionExecutionHandlerIndex = (Type.getArgumentsAndReturnSizes(methodHeader.descriptor()) >> 2) - (isStatic ? 1 : 0);
+ int methodHeaderIndex = submissionExecutionHandlerIndex + 1;
+
+ // calculate default locals for frames
+ List