diff --git a/src/main/java/noppes/npcs/client/gui/util/script/ClassPathFinder.java b/src/main/java/noppes/npcs/client/gui/util/script/ClassPathFinder.java new file mode 100644 index 000000000..18f4252a5 --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/util/script/ClassPathFinder.java @@ -0,0 +1,561 @@ +package noppes.npcs.client.gui.util.script; + +import java.util.*; + +/** + * Helper class for resolving Java classpath imports and determining class types. + * Handles: + * - Class resolution via Class.forName + * - Inner class notation (Outer.Inner -> Outer$Inner) + * - Package validation + * - Type detection (interface/enum/class) + * - Result caching for performance + */ +public class ClassPathFinder { + + // Cache: fully-qualified class name -> ClassInfo (null if not found) + private final Map classCache = new HashMap<>(); + + // Cache: package prefix -> whether it's a valid package (has classes under it) + private final Set validPackages = new HashSet<>(); + + // Common java.lang classes (auto-imported in Java) + public static final Set JAVA_LANG_CLASSES = new HashSet<>(Arrays.asList( + "Object", "String", "Class", "System", "Math", "Integer", "Double", "Float", "Long", "Short", "Byte", + "Character", "Boolean", "Number", "Void", "Thread", "Runnable", "Exception", "RuntimeException", + "Error", "Throwable", "StringBuilder", "StringBuffer", "Enum", "Comparable", "Iterable", + "CharSequence", "Cloneable", "Process", "ProcessBuilder", "Runtime", "SecurityManager", + "ClassLoader", "Package", "ArithmeticException", "ArrayIndexOutOfBoundsException", + "ClassCastException", "IllegalArgumentException", "IllegalStateException", + "IndexOutOfBoundsException", "NullPointerException", "NumberFormatException", + "UnsupportedOperationException", "AssertionError", "OutOfMemoryError", "StackOverflowError" + )); + + /** + * Information about a resolved class + */ + public static class ClassInfo { + public final String resolvedName; // Full class name with $ for inner classes + public final ClassType type; // INTERFACE, ENUM, or CLASS + public final int packageEndIndex; // Index in original path where package ends (for "java.util.List" -> 9) + + public ClassInfo(String resolvedName, ClassType type, int packageEndIndex) { + this.resolvedName = resolvedName; + this.type = type; + this.packageEndIndex = packageEndIndex; + } + } + + public enum ClassType { + INTERFACE, + ENUM, + CLASS + } + + /** + * Result of resolving an import path + */ + public static class ResolveResult { + public final boolean found; + public final ClassInfo classInfo; + public final String packagePortion; // The package part (e.g., "java.util") + public final String classPortion; // The class part (e.g., "List" or "IOverlay.ColorType") + public final List classSegments; // Each class/inner-class segment with its type + public final int invalidStartOffset; // Character offset where invalid portion starts (-1 if all valid or all invalid) + + private ResolveResult(boolean found, ClassInfo classInfo, String packagePortion, + String classPortion, List classSegments, int invalidStartOffset) { + this.found = found; + this.classInfo = classInfo; + this.packagePortion = packagePortion; + this.classPortion = classPortion; + this.classSegments = classSegments != null ? classSegments : Collections.emptyList(); + this.invalidStartOffset = invalidStartOffset; + } + + public static ResolveResult notFound(String packagePortion, String classPortion, int invalidStartOffset) { + return new ResolveResult(false, null, packagePortion, classPortion, null, invalidStartOffset); + } + + public static ResolveResult found(ClassInfo info, String packagePortion, + String classPortion, List segments) { + return new ResolveResult(true, info, packagePortion, classPortion, segments, -1); + } + } + + /** + * Represents a segment of the class portion (e.g., "IOverlay" or "ColorType") + */ + public static class ClassSegment { + public final String name; + public final ClassType type; + + public ClassSegment(String name, ClassType type) { + this.name = name; + this.type = type; + } + } + + /** + * Clear all caches (call when imports change) + */ + public void clearCache() { + classCache.clear(); + validPackages.clear(); + } + + /** + * Resolve an import path to determine if it's a valid class and get its type. + * Handles inner classes like "kamkeel.npcdbc.api.client.overlay.IOverlay.ColorType" + * + * @param importPath The import path (e.g., "java.util.List" or "kamkeel...IOverlay.ColorType") + * @return ResolveResult with resolution details + */ + public ResolveResult resolve(String importPath) { + if (importPath == null || importPath.isEmpty()) { + return ResolveResult.notFound("", "", -1); + } + + // Handle trailing dot (user is still typing a package path like "java.util.") + boolean trailingDot = importPath.endsWith("."); + String pathToResolve = trailingDot ? importPath.substring(0, importPath.length() - 1) : importPath; + + if (pathToResolve.isEmpty()) { + return ResolveResult.notFound("", "", -1); + } + + String[] segments = pathToResolve.split("\\."); + int n = segments.length; + + // Find the first uppercase segment (likely start of class name) + int firstUpperIndex = findFirstUppercaseSegment(segments); + + // If trailing dot, check if the entire path is a valid package (either cached or all-lowercase) + if (trailingDot) { + // If entire path is lowercase, treat as valid package being typed + if (firstUpperIndex == n) { + // Check if we have it cached OR if it could be a valid package + if (isValidPackage(pathToResolve)) { + return ResolveResult.notFound(pathToResolve, "", -1); + } + // Not cached, but all lowercase - use heuristic: assume valid package + return ResolveResult.notFound(pathToResolve, "", -1); + } + } + + // Try different splits: package vs class boundary + // Start from firstUpperIndex and try treating each uppercase segment as potential class start + for (int classStartIdx = firstUpperIndex; classStartIdx < n; classStartIdx++) { + // Try different inner class depths (deepest first) + for (int classEndIdx = n; classEndIdx > classStartIdx; classEndIdx--) { + String candidate = buildClassName(segments, classStartIdx, classEndIdx); + ClassInfo info = tryResolveClass(candidate); + + if (info != null) { + // Found a valid class! + String packagePortion = buildPackagePortion(segments, classStartIdx); + String validClassPortion = buildClassPortion(segments, classStartIdx, classEndIdx); + + // Register this package as valid for future lookups + if (!packagePortion.isEmpty()) { + registerValidPackage(packagePortion); + } + + // Check if there are MORE segments after what we resolved (invalid inner class) + if (classEndIdx < n) { + // e.g., IOverlay.ColorType resolved but there's more like ".idk" + // Mark the extra parts as invalid + List classSegments = buildClassSegments(segments, classStartIdx, classEndIdx); + String fullClassPortion = buildClassPortion(segments, classStartIdx, n); + // Calculate where the invalid portion starts (after valid class + dot) + int validEndOffset = packagePortion.length() + (packagePortion.isEmpty() ? 0 : 1) + validClassPortion.length() + 1; + return new ResolveResult(false, info, packagePortion, + fullClassPortion, classSegments, validEndOffset); + } + + // Full resolution successful + List classSegments = buildClassSegments(segments, classStartIdx, n); + return ResolveResult.found( + new ClassInfo(info.resolvedName, info.type, packagePortion.length()), + packagePortion, validClassPortion, classSegments + ); + } + } + } + + // Class resolution failed completely + // Strategy: Use heuristic - everything before first uppercase = package, rest = class + // This ensures consistent behavior regardless of cache state + + if (firstUpperIndex > 0 && firstUpperIndex < n) { + // There's a package portion (lowercase) and class portion (uppercase start) + String packagePortion = buildPackagePortion(segments, firstUpperIndex); + String classPortion = buildClassPortion(segments, firstUpperIndex, n); + + // Check if we can detect a typo in the package by comparing with cached packages + int typoIndex = findPackageTypoIndex(segments, firstUpperIndex); + if (typoIndex >= 0 && typoIndex < firstUpperIndex) { + // Found a divergence from a known valid package + String validPrefix = buildPackagePortion(segments, typoIndex); + int invalidOffset = validPrefix.isEmpty() ? 0 : validPrefix.length() + 1; + return ResolveResult.notFound(validPrefix, buildClassPortion(segments, typoIndex, n), invalidOffset); + } + + // No typo detected - assume package is valid, only class portion is invalid + int invalidOffset = packagePortion.length() + 1; // After the package dot + return ResolveResult.notFound(packagePortion, classPortion, invalidOffset); + + } else if (firstUpperIndex == 0) { + // Entire path starts with uppercase (e.g., "String" or "MyClass.Inner") + // Treat as class with no package + return ResolveResult.notFound("", pathToResolve, 0); + + } else { + // All lowercase, no uppercase found - entire path is package-like + // If trailing dot was handled above, this is just a package without class + return ResolveResult.notFound(pathToResolve, "", -1); + } + } + + /** + * Find index where the typed package diverges from a known cached package. + * Returns -1 if no typo detected, or the segment index where divergence starts. + */ + private int findPackageTypoIndex(String[] segments, int classStartIdx) { + // Build the typed package path segment by segment and compare with cache + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < classStartIdx; i++) { + if (i > 0) sb.append("."); + sb.append(segments[i]); + String currentPath = sb.toString(); + + // Check if this exact path is in cache + if (!validPackages.contains(currentPath)) { + // Not in cache - check if a DIFFERENT path at this level IS in cache + // This would indicate a typo (e.g., "guui" vs "gui") + if (i > 0) { + String parentPath = buildPackagePortion(segments, i); + // Look for any cached package that starts with parent but differs at segment i + for (String cached : validPackages) { + if (cached.startsWith(parentPath + ".") && !cached.startsWith(currentPath)) { + // Found a cached package with same parent but different segment + return i; // This segment is likely a typo + } + } + } + } + } + return -1; // No typo detected + } + + /** + * Check if a package path is valid (has been seen in successful resolutions or can be validated) + */ + public boolean isValidPackage(String packagePath) { + if (packagePath == null || packagePath.isEmpty()) { + return false; + } + + // Check cache first + if (validPackages.contains(packagePath)) { + return true; + } + + // Try to find any class under this package to validate it + // Common test classes for well-known packages + String[] testClasses = getTestClassesForPackage(packagePath); + for (String testClass : testClasses) { + try { + Class.forName(testClass); + registerValidPackage(packagePath); + return true; + } catch (ClassNotFoundException | LinkageError ignored) { + } + } + + return false; + } + + /** + * Get the ClassInfo for a simple class name, using provided imports map + */ + public ClassInfo resolveSimpleName(String simpleName, Map importedClasses) { + if (simpleName == null || simpleName.isEmpty()) { + return null; + } + + // Check imported classes first + String fullName = importedClasses.get(simpleName); + if (fullName != null) { + return tryResolveClass(fullName); + } + + // Check java.lang classes + if (JAVA_LANG_CLASSES.contains(simpleName)) { + return tryResolveClass("java.lang." + simpleName); + } + + return null; + } + + /** + * Try to resolve a fully-qualified class name (with $ for inner classes) + */ + private ClassInfo tryResolveClass(String className) { + if (className == null || className.isEmpty()) { + return null; + } + + // Check cache + if (classCache.containsKey(className)) { + return classCache.get(className); + } + + ClassInfo result = null; + + try { + Class clazz = Class.forName(className); + result = createClassInfo(className, clazz); + classCache.put(className, result); + } catch (ClassNotFoundException | NoClassDefFoundError e) { + // Not found with given name, will be cached as null + } catch (LinkageError e) { + // Linkage error - treat as not found + } + + return result; + } + + private ClassInfo createClassInfo(String resolvedName, Class clazz) { + ClassType type; + if (clazz.isInterface()) { + type = ClassType.INTERFACE; + } else if (clazz.isEnum()) { + type = ClassType.ENUM; + } else { + type = ClassType.CLASS; + } + + // Calculate package end index + int pkgEnd = resolvedName.lastIndexOf('.'); + if (pkgEnd < 0) + pkgEnd = 0; + + return new ClassInfo(resolvedName, type, pkgEnd); + } + + /** + * Build a class name candidate from segments, using $ for inner classes + */ + private String buildClassName(String[] segments, int classStartIdx, int classEndIdx) { + StringBuilder sb = new StringBuilder(); + + // Package portion (dot-separated) + for (int i = 0; i < classStartIdx; i++) { + if (i > 0) + sb.append('.'); + sb.append(segments[i]); + } + + // Class portion ($ for inner classes after the first class) + for (int i = classStartIdx; i < classEndIdx; i++) { + if (i == classStartIdx) { + if (sb.length() > 0) + sb.append('.'); + } else { + sb.append('$'); + } + sb.append(segments[i]); + } + + return sb.toString(); + } + + private String buildPackagePortion(String[] segments, int classStartIdx) { + if (classStartIdx <= 0) + return ""; + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < classStartIdx; i++) { + if (i > 0) + sb.append('.'); + sb.append(segments[i]); + } + return sb.toString(); + } + + private String buildClassPortion(String[] segments, int classStartIdx, int endIdx) { + StringBuilder sb = new StringBuilder(); + for (int i = classStartIdx; i < endIdx; i++) { + if (i > classStartIdx) + sb.append('.'); + sb.append(segments[i]); + } + return sb.toString(); + } + + private List buildClassSegments(String[] segments, int classStartIdx, int endIdx) { + List result = new ArrayList<>(); + StringBuilder fullName = new StringBuilder(); + + // Build package portion + for (int i = 0; i < classStartIdx; i++) { + if (i > 0) + fullName.append('.'); + fullName.append(segments[i]); + } + + // Add each class segment + for (int i = classStartIdx; i < endIdx; i++) { + if (i == classStartIdx) { + if (fullName.length() > 0) + fullName.append('.'); + } else { + fullName.append('$'); + } + fullName.append(segments[i]); + + // Try to resolve this partial class name + ClassInfo info = tryResolveClass(fullName.toString()); + ClassType type = (info != null) ? info.type : ClassType.CLASS; + result.add(new ClassSegment(segments[i], type)); + } + + return result; + } + + private int findFirstUppercaseSegment(String[] segments) { + for (int i = 0; i < segments.length; i++) { + if (segments[i].length() > 0 && Character.isUpperCase(segments[i].charAt(0))) { + return i; + } + } + return segments.length - 1; // Default to last segment + } + + private void registerValidPackage(String packagePath) { + validPackages.add(packagePath); + + // Also register all parent packages + int lastDot; + String current = packagePath; + while ((lastDot = current.lastIndexOf('.')) > 0) { + current = current.substring(0, lastDot); + validPackages.add(current); + } + } + + private String[] getTestClassesForPackage(String packagePath) { + // Return common classes for well-known packages + switch (packagePath) { + case "java": + return new String[]{"java.lang.Object"}; + case "java.util": + return new String[]{"java.util.List", "java.util.Map", "java.util.Set"}; + case "java.io": + return new String[]{"java.io.File", "java.io.InputStream"}; + case "java.net": + return new String[]{"java.net.URL", "java.net.Socket"}; + case "java.lang": + return new String[]{"java.lang.Object", "java.lang.String"}; + default: + // For unknown packages, try common class suffixes + return new String[]{ + packagePath + ".package-info", // Won't work but harmless + }; + } + } + + // ==================== GENERIC TYPE PARSING ==================== + + /** + * Parse generic type content and return type names with their positions. + * Handles arbitrary nesting depth like "Map>>" + * + * @param genericContent The content inside <...> (without the outer angle brackets) + * @param importedClasses Map of simple name -> full class name for resolution + * @return List of TypeOccurrence with position and type info + */ + public List parseGenericTypes(String genericContent, Map importedClasses) { + List results = new ArrayList<>(); + parseGenericTypesRecursive(genericContent, 0, importedClasses, results); + return results; + } + + /** + * Represents a type occurrence within generic content + */ + public static class TypeOccurrence { + public final int startOffset; // Relative to the start of genericContent + public final int endOffset; + public final String typeName; + public final ClassType type; + + public TypeOccurrence(int startOffset, int endOffset, String typeName, ClassType type) { + this.startOffset = startOffset; + this.endOffset = endOffset; + this.typeName = typeName; + this.type = type; + } + } + + private void parseGenericTypesRecursive(String content, int baseOffset, + Map importedClasses, + List results) { + if (content == null || content.isEmpty()) + return; + + int i = 0; + while (i < content.length()) { + char c = content.charAt(i); + + // Skip whitespace and punctuation + if (!Character.isJavaIdentifierStart(c)) { + i++; + continue; + } + + // Found start of identifier + int start = i; + while (i < content.length() && Character.isJavaIdentifierPart(content.charAt(i))) { + i++; + } + String typeName = content.substring(start, i); + + // Only process if it looks like a type name (starts with uppercase) + if (Character.isUpperCase(typeName.charAt(0))) { + // Resolve the type + ClassInfo info = resolveSimpleName(typeName, importedClasses); + ClassType classType = (info != null) ? info.type : ClassType.CLASS; + + results.add(new TypeOccurrence(baseOffset + start, baseOffset + i, typeName, classType)); + } + + // Skip whitespace + while (i < content.length() && Character.isWhitespace(content.charAt(i))) { + i++; + } + + // Check for nested generic + if (i < content.length() && content.charAt(i) == '<') { + int nestedStart = i + 1; + int depth = 1; + i++; + + // Find matching > + while (i < content.length() && depth > 0) { + if (content.charAt(i) == '<') + depth++; + else if (content.charAt(i) == '>') + depth--; + i++; + } + + // Recursively parse nested content + if (nestedStart < i - 1) { + String nestedContent = content.substring(nestedStart, i - 1); + parseGenericTypesRecursive(nestedContent, baseOffset + nestedStart, importedClasses, results); + } + } + } + } +} diff --git a/src/main/java/noppes/npcs/client/gui/util/script/JavaTextContainer.java b/src/main/java/noppes/npcs/client/gui/util/script/JavaTextContainer.java index 42077c5bc..95d446086 100644 --- a/src/main/java/noppes/npcs/client/gui/util/script/JavaTextContainer.java +++ b/src/main/java/noppes/npcs/client/gui/util/script/JavaTextContainer.java @@ -16,15 +16,8 @@ public class JavaTextContainer extends TextContainer { "\\b(null|boolean|int|float|double|long|char|byte|short|void|if|else|switch|case|for|while|do|try|catch|finally|return|throw|var|let|const|function|continue|break|this|new|typeof|instanceof|import)\\b"); public static final Pattern CLASS_DECL = Pattern.compile("\\b(class|interface|enum)\\s+([A-Za-z_][a-zA-Z0-9_]*)"); - - public static final Pattern TYPE_DECL = Pattern.compile( - "(?:(?:public|private|protected|static|final|transient|volatile)\\s+)*" + // optional modifiers - "([A-Za-z_][a-zA-Z0-9_]*)" + // Group 1 → main type - "\\s*(<([^<>]*(?:<[^<>]*>)*)>)?" + // Group 2 → <…>, Group 3 → inner type (handles one level of nesting) - "\\s+[a-zA-Z_][a-zA-Z0-9_]*" // variable name - ); - public static final Pattern NEW_TYPE = Pattern.compile("\\bnew\\s+([A-Za-z_][a-zA-Z0-9_]*)"); + public static final Pattern NEW_TYPE = Pattern.compile("\\bnew\\s+([A-Za-z_][a-zA-Z0-9_]*)");; public static final Pattern METHOD_DECL = Pattern.compile("\\b([A-Za-z_][a-zA-Z0-9_<>\\[\\]]*)\\s+" + // return type "([a-zA-Z_][a-zA-Z0-9_]*)\\s*\\(" // method name @@ -44,9 +37,12 @@ public class JavaTextContainer extends TextContainer { public static final Pattern NUMBER = Pattern.compile( "\\b-?(?:0[xX][\\dA-Fa-f]+|0[bB][01]+|0[oO][0-7]+|\\d*\\.?\\d+(?:[Ee][+-]?\\d+)?(?:[fFbBdDlLsS])?|NaN|null|Infinity|true|false)\\b"); - // Import statement: import [static] package.path.ClassName [.*]; + // Import statement: import [static] package.path.ClassName [.*]; (semicolon optional for live typing) public static final Pattern IMPORT = Pattern.compile( - "\\bimport\\s+(?:static\\s+)?([a-zA-Z_][a-zA-Z0-9_.]*?)(?:\\s*\\.\\s*\\*)?\\s*;"); + "(?m)\\bimport\\s+(?:static\\s+)?([a-zA-Z_][a-zA-Z0-9_.]*?)(?:\\s*\\.\\s*\\*)?\\s*(?:;|$)"); + + // ClassPathFinder for resolving imports and determining class types + private final ClassPathFinder classPathFinder = new ClassPathFinder(); public List lines = new ArrayList<>(); public List methodBlocks = new ArrayList<>(); @@ -97,28 +93,11 @@ public class JavaTextContainer extends TextContainer { private List localFields = new ArrayList<>(); private java.util.Map importedClasses = new java.util.HashMap<>(); // simpleName -> fullName private java.util.Set importedPackages = new java.util.HashSet<>(); // wildcard imports - private java.util.Map classTypeCache = new java.util.HashMap<>(); // fullClassName -> TokenType (cached reflection results) - private java.util.Set resolvedPackages = new java.util.HashSet<>(); - private java.util.Set knownPackages = new java.util.HashSet<>(); - - // Common java.lang classes (auto-imported) - private static final java.util.Set JAVA_LANG_CLASSES = new java.util.HashSet<>(java.util.Arrays.asList( - "Object", "String", "Class", "System", "Math", "Integer", "Double", "Float", "Long", "Short", "Byte", - "Character", "Boolean", - "Number", "Void", "Thread", "Runnable", "Exception", "RuntimeException", "Error", "Throwable", - "StringBuilder", "StringBuffer", "Enum", "Comparable", "Iterable", "CharSequence", "Cloneable", - "Process", "ProcessBuilder", "Runtime", "SecurityManager", "ClassLoader", "Package", - "ArithmeticException", "ArrayIndexOutOfBoundsException", "ClassCastException", "IllegalArgumentException", - "IllegalStateException", "IndexOutOfBoundsException", "NullPointerException", "NumberFormatException", - "UnsupportedOperationException", "AssertionError", "OutOfMemoryError", "StackOverflowError" - )); private void collectImports() { importedClasses.clear(); importedPackages.clear(); - // Clear cached import resolutions when re-parsing imports (keeps editor responsive during typing) - importResolveCache.clear(); - knownPackages.clear(); + classPathFinder.clearCache(); List excluded = MethodBlock.getExcludedRanges(text); Matcher m = IMPORT.matcher(text); @@ -126,229 +105,53 @@ public class JavaTextContainer extends TextContainer { if (isInExcludedRange(m.start(), excluded)) continue; String fullPath = m.group(1).trim(); + String matchText = m.group(0); + // Check if wildcard import (import a.b.*;) - if (text.substring(m.start(), m.end()).contains("*")) { + if (matchText.contains("*")) { importedPackages.add(fullPath); } else { - // Specific class import: extract simple name + // Resolve the import to get the proper class name + ClassPathFinder.ResolveResult result = classPathFinder.resolve(fullPath); + + // Extract simple name (last segment) int lastDot = fullPath.lastIndexOf('.'); String simpleName = lastDot >= 0 ? fullPath.substring(lastDot + 1) : fullPath; - // Remember the package prefix as a known package (even before resolution) - if (lastDot > 0) { - String pkg = fullPath.substring(0, lastDot); - knownPackages.add(pkg); - } - // Store with $ notation for inner classes (e.g., IOverlay.ColorType -> IOverlay$ColorType) - String storagePath = fullPath.replaceAll("\\.(\\p{Upper})", "\\$$1"); - importedClasses.put(simpleName, storagePath); - } - } - } - - // Cache for resolved import attempts: original fullPath -> resolved class name (with $ for inner classes) or null if unresolved - private java.util.Map importResolveCache = new java.util.HashMap<>(); - - private static class ResolvedImport { - String resolvedClass; // full class name with $ notation if found - int classStartIndex; // index in segments where class starts - TokenType tokenType; - boolean found; - } - - /** - * Try to resolve an import path to an actual class using Class.forName by attempting - * different class-start splits and inner-class ($) combinations. Caches results. - */ - private ResolvedImport resolveImportPath(String fullPath) { - ResolvedImport ri = new ResolvedImport(); - if (fullPath == null || fullPath.isEmpty()) { - ri.found = false; return ri; - } - if (importResolveCache.containsKey(fullPath)) { - String cached = importResolveCache.get(fullPath); - if (cached == null) { ri.found = false; return ri; } - ri.resolvedClass = cached; - ri.found = true; - ri.tokenType = getClassTokenType(cached, TokenType.IMPORTED_CLASS); - // derive classStartIndex by comparing segments - String[] segs = fullPath.split("\\."); - String[] rcsegs = cached.replace('$', '.').split("\\."); - int si = 0; while (si < segs.length && si < rcsegs.length && segs[si].equals(rcsegs[si])) si++; - ri.classStartIndex = si; - return ri; - } - - String[] segments = fullPath.split("\\."); - int n = segments.length; - - // Try candidate class start positions. Start from left-most uppercase-looking segment for efficiency. - int firstUpper = 0; - for (int i = 0; i < n; i++) { - if (segments[i].length() > 0 && Character.isUpperCase(segments[i].charAt(0))) { firstUpper = i; break; } - } - - outer: - for (int k = firstUpper; k < n; k++) { - // number of inner parts available - int innerAvailable = n - (k + 1); - // Try the longest inner suffix first (deepest inner class) - for (int innerCount = innerAvailable; innerCount >= 0; innerCount--) { - // build top-level class and inner suffix - StringBuilder classBuilder = new StringBuilder(); - // package portion - if (k > 0) { - for (int i = 0; i < k; i++) { - if (i > 0) classBuilder.append('.'); - classBuilder.append(segments[i]); - } - classBuilder.append('.'); - } - // top class - classBuilder.append(segments[k]); - // inner parts - if (innerCount > 0) { - classBuilder.append('$'); - for (int j = 0; j < innerCount; j++) { - if (j > 0) classBuilder.append('$'); - classBuilder.append(segments[k + 1 + j]); - } - } - String candidate = classBuilder.toString(); - TokenType tt = getClassTokenType(candidate, TokenType.IMPORTED_CLASS); - if (tt != TokenType.UNDEFINED_VAR) { - ri.found = true; - ri.resolvedClass = candidate; - ri.classStartIndex = k; - ri.tokenType = tt; - importResolveCache.put(fullPath, candidate); - break outer; - } - // also try without package separator if k==0 (top-level without package) - if (k == 0 && innerCount == 0) { - // candidate is segments[0] only, already tried - } - } - } - - if (!ri.found) { - importResolveCache.put(fullPath, null); - ri.found = false; - // approximate classStartIndex as first uppercase or last segment - int classStart = 0; - for (int i = 0; i < segments.length; i++) { - if (segments[i].length() > 0 && Character.isUpperCase(segments[i].charAt(0))) { classStart = i; break; } - } - ri.classStartIndex = classStart; - } - return ri; - } - - /** - * Determine the token type for a class using reflection. - * Returns INTERFACE_DECL for interfaces, ENUM_DECL for enums, UNDEFINED for unfound classes, or the defaultType for classes. - */ - private TokenType getClassTokenType(String fullClassName, TokenType defaultType) { - if (fullClassName == null || fullClassName.isEmpty()) return defaultType; - - // Check cache first - // if (classTypeCache.containsKey(fullClassName)) { - // return classTypeCache.get(fullClassName); - //} - TokenType result = defaultType; - try { - Class clazz = Class.forName(fullClassName); - if (clazz.isInterface()) { - result = TokenType.INTERFACE_DECL; - } else if (clazz.isEnum()) { - result = TokenType.ENUM_DECL; - } else { - result = defaultType; // regular class - } - } catch (ClassNotFoundException | NoClassDefFoundError e) { - // Try with $ notation for inner classes (e.g., IOverlay$ColorType) - String innerClassNotation = fullClassName.replaceAll("\\.(\\p{Upper})", "\\$$1"); - if (!innerClassNotation.equals(fullClassName)) { - try { - Class clazz = Class.forName(innerClassNotation); - if (clazz.isInterface()) { - result = TokenType.INTERFACE_DECL; - } else if (clazz.isEnum()) { - result = TokenType.ENUM_DECL; + if (!simpleName.isEmpty()) { + if (result.found && result.classInfo != null) { + // Store with resolved name (has $ for inner classes) + importedClasses.put(simpleName, result.classInfo.resolvedName); } else { - result = defaultType; // regular class + // Store original path for unresolved imports + importedClasses.put(simpleName, fullPath); } - // Cache with original notation - classTypeCache.put(fullClassName, result); - return result; - } catch (Exception e2) { - // Still not found } } - // Class not found - mark as undefined - result = TokenType.UNDEFINED_VAR; - } catch (LinkageError e) { - // Linkage errors (other than NoClassDefFoundError) - treat as undefined - result = TokenType.UNDEFINED_VAR; } - - // Cache the result - classTypeCache.put(fullClassName, result); - return result; } /** - * Parse and highlight generic type parameters recursively (e.g., "List" or "Map>>") - * Handles arbitrarily nested generics by tracking bracket depth. + * Parse and highlight generic type parameters using ClassPathFinder. + * Handles arbitrarily nested generics like "Map>>". */ private void highlightGenericTypes(String genericContent, int contentStart, List marks, List excluded) { if (genericContent == null || genericContent.isEmpty()) return; - int i = 0; - while (i < genericContent.length()) { - char c = genericContent.charAt(i); + List occurrences = classPathFinder.parseGenericTypes(genericContent, importedClasses); + + for (ClassPathFinder.TypeOccurrence occ : occurrences) { + int absStart = contentStart + occ.startOffset; + int absEnd = contentStart + occ.endOffset; - // Check if we're starting a type name (uppercase letter) - if (Character.isJavaIdentifierStart(c) && Character.isUpperCase(c)) { - int start = i; - while (i < genericContent.length() && Character.isJavaIdentifierPart(genericContent.charAt(i))) { - i++; + if (!isInExcludedRange(absStart, excluded)) { + TokenType tokenType; + switch (occ.type) { + case INTERFACE: tokenType = TokenType.INTERFACE_DECL; break; + case ENUM: tokenType = TokenType.ENUM_DECL; break; + default: tokenType = TokenType.IMPORTED_CLASS; break; } - String typeName = genericContent.substring(start, i); - int absStart = contentStart + start; - int absEnd = contentStart + i; - - if (!isInExcludedRange(absStart, excluded)) { - // Resolve full class name - String fullClassName = importedClasses.get(typeName); - if (fullClassName == null && JAVA_LANG_CLASSES.contains(typeName)) { - fullClassName = "java.lang." + typeName; - } - - TokenType tokenType = getClassTokenType(fullClassName, TokenType.IMPORTED_CLASS); - marks.add(new Mark(absStart, absEnd, tokenType)); - } - - // Check for nested generics after this type - while (i < genericContent.length() && Character.isWhitespace(genericContent.charAt(i))) i++; - if (i < genericContent.length() && genericContent.charAt(i) == '<') { - // Find matching > - int depth = 1; - int nestedStart = i + 1; - i++; - while (i < genericContent.length() && depth > 0) { - if (genericContent.charAt(i) == '<') depth++; - else if (genericContent.charAt(i) == '>') depth--; - i++; - } - // Recursively process nested content - String nestedContent = genericContent.substring(nestedStart, i - 1); - highlightGenericTypes(nestedContent, contentStart + nestedStart, marks, excluded); - // Continue after the closing > to find more types - } - // Continue to next character (handles commas, spaces, etc.) - } else { - i++; + marks.add(new Mark(absStart, absEnd, tokenType)); } } } @@ -604,7 +407,6 @@ public class JavaTextContainer extends TextContainer { private void collectImportStatements(List marks) { List excluded = MethodBlock.getExcludedRanges(text); - // Pattern to match: import [static] package.path.ClassName [.*]; Matcher m = IMPORT.matcher(text); while (m.find()) { if (isInExcludedRange(m.start(), excluded)) @@ -612,108 +414,95 @@ public class JavaTextContainer extends TextContainer { String fullPath = m.group(1).trim(); int pathStart = m.start(1); int pathEnd = m.end(1); + String matchText = m.group(0); // Highlight the 'import' keyword itself int importKwStart = m.start(); - int importKwEnd = Math.min(importKwStart + 6, text.length()); // "import" + int importKwEnd = Math.min(importKwStart + 6, text.length()); marks.add(new Mark(importKwStart, importKwEnd, TokenType.IMPORT_KEYWORD)); - // Resolve import path to an actual class if possible - ResolvedImport ri = resolveImportPath(fullPath); - String[] segments = fullPath.split("\\."); + // Use ClassPathFinder to resolve the import + ClassPathFinder.ResolveResult result = classPathFinder.resolve(fullPath); - // Compute package portion length - int pkgSegments = 0; - if (ri.found && ri.resolvedClass != null) { - pkgSegments = Math.max(0, ri.classStartIndex); - // register resolved package prefix - if (pkgSegments > 0) { - StringBuilder pb = new StringBuilder(); - for (int i = 0; i < pkgSegments; i++) { - if (i > 0) pb.append('.'); - pb.append(segments[i]); - } - resolvedPackages.add(pb.toString()); + String packagePortion = result.packagePortion; + String classPortion = result.classPortion; + boolean hasPackage = packagePortion != null && !packagePortion.isEmpty(); + boolean hasClass = classPortion != null && !classPortion.isEmpty(); + + if (result.found && result.invalidStartOffset < 0) { + // Fully resolved - highlight package (blue) and class segments (typed) + if (hasPackage) { + int pkgEnd = pathStart + packagePortion.length(); + if (hasClass) pkgEnd++; // include dot + marks.add(new Mark(pathStart, pkgEnd, TokenType.TYPE_DECL)); } - } else { - // Try to find the longest previously-resolved package prefix, or known package prefix - for (int p = segments.length - 1; p >= 1; p--) { - StringBuilder test = new StringBuilder(); - for (int i = 0; i < p; i++) { - if (i > 0) test.append('.'); - test.append(segments[i]); - } - String testStr = test.toString(); - if (resolvedPackages.contains(testStr)) { - pkgSegments = p; - break; - } - if (knownPackages.contains(testStr)) { - pkgSegments = p; - // don't break; prefer a resolved package if found later in loop - } - } - // fallback to ri.classStartIndex if nothing known - if (pkgSegments == 0) pkgSegments = Math.max(0, ri.classStartIndex); - } - StringBuilder pkgBuilder = new StringBuilder(); - for (int i = 0; i < pkgSegments; i++) { - if (i > 0) pkgBuilder.append('.'); - pkgBuilder.append(segments[i]); - } - int pkgLen = pkgBuilder.length(); - if (pkgLen > 0) { - marks.add(new Mark(pathStart, pathStart + pkgLen + 1, TokenType.TYPE_DECL)); // package + dot - } - - // Highlight class and inner-class segments - int classStart = pathStart + (pkgLen > 0 ? pkgLen + 1 : 0); - if (ri.found && ri.resolvedClass != null) { - // Use resolved class (with $). Break into segments for progressive checks. - for (int i = ri.classStartIndex; i < segments.length; i++) { - int segStart = classStart; - int segEnd = classStart + segments[i].length(); - - // Build candidate full name up to this segment using $ for inner separators - StringBuilder testBuilder = new StringBuilder(); - if (ri.classStartIndex > 0) { - for (int p = 0; p < ri.classStartIndex; p++) { - if (p > 0) testBuilder.append('.'); - testBuilder.append(segments[p]); + + if (hasClass && !result.classSegments.isEmpty()) { + int classStart = pathStart + (hasPackage ? packagePortion.length() + 1 : 0); + for (ClassPathFinder.ClassSegment seg : result.classSegments) { + int segEnd = classStart + seg.name.length(); + TokenType segType; + switch (seg.type) { + case INTERFACE: segType = TokenType.INTERFACE_DECL; break; + case ENUM: segType = TokenType.ENUM_DECL; break; + default: segType = TokenType.IMPORTED_CLASS; break; } - testBuilder.append('.'); + marks.add(new Mark(classStart, segEnd, segType)); + classStart = segEnd + 1; } - testBuilder.append(segments[ri.classStartIndex]); - for (int q = ri.classStartIndex + 1; q <= i; q++) { - testBuilder.append('$').append(segments[q]); + } + } else if (result.classSegments != null && !result.classSegments.isEmpty()) { + // Partial resolution - some class segments are valid, rest is invalid + // (e.g., IOverlay.ColorType resolved but ".idk" is invalid) + if (hasPackage) { + int pkgEnd = pathStart + packagePortion.length() + 1; // include dot + marks.add(new Mark(pathStart, pkgEnd, TokenType.TYPE_DECL)); + } + + int classStart = pathStart + (hasPackage ? packagePortion.length() + 1 : 0); + for (ClassPathFinder.ClassSegment seg : result.classSegments) { + int segEnd = classStart + seg.name.length(); + TokenType segType; + switch (seg.type) { + case INTERFACE: segType = TokenType.INTERFACE_DECL; break; + case ENUM: segType = TokenType.ENUM_DECL; break; + default: segType = TokenType.IMPORTED_CLASS; break; } - String testFull = testBuilder.toString(); - TokenType segType = getClassTokenType(testFull, TokenType.IMPORTED_CLASS); - marks.add(new Mark(segStart, segEnd, segType)); - - // Update importedClasses map for simple name resolution - if (i == segments.length - 1) { - importedClasses.put(segments[i], ri.resolvedClass); + marks.add(new Mark(classStart, segEnd, segType)); + classStart = segEnd + 1; + } + + // Mark remaining invalid portion in red + if (result.invalidStartOffset >= 0) { + int invalidStart = pathStart + result.invalidStartOffset; + if (invalidStart < pathEnd) { + marks.add(new Mark(invalidStart, pathEnd, TokenType.UNDEFINED_VAR)); } - - classStart = segEnd + 1; // +1 for dot } } else { - // Unresolved import: mark the entire tail (after known package prefix) as undefined - int undefStart = classStart; - int undefEnd = pathEnd; - if (undefStart < undefEnd) { - marks.add(new Mark(undefStart, undefEnd, TokenType.UNDEFINED_VAR)); - } - // Remove any previously-added simple-name mapping to avoid treating it as imported - String simpleName = segments[segments.length - 1]; - if (importedClasses.containsKey(simpleName)) { - importedClasses.remove(simpleName); + // Not resolved at all - use invalidStartOffset to determine valid/invalid split + if (result.invalidStartOffset >= 0 && hasPackage) { + // Valid package prefix, invalid class + int pkgEnd = pathStart + packagePortion.length(); + marks.add(new Mark(pathStart, pkgEnd, TokenType.TYPE_DECL)); + + int invalidStart = pathStart + result.invalidStartOffset; + if (invalidStart < pathEnd) { + marks.add(new Mark(invalidStart, pathEnd, TokenType.UNDEFINED_VAR)); + } + } else if (fullPath.endsWith(".") && hasPackage) { + // Trailing dot on package - all blue + marks.add(new Mark(pathStart, pathEnd, TokenType.TYPE_DECL)); + } else if (result.invalidStartOffset < 0 && hasPackage && !hasClass) { + // All-lowercase path treated as valid package + marks.add(new Mark(pathStart, pathEnd, TokenType.TYPE_DECL)); + } else { + // Entire path is invalid + marks.add(new Mark(pathStart, pathEnd, TokenType.UNDEFINED_VAR)); } } - // Highlight .* if present (find '*' between match bounds) - String matchText = text.substring(m.start(), m.end()); + // Highlight .* if present int starIndex = matchText.indexOf('*'); if (starIndex != -1) { int absStar = m.start() + starIndex; @@ -735,13 +524,9 @@ public class JavaTextContainer extends TextContainer { continue; // Check if this is an imported class, wildcard match, or java.lang class - boolean isImported = importedClasses.containsKey(className) || JAVA_LANG_CLASSES.contains(className); - if (!isImported) { - // Check wildcard imports (heuristic: if any imported package exists, mark it) - for (String pkg : importedPackages) { - isImported = true; - break; - } + boolean isImported = importedClasses.containsKey(className) || ClassPathFinder.JAVA_LANG_CLASSES.contains(className); + if (!isImported && !importedPackages.isEmpty()) { + isImported = true; // Wildcard import present } // Also skip if it's a local/global field or parameter (avoid false positives) @@ -753,12 +538,15 @@ public class JavaTextContainer extends TextContainer { } if (isImported && !isShadowed) { - // Determine token type dynamically using reflection - String fullClassName = importedClasses.get(className); - if (fullClassName == null && JAVA_LANG_CLASSES.contains(className)) { - fullClassName = "java.lang." + className; + // Resolve the class to determine its type + ClassPathFinder.ClassInfo info = classPathFinder.resolveSimpleName(className, importedClasses); + TokenType tokenType = TokenType.IMPORTED_CLASS; + if (info != null) { + switch (info.type) { + case INTERFACE: tokenType = TokenType.INTERFACE_DECL; break; + case ENUM: tokenType = TokenType.ENUM_DECL; break; + } } - TokenType tokenType = getClassTokenType(fullClassName, TokenType.IMPORTED_CLASS); marks.add(new Mark(start, end, tokenType)); } } @@ -869,46 +657,118 @@ public class JavaTextContainer extends TextContainer { } private void collectTypeDeclarations(List marks) { - // Run TYPE_DECL on each line to avoid matches bleeding across newlines List excluded = MethodBlock.getExcludedRanges(text); + + // Pattern to find type declarations: Type<...> variableName or Type variableName + // We'll manually parse the generic portion to handle arbitrary nesting + Pattern typeStart = Pattern.compile( + "(?:(?:public|private|protected|static|final|transient|volatile)\\s+)*" + + "([A-Z][a-zA-Z0-9_]*)\\s*" // Group 1 → type name (must start with uppercase) + ); + for (LineData ld : lines) { int lineStart = ld.start; int lineEnd = ld.end; if (lineStart >= lineEnd) continue; String s = ld.text; - Matcher m = TYPE_DECL.matcher(s); - while (m.find()) { - int g1s = lineStart + m.start(1); - int g1e = lineStart + m.end(1); - // Skip if any part of the match overlaps an excluded range (string/comment) + + Matcher m = typeStart.matcher(s); + int searchFrom = 0; + + while (m.find(searchFrom)) { + int typeNameStart = lineStart + m.start(1); + int typeNameEnd = lineStart + m.end(1); + + // Skip if in excluded range boolean skip = false; for (int[] r : excluded) { - if (g1s < r[1] && g1e > r[0]) { skip = true; break; } + if (typeNameStart < r[1] && typeNameEnd > r[0]) { skip = true; break; } } - if (skip) continue; - // Check if this type is a known interface or enum using reflection + if (skip) { + searchFrom = m.end(); + continue; + } + String typeName = m.group(1); - String fullClassName = importedClasses.get(typeName); - if (fullClassName == null && JAVA_LANG_CLASSES.contains(typeName)) { - fullClassName = "java.lang." + typeName; + int posAfterType = m.end(1); + + // Skip whitespace after type name + while (posAfterType < s.length() && Character.isWhitespace(s.charAt(posAfterType))) { + posAfterType++; } - TokenType tokenType = getClassTokenType(fullClassName, TokenType.TYPE_DECL); - marks.add(new Mark(g1s, g1e, tokenType)); - - if (m.group(2) != null) { - int start = lineStart + m.start(2); - int end = lineStart + m.end(2); - // Color < and > as default - marks.add(new Mark(start, start + 1, TokenType.DEFAULT)); // < - marks.add(new Mark(end - 1, end, TokenType.DEFAULT)); // > - - // Parse and highlight all types in generic content (handles nested generics) - if (m.group(3) != null) { - String genericContent = m.group(3); - int contentStart = lineStart + m.start(3); + + // Check for generic parameters + String genericContent = null; + int genericStart = -1; + int genericEnd = -1; + + if (posAfterType < s.length() && s.charAt(posAfterType) == '<') { + genericStart = posAfterType; + int depth = 1; + int i = posAfterType + 1; + while (i < s.length() && depth > 0) { + char c = s.charAt(i); + if (c == '<') depth++; + else if (c == '>') depth--; + i++; + } + if (depth == 0) { + genericEnd = i; + genericContent = s.substring(genericStart + 1, genericEnd - 1); + posAfterType = genericEnd; + } + } + + // Skip whitespace after generics + while (posAfterType < s.length() && Character.isWhitespace(s.charAt(posAfterType))) { + posAfterType++; + } + + // Check if this looks like a type declaration: + // 1. Has generic content (e.g., List) - always a type + // 2. Followed by a variable name (lowercase start or underscore) + // 3. At end of line with generic content (line might be wrapped) + boolean hasGeneric = genericContent != null && !genericContent.isEmpty(); + boolean atEndOfLine = posAfterType >= s.length(); + boolean followedByVarName = false; + + if (!atEndOfLine) { + char nextChar = s.charAt(posAfterType); + followedByVarName = Character.isLetter(nextChar) || nextChar == '_'; + } + + // Accept as type if: has generics OR followed by variable name + // This handles both `List myList` and wrapped lines like `List` + if (hasGeneric || followedByVarName) { + // This looks like a type declaration + + // Resolve the main type + ClassPathFinder.ClassInfo info = classPathFinder.resolveSimpleName(typeName, importedClasses); + TokenType tokenType = TokenType.TYPE_DECL; + if (info != null) { + switch (info.type) { + case INTERFACE: tokenType = TokenType.INTERFACE_DECL; break; + case ENUM: tokenType = TokenType.ENUM_DECL; break; + } + } + marks.add(new Mark(typeNameStart, typeNameEnd, tokenType)); + + // Handle generic content + if (hasGeneric && genericStart >= 0) { + int absGenericStart = lineStart + genericStart; + int absGenericEnd = lineStart + genericEnd; + + // Mark < and > as default + marks.add(new Mark(absGenericStart, absGenericStart + 1, TokenType.DEFAULT)); + marks.add(new Mark(absGenericEnd - 1, absGenericEnd, TokenType.DEFAULT)); + + // Parse and highlight all types in generic content + int contentStart = lineStart + genericStart + 1; highlightGenericTypes(genericContent, contentStart, marks, excluded); } } + + searchFrom = m.end(); } } }