GeneratedNameAllocator.java

/*******************************************************************************
 * Copyright (c) 2026 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
 *******************************************************************************/
package org.sandbox.jdt.cleanup.multifile;

import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;

import org.eclipse.jdt.core.ICompilationUnit;
import org.eclipse.jdt.core.dom.ASTNode;
import org.eclipse.jdt.core.dom.ASTVisitor;
import org.eclipse.jdt.core.dom.AbstractTypeDeclaration;
import org.eclipse.jdt.core.dom.AnnotationTypeMemberDeclaration;
import org.eclipse.jdt.core.dom.CompilationUnit;
import org.eclipse.jdt.core.dom.EnumConstantDeclaration;
import org.eclipse.jdt.core.dom.FieldDeclaration;
import org.eclipse.jdt.core.dom.ITypeBinding;
import org.eclipse.jdt.core.dom.ImportDeclaration;
import org.eclipse.jdt.core.dom.MethodDeclaration;
import org.eclipse.jdt.core.dom.SingleVariableDeclaration;
import org.eclipse.jdt.core.dom.TypeParameter;
import org.eclipse.jdt.core.dom.VariableDeclarationFragment;

/**
 * Deterministically validates names proposed by multi-file cleanups.
 *
 * <p>The allocator deliberately does not invent numeric suffixes for generated
 * domain types. A requested name is either available or rejected with stable,
 * structured collision information that can be shown in previews and reused
 * for stale-plan validation.</p>
 */
public final class GeneratedNameAllocator {

	/** Java namespaces relevant to generated nested type references. */
	public enum Namespace {
		/** Existing or prospective type declaration. */
		TYPE,
		/** Field, enum constant, method or annotation member. */
		MEMBER,
		/** Parameter, local variable or other local declaration. */
		LOCAL,
		/** Explicit import using the requested simple name. */
		IMPORT,
		/** Another cleanup candidate reserved the same generated name. */
		PLANNED
	}

	/** Identity and target of one requested nested type. */
	public record NestedTypeRequest(String requestId, String ownerCompilationUnitHandle,
			String ownerTypeBindingKey, String ownerTypeQualifiedName, String requestedName) {

		/** Validates and normalizes request identity. */
		public NestedTypeRequest {
			requestId= requireText(requestId, "requestId"); //$NON-NLS-1$
			ownerCompilationUnitHandle= normalize(ownerCompilationUnitHandle);
			ownerTypeBindingKey= normalize(ownerTypeBindingKey);
			ownerTypeQualifiedName= normalize(ownerTypeQualifiedName);
			requestedName= requireText(requestedName, "requestedName"); //$NON-NLS-1$
			if (ownerTypeBindingKey.isEmpty() && ownerTypeQualifiedName.isEmpty()) {
				throw new IllegalArgumentException(
						"Either ownerTypeBindingKey or ownerTypeQualifiedName must be provided."); //$NON-NLS-1$
			}
			if (!isJavaIdentifier(requestedName)) {
				throw new IllegalArgumentException("Not a Java identifier: " + requestedName); //$NON-NLS-1$
			}
		}
	}

	/** One declaration or reservation conflicting with a requested name. */
	public record Collision(Namespace namespace, String compilationUnitHandle,
			String declarationKind, String context) {

		/** Normalizes diagnostic fields. */
		public Collision {
			namespace= Objects.requireNonNull(namespace, "namespace"); //$NON-NLS-1$
			compilationUnitHandle= normalize(compilationUnitHandle);
			declarationKind= requireText(declarationKind, "declarationKind"); //$NON-NLS-1$
			context= requireText(context, "context"); //$NON-NLS-1$
		}
	}

	/** Result for one requested generated name. */
	public record Allocation(NestedTypeRequest request, List<Collision> collisions) {

		/** Defensively copies and sorts collision diagnostics. */
		public Allocation {
			request= Objects.requireNonNull(request, "request"); //$NON-NLS-1$
			collisions= collisions.stream()
					.map(collision -> Objects.requireNonNull(collision, "collision")) //$NON-NLS-1$
					.sorted(COLLISION_ORDER).toList();
		}

		/** Returns whether the requested name can be generated unchanged. */
		public boolean available() {
			return collisions.isEmpty();
		}

		/** Returns a stable human-readable explanation of all collisions. */
		public String diagnosticMessage() {
			return collisions.stream()
					.map(collision -> collision.namespace().name().toLowerCase(Locale.ROOT) + " " //$NON-NLS-1$
							+ collision.declarationKind() + " (" + collision.context() + ")") //$NON-NLS-1$ //$NON-NLS-2$
					.collect(Collectors.joining("; ")); //$NON-NLS-1$
		}
	}

	private record Owner(CompilationUnit root, AbstractTypeDeclaration type) {
	}

	private static final Comparator<Collision> COLLISION_ORDER= Comparator
			.comparing((Collision collision) -> collision.namespace().ordinal())
			.thenComparing(Collision::compilationUnitHandle)
			.thenComparing(Collision::declarationKind)
			.thenComparing(Collision::context);

	private static final Set<String> RESERVED_IDENTIFIERS= Set.of(
			"abstract", "assert", "boolean", "break", "byte", "case", "catch", "char", "class", //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ //$NON-NLS-4$ //$NON-NLS-5$ //$NON-NLS-6$ //$NON-NLS-7$ //$NON-NLS-8$ //$NON-NLS-9$
			"const", "continue", "default", "do", "double", "else", "enum", "extends", "final", //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ //$NON-NLS-4$ //$NON-NLS-5$ //$NON-NLS-6$ //$NON-NLS-7$ //$NON-NLS-8$ //$NON-NLS-9$
			"finally", "float", "for", "goto", "if", "implements", "import", "instanceof", "int", //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ //$NON-NLS-4$ //$NON-NLS-5$ //$NON-NLS-6$ //$NON-NLS-7$ //$NON-NLS-8$ //$NON-NLS-9$
			"interface", "long", "native", "new", "package", "private", "protected", "public", //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ //$NON-NLS-4$ //$NON-NLS-5$ //$NON-NLS-6$ //$NON-NLS-7$ //$NON-NLS-8$
			"return", "short", "static", "strictfp", "super", "switch", "synchronized", "this", //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ //$NON-NLS-4$ //$NON-NLS-5$ //$NON-NLS-6$ //$NON-NLS-7$ //$NON-NLS-8$
			"throw", "throws", "transient", "try", "void", "volatile", "while", "true", "false", //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ //$NON-NLS-4$ //$NON-NLS-5$ //$NON-NLS-6$ //$NON-NLS-7$ //$NON-NLS-8$ //$NON-NLS-9$
			"null", "_", "record", "sealed", "permits", "yield", "var"); //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ //$NON-NLS-4$ //$NON-NLS-5$ //$NON-NLS-6$ //$NON-NLS-7$

	private GeneratedNameAllocator() {
	}

	/**
	 * Validates nested type requests against the supplied compilation units and
	 * against all other prospective requests.
	 *
	 * @return allocations keyed by request ID in deterministic request-ID order
	 */
	public static Map<String, Allocation> allocateNestedTypes(Collection<CompilationUnit> roots,
			Collection<NestedTypeRequest> requests) {
		Objects.requireNonNull(roots, "roots"); //$NON-NLS-1$
		Objects.requireNonNull(requests, "requests"); //$NON-NLS-1$

		List<CompilationUnit> orderedRoots= new ArrayList<>();
		for (CompilationUnit root : roots) {
			orderedRoots.add(Objects.requireNonNull(root, "root")); //$NON-NLS-1$
		}
		orderedRoots.sort(Comparator.comparing(GeneratedNameAllocator::compilationUnitKey));

		List<NestedTypeRequest> orderedRequests= new ArrayList<>();
		for (NestedTypeRequest request : requests) {
			orderedRequests.add(Objects.requireNonNull(request, "request")); //$NON-NLS-1$
		}
		orderedRequests.sort(Comparator.comparing(NestedTypeRequest::requestId));
		ensureUniqueRequestIds(orderedRequests);

		Map<String, List<NestedTypeRequest>> reservationGroups= new LinkedHashMap<>();
		for (NestedTypeRequest request : orderedRequests) {
			reservationGroups.computeIfAbsent(reservationKey(request), ignored -> new ArrayList<>()).add(request);
		}

		Map<String, Allocation> allocations= new LinkedHashMap<>();
		for (NestedTypeRequest request : orderedRequests) {
			List<Collision> collisions= new ArrayList<>();
			addProspectiveCollisions(request, reservationGroups.get(reservationKey(request)), collisions);
			Owner owner= findOwner(orderedRoots, request);
			if (owner == null) {
				collisions.add(new Collision(Namespace.TYPE, request.ownerCompilationUnitHandle(),
						"owner type", "owner declaration could not be resolved")); //$NON-NLS-1$ //$NON-NLS-2$
			} else {
				collectImportCollisions(owner, request, collisions);
				collectDeclarationCollisions(owner, request, collisions);
			}
			allocations.put(request.requestId(), new Allocation(request, collisions));
		}
		return Collections.unmodifiableMap(allocations);
	}

	private static void ensureUniqueRequestIds(List<NestedTypeRequest> requests) {
		Set<String> ids= new HashSet<>();
		for (NestedTypeRequest request : requests) {
			if (!ids.add(request.requestId())) {
				throw new IllegalArgumentException("Duplicate generated-name request ID: " + request.requestId()); //$NON-NLS-1$
			}
		}
	}

	private static void addProspectiveCollisions(NestedTypeRequest request, List<NestedTypeRequest> group,
			List<Collision> collisions) {
		if (group == null || group.size() < 2) {
			return;
		}
		String competingIds= group.stream().map(NestedTypeRequest::requestId)
				.filter(candidateId -> !candidateId.equals(request.requestId())).sorted()
				.collect(Collectors.joining(", ")); //$NON-NLS-1$
		collisions.add(new Collision(Namespace.PLANNED, request.ownerCompilationUnitHandle(),
				"nested type reservation", "also requested by " + competingIds)); //$NON-NLS-1$ //$NON-NLS-2$
	}

	private static Owner findOwner(List<CompilationUnit> roots, NestedTypeRequest request) {
		if (!request.ownerCompilationUnitHandle().isEmpty()) {
			for (CompilationUnit root : roots) {
				if (request.ownerCompilationUnitHandle().equals(compilationUnitHandle(root))) {
					AbstractTypeDeclaration type= findOwnerType(root, request);
					if (type != null) {
						return new Owner(root, type);
					}
				}
			}
		}
		for (CompilationUnit root : roots) {
			AbstractTypeDeclaration type= findOwnerType(root, request);
			if (type != null) {
				return new Owner(root, type);
			}
		}
		return null;
	}

	private static AbstractTypeDeclaration findOwnerType(CompilationUnit root, NestedTypeRequest request) {
		AbstractTypeDeclaration[] result= new AbstractTypeDeclaration[1];
		root.accept(new ASTVisitor() {
			@Override
			public void preVisit(ASTNode node) {
				if (result[0] == null && node instanceof AbstractTypeDeclaration type
						&& matchesOwner(root, type, request)) {
					result[0]= type;
				}
			}
		});
		return result[0];
	}

	private static boolean matchesOwner(CompilationUnit root, AbstractTypeDeclaration type,
			NestedTypeRequest request) {
		ITypeBinding binding= type.resolveBinding();
		ITypeBinding declaration= binding == null ? null : binding.getTypeDeclaration();
		if (declaration != null && !request.ownerTypeBindingKey().isEmpty()
				&& request.ownerTypeBindingKey().equals(declaration.getKey())) {
			return true;
		}
		String qualifiedName= declaration == null ? qualifiedName(root, type) : declaration.getQualifiedName();
		return !request.ownerTypeQualifiedName().isEmpty()
				&& request.ownerTypeQualifiedName().equals(qualifiedName);
	}

	private static void collectImportCollisions(Owner owner, NestedTypeRequest request,
			List<Collision> collisions) {
		for (Object importObject : owner.root().imports()) {
			ImportDeclaration importDeclaration= (ImportDeclaration) importObject;
			if (importDeclaration.isOnDemand()) {
				continue;
			}
			String importedName= importDeclaration.getName().getFullyQualifiedName();
			int separator= importedName.lastIndexOf('.');
			String simpleName= separator < 0 ? importedName : importedName.substring(separator + 1);
			if (request.requestedName().equals(simpleName)) {
				collisions.add(collision(Namespace.IMPORT, owner.root(), request,
						importDeclaration.isStatic() ? "static import" : "type import", //$NON-NLS-1$ //$NON-NLS-2$
						importedName, importDeclaration));
			}
		}
	}

	private static void collectDeclarationCollisions(Owner owner, NestedTypeRequest request,
			List<Collision> collisions) {
		owner.type().accept(new ASTVisitor() {
			@Override
			public void preVisit(ASTNode node) {
				String requestedName= request.requestedName();
				if (node instanceof AbstractTypeDeclaration type
						&& requestedName.equals(type.getName().getIdentifier())) {
					String kind= type == owner.type() ? "owner type" : "type declaration"; //$NON-NLS-1$ //$NON-NLS-2$
					collisions.add(collision(Namespace.TYPE, owner.root(), request, kind,
							qualifiedName(owner.root(), type), type));
				} else if (node instanceof TypeParameter parameter
						&& requestedName.equals(parameter.getName().getIdentifier())) {
					collisions.add(collision(Namespace.TYPE, owner.root(), request,
							"type parameter", parameter.getName().getIdentifier(), parameter)); //$NON-NLS-1$
				} else if (node instanceof FieldDeclaration field) {
					for (Object fragmentObject : field.fragments()) {
						VariableDeclarationFragment fragment= (VariableDeclarationFragment) fragmentObject;
						if (requestedName.equals(fragment.getName().getIdentifier())) {
							collisions.add(collision(Namespace.MEMBER, owner.root(), request,
									"field", fragment.getName().getIdentifier(), fragment)); //$NON-NLS-1$
						}
					}
				} else if (node instanceof EnumConstantDeclaration enumConstant
						&& requestedName.equals(enumConstant.getName().getIdentifier())) {
					collisions.add(collision(Namespace.MEMBER, owner.root(), request,
							"enum constant", enumConstant.getName().getIdentifier(), enumConstant)); //$NON-NLS-1$
				} else if (node instanceof MethodDeclaration method && !method.isConstructor()
						&& requestedName.equals(method.getName().getIdentifier())) {
					collisions.add(collision(Namespace.MEMBER, owner.root(), request,
							"method", method.getName().getIdentifier(), method)); //$NON-NLS-1$
				} else if (node instanceof AnnotationTypeMemberDeclaration member
						&& requestedName.equals(member.getName().getIdentifier())) {
					collisions.add(collision(Namespace.MEMBER, owner.root(), request,
							"annotation member", member.getName().getIdentifier(), member)); //$NON-NLS-1$
				} else if (node instanceof SingleVariableDeclaration variable
						&& requestedName.equals(variable.getName().getIdentifier())) {
					collisions.add(collision(Namespace.LOCAL, owner.root(), request,
							"parameter or local variable", variable.getName().getIdentifier(), variable)); //$NON-NLS-1$
				} else if (node instanceof VariableDeclarationFragment fragment
						&& !(fragment.getParent() instanceof FieldDeclaration)
						&& requestedName.equals(fragment.getName().getIdentifier())) {
					collisions.add(collision(Namespace.LOCAL, owner.root(), request,
							"local variable", fragment.getName().getIdentifier(), fragment)); //$NON-NLS-1$
				}
			}
		});
	}

	private static Collision collision(Namespace namespace, CompilationUnit root, NestedTypeRequest request,
			String declarationKind, String detail, ASTNode node) {
		String handle= compilationUnitHandle(root);
		if (handle.isEmpty()) {
			handle= request.ownerCompilationUnitHandle();
		}
		int line= root.getLineNumber(node.getStartPosition());
		String position= line > 0 ? "line " + line : "offset " + node.getStartPosition(); //$NON-NLS-1$ //$NON-NLS-2$
		return new Collision(namespace, handle, declarationKind, detail + " at " + position); //$NON-NLS-1$
	}

	private static String reservationKey(NestedTypeRequest request) {
		String ownerIdentity= !request.ownerTypeBindingKey().isEmpty()
				? request.ownerTypeBindingKey()
				: request.ownerCompilationUnitHandle() + ':' + request.ownerTypeQualifiedName();
		return ownerIdentity + '\u0000' + request.requestedName();
	}

	private static String compilationUnitKey(CompilationUnit root) {
		String handle= compilationUnitHandle(root);
		if (!handle.isEmpty()) {
			return handle;
		}
		String packageName= root.getPackage() == null
				? "" //$NON-NLS-1$
				: root.getPackage().getName().getFullyQualifiedName();
		List<String> typeNames= new ArrayList<>();
		for (Object typeObject : root.types()) {
			if (typeObject instanceof AbstractTypeDeclaration type) {
				typeNames.add(type.getName().getIdentifier());
			}
		}
		Collections.sort(typeNames);
		return packageName + ':' + String.join(",", typeNames); //$NON-NLS-1$
	}

	private static String compilationUnitHandle(CompilationUnit root) {
		return root.getJavaElement() instanceof ICompilationUnit unit
				? unit.getPrimary().getHandleIdentifier()
				: ""; //$NON-NLS-1$
	}

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

	private static boolean isJavaIdentifier(String value) {
		if (value.isEmpty() || RESERVED_IDENTIFIERS.contains(value)
				|| !Character.isJavaIdentifierStart(value.codePointAt(0))) {
			return false;
		}
		int index= Character.charCount(value.codePointAt(0));
		while (index < value.length()) {
			int codePoint= value.codePointAt(index);
			if (!Character.isJavaIdentifierPart(codePoint)) {
				return false;
			}
			index+= Character.charCount(codePoint);
		}
		return true;
	}

	private static String normalize(String value) {
		return value == null ? "" : value.strip(); //$NON-NLS-1$
	}

	private static String requireText(String value, String fieldName) {
		String normalized= normalize(value);
		if (normalized.isEmpty()) {
			throw new IllegalArgumentException(fieldName + " must not be blank."); //$NON-NLS-1$
		}
		return normalized;
	}
}