NamingUtils.java

/*******************************************************************************
 * Copyright (c) 2025 Carsten Hammer.
 *
 * This program and the accompanying materials
 * are made available under the terms of the Eclipse Public License 2.0
 * which accompanies this distribution, and is available at
 * https://www.eclipse.org/legal/epl-2.0/
 *
 * SPDX-License-Identifier: EPL-2.0
 *
 * Contributors:
 *     Carsten Hammer
 *******************************************************************************/
package org.sandbox.jdt.internal.corext.util;

import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.List;

import org.eclipse.jdt.core.ICompilationUnit;
import org.eclipse.jdt.core.dom.ASTNode;
import org.eclipse.jdt.core.dom.AnonymousClassDeclaration;
import org.eclipse.jdt.core.dom.CompilationUnit;
import org.eclipse.jdt.core.dom.FieldDeclaration;
import org.eclipse.jdt.core.dom.ITypeBinding;
import org.eclipse.jdt.core.dom.QualifiedType;
import org.eclipse.jdt.core.dom.SimpleType;
import org.eclipse.jdt.core.dom.Type;
import org.eclipse.jdt.core.dom.TypeDeclaration;
import org.eclipse.jdt.core.dom.VariableDeclarationFragment;

import org.sandbox.jdt.cleanup.multifile.GeneratedNameAllocator;
import org.sandbox.jdt.cleanup.multifile.GeneratedNameAllocator.Allocation;
import org.sandbox.jdt.cleanup.multifile.GeneratedNameAllocator.NestedTypeRequest;
import org.sandbox.jdt.cleanup.multifile.GeneratedNameHierarchyPolicy;

/**
 * Utility class for naming and string operations.
 * Provides methods for class name generation, type name extraction,
 * and string transformations needed during JUnit migration.
 */
public final class NamingUtils {

	/** Length in hexadecimal characters of the checksum used for generated nested class names to ensure uniqueness */
	private static final int GENERATED_CLASS_NAME_CHECKSUM_LENGTH = 5;

	private NamingUtils() {
		// Utility class - prevent instantiation
	}

	/**
	 * Checks if a string is a valid Java identifier.
	 * A valid Java identifier starts with a character for which
	 * {@link Character#isJavaIdentifierStart(char)} returns true,
	 * followed by characters for which {@link Character#isJavaIdentifierPart(char)} returns true.
	 *
	 * @param name the string to check, may be null
	 * @return true if the string is a valid Java identifier, false otherwise
	 */
	public static boolean isValidJavaIdentifier(String name) {
		if (name == null || name.isEmpty()) {
			return false;
		}
		if (!Character.isJavaIdentifierStart(name.charAt(0))) {
			return false;
		}
		for (int i = 1; i < name.length(); i++) {
			if (!Character.isJavaIdentifierPart(name.charAt(i))) {
				return false;
			}
		}
		return true;
	}

	/**
	 * Capitalizes the first letter of a string.
	 * 
	 * @param input the string to capitalize
	 * @return the string with first letter capitalized, or the original string if null or empty
	 */
	public static String capitalizeFirstLetter(String input) {
		if (input == null || input.isEmpty()) {
			return input;
		}
		return Character.toUpperCase(input.charAt(0)) + input.substring(1);
	}

	/**
	 * Generates and validates a deterministic nested helper class name.
	 *
	 * <p>The historical field-name/checksum convention is retained for stable
	 * output, but the proposed name is now checked by the shared generated-name
	 * services against type, member, local, import and inherited member-type
	 * namespaces before any rewrite is committed.</p>
	 *
	 * @param anonymousClass the anonymous class declaration
	 * @param baseName the base name from the field
	 * @return the available class name
	 * @throws IllegalStateException if owner context is unavailable or the name collides
	 */
	public static String generateUniqueNestedClassName(AnonymousClassDeclaration anonymousClass, String baseName) {
		String anonymousCode = anonymousClass.toString();
		String checksum = generateChecksum(anonymousCode);
		String requestedName = capitalizeFirstLetter(baseName) + "_" + checksum; //$NON-NLS-1$
		if (!(anonymousClass.getRoot() instanceof CompilationUnit root)) {
			throw new IllegalStateException("Cannot validate generated helper name without a compilation unit."); //$NON-NLS-1$
		}
		TypeDeclaration owner= ASTNavigationUtils.findEnclosingTypeDeclaration(anonymousClass);
		if (owner == null) {
			throw new IllegalStateException("Cannot validate generated helper name without an enclosing type."); //$NON-NLS-1$
		}
		ITypeBinding ownerBinding= owner.resolveBinding();
		ITypeBinding ownerDeclaration= ownerBinding == null ? null : ownerBinding.getTypeDeclaration();
		String ownerKey= ownerDeclaration == null || ownerDeclaration.getKey() == null
				? "" : ownerDeclaration.getKey(); //$NON-NLS-1$
		String ownerQualifiedName= ownerDeclaration == null || ownerDeclaration.getQualifiedName().isEmpty()
				? qualifiedName(root, owner) : ownerDeclaration.getQualifiedName();
		String unitHandle= root.getJavaElement() instanceof ICompilationUnit unit
				? unit.getPrimary().getHandleIdentifier() : ""; //$NON-NLS-1$
		String requestId= (ownerKey.isEmpty() ? ownerQualifiedName : ownerKey) + "#junit-helper:" + requestedName; //$NON-NLS-1$
		NestedTypeRequest request= new NestedTypeRequest(requestId, unitHandle, ownerKey, ownerQualifiedName,
				requestedName);
		Allocation allocation= GeneratedNameAllocator.allocateNestedTypes(List.of(root), List.of(request))
				.get(requestId);
		if (allocation == null || !allocation.available()) {
			String detail= allocation == null ? "allocation result is missing" : allocation.diagnosticMessage(); //$NON-NLS-1$
			throw new IllegalStateException("Generated nested helper name " + requestedName //$NON-NLS-1$
					+ " is unavailable: " + detail); //$NON-NLS-1$
		}
		List<String> inheritedCollisions= GeneratedNameHierarchyPolicy
				.inheritedMemberTypeCollisions(ownerDeclaration, requestedName);
		if (!inheritedCollisions.isEmpty()) {
			throw new IllegalStateException("Generated nested helper name " + requestedName //$NON-NLS-1$
					+ " is unavailable: inherited member type (" //$NON-NLS-1$
					+ String.join(", ", inheritedCollisions) + ")"); //$NON-NLS-1$ //$NON-NLS-2$
		}
		return requestedName;
	}

	/**
	 * Generates a short SHA-256 checksum for the given input.
	 * 
	 * @param input the string to hash
	 * @return a 5-character hexadecimal checksum
	 * @throws RuntimeException if SHA-256 algorithm is not available (should never happen in standard JVM environments)
	 */
	public static String generateChecksum(String input) {
		try {
			MessageDigest md = MessageDigest.getInstance("SHA-256"); //$NON-NLS-1$
			byte[] hashBytes = md.digest(input.getBytes(StandardCharsets.UTF_8));
			StringBuilder hexString = new StringBuilder();
			for (byte b : hashBytes) {
				String hex = Integer.toHexString(0xff & b);
				if (hex.length() == 1) {
					hexString.append('0');
				}
				hexString.append(hex);
			}
			return hexString.toString().substring(0, GENERATED_CLASS_NAME_CHECKSUM_LENGTH);
		} catch (NoSuchAlgorithmException e) {
			throw new RuntimeException("SHA-256 algorithm not found", e); //$NON-NLS-1$
		}
	}

	/**
	 * Extracts the class name from a field declaration's initializer.
	 * 
	 * @param field the field declaration to extract from
	 * @return the class name, or null if not found
	 */
	public static String extractClassNameFromField(FieldDeclaration field) {
		for (Object fragmentObj : field.fragments()) {
			if (fragmentObj instanceof VariableDeclarationFragment fragment
					&& fragment.getInitializer() instanceof org.eclipse.jdt.core.dom.ClassInstanceCreation creation) {
				return extractTypeName(creation.getType());
			}
		}
		return null;
	}

	/**
	 * Extracts the field name from a field declaration.
	 * 
	 * @param fieldDeclaration the field declaration
	 * @return the field name, or "UnnamedField" if not found
	 */
	public static String extractFieldName(FieldDeclaration fieldDeclaration) {
		return (String) fieldDeclaration.fragments().stream()
				.filter(VariableDeclarationFragment.class::isInstance)
				.map(fragment -> ((VariableDeclarationFragment) fragment).getName().getIdentifier())
				.findFirst()
				.orElse("UnnamedField"); //$NON-NLS-1$
	}

	/**
	 * Extracts the fully qualified type name from a QualifiedType AST node.
	 * 
	 * @param qualifiedType the qualified type to extract from
	 * @return the fully qualified class name
	 */
	public static String extractQualifiedTypeName(QualifiedType qualifiedType) {
		StringBuilder fullClassName = new StringBuilder();
		Type currentType = qualifiedType;

		while (currentType instanceof QualifiedType currentQualified) {
			if (fullClassName.length() > 0) {
				fullClassName.insert(0, "."); //$NON-NLS-1$
			}
			fullClassName.insert(0, currentQualified.getName().getFullyQualifiedName());
			currentType = currentQualified.getQualifier();
		}
		return fullClassName.toString();
	}

	/**
	 * General method to extract a type's fully qualified name.
	 * 
	 * @param type the type to extract from
	 * @return the type name, or null if not a recognized type
	 */
	public static String extractTypeName(Type type) {
		if (type instanceof QualifiedType qualifiedType) {
			return extractQualifiedTypeName(qualifiedType);
		} else if (type instanceof SimpleType simpleType) {
			return simpleType.getName().getFullyQualifiedName();
		}
		return null;
	}

	private static String qualifiedName(CompilationUnit root, TypeDeclaration owner) {
		List<String> typeNames= new ArrayList<>();
		ASTNode current= owner;
		while (current != null) {
			if (current instanceof TypeDeclaration type) {
				typeNames.add(0, type.getName().getIdentifier());
			}
			current= current.getParent();
		}
		String packageName= root.getPackage() == null
				? "" : root.getPackage().getName().getFullyQualifiedName(); //$NON-NLS-1$
		String localName= String.join(".", typeNames); //$NON-NLS-1$
		return packageName.isEmpty() ? localName : packageName + '.' + localName;
	}
}