From 3e21757d12d3d417d262330c57b6e4aa1aeb8f02 Mon Sep 17 00:00:00 2001 From: bigguy345 Date: Sun, 28 Dec 2025 18:54:10 +0200 Subject: [PATCH] Method declaration error validation + helper classes needed for that --- .../util/script/interpreter/CodeParser.java | 323 ++++++++++++++++++ .../interpreter/ControlFlowAnalyzer.java | 215 ++++++++++++ .../util/script/interpreter/MethodInfo.java | 261 ++++++++++++++ .../util/script/interpreter/ScriptLine.java | 120 +++++++ .../util/script/interpreter/TypeChecker.java | 161 +++++++++ .../interpreter/hover/TokenHoverInfo.java | 74 ++++ 6 files changed, 1154 insertions(+) create mode 100644 src/main/java/noppes/npcs/client/gui/util/script/interpreter/CodeParser.java create mode 100644 src/main/java/noppes/npcs/client/gui/util/script/interpreter/ControlFlowAnalyzer.java create mode 100644 src/main/java/noppes/npcs/client/gui/util/script/interpreter/TypeChecker.java diff --git a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/CodeParser.java b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/CodeParser.java new file mode 100644 index 000000000..435c8f42e --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/CodeParser.java @@ -0,0 +1,323 @@ +package noppes.npcs.client.gui.util.script.interpreter; + +/** + * Utility class for parsing and analyzing code text. + * Provides methods for finding matching braces/parentheses, removing comments/strings, + * and locating keywords in code. + */ +public final class CodeParser { + + private CodeParser() {} // Utility class + + // ==================== Matching Delimiters ==================== + + /** + * Find the closing brace that matches the opening brace at the given position. + * @param text The text to search + * @param openBrace Position of the opening '{' + * @return Position of matching '}' or -1 if not found + */ + public static int findMatchingBrace(String text, int openBrace) { + if (openBrace < 0 || openBrace >= text.length() || text.charAt(openBrace) != '{') { + return -1; + } + int depth = 1; + for (int i = openBrace + 1; i < text.length(); i++) { + char c = text.charAt(i); + if (c == '{') depth++; + else if (c == '}') { + depth--; + if (depth == 0) return i; + } + } + return -1; + } + + /** + * Find the closing parenthesis that matches the opening parenthesis at the given position. + * @param text The text to search + * @param openParen Position of the opening '(' + * @return Position of matching ')' or -1 if not found + */ + public static int findMatchingParen(String text, int openParen) { + if (openParen < 0 || openParen >= text.length() || text.charAt(openParen) != '(') { + return -1; + } + int depth = 1; + for (int i = openParen + 1; i < text.length(); i++) { + char c = text.charAt(i); + if (c == '(') depth++; + else if (c == ')') { + depth--; + if (depth == 0) return i; + } + } + return -1; + } + + // ==================== Comment/String Removal ==================== + + /** + * Remove string literals and comments from code for structural analysis. + * This is used when analyzing control flow to avoid false positives from + * keywords inside strings or comments. + * @param code The source code + * @return Code with strings and comments removed + */ + public static String removeStringsAndComments(String code) { + StringBuilder result = new StringBuilder(); + boolean inString = false; + boolean inChar = false; + boolean inLineComment = false; + boolean inBlockComment = false; + + for (int i = 0; i < code.length(); i++) { + char c = code.charAt(i); + char next = (i + 1 < code.length()) ? code.charAt(i + 1) : 0; + + if (inLineComment) { + if (c == '\n') { + inLineComment = false; + result.append(c); + } + continue; + } + + if (inBlockComment) { + if (c == '*' && next == '/') { + inBlockComment = false; + i++; // skip '/' + } + continue; + } + + if (inString || inChar) { + if (c == '\\' && i + 1 < code.length()) { + i++; // skip escaped character + continue; + } + if ((inString && c == '"') || (inChar && c == '\'')) { + inString = false; + inChar = false; + } + continue; + } + + // Check for start of comments or strings + if (c == '/' && next == '/') { + inLineComment = true; + i++; + continue; + } + if (c == '/' && next == '*') { + inBlockComment = true; + i++; + continue; + } + if (c == '"') { + inString = true; + continue; + } + if (c == '\'') { + inChar = true; + continue; + } + + result.append(c); + } + + return result.toString(); + } + + /** + * Remove only comments from code, keeping strings intact and preserving positions. + * Comments are replaced with spaces to maintain character positions. + * @param code The source code + * @return Code with comments replaced by spaces + */ + public static String removeComments(String code) { + StringBuilder result = new StringBuilder(); + boolean inString = false; + boolean inChar = false; + boolean inLineComment = false; + boolean inBlockComment = false; + + for (int i = 0; i < code.length(); i++) { + char c = code.charAt(i); + char next = (i + 1 < code.length()) ? code.charAt(i + 1) : 0; + + if (inLineComment) { + if (c == '\n') { + inLineComment = false; + result.append(c); + } else { + result.append(' '); // Preserve position with spaces + } + continue; + } + + if (inBlockComment) { + if (c == '*' && next == '/') { + inBlockComment = false; + result.append(" "); // Preserve position + i++; + } else { + result.append(c == '\n' ? '\n' : ' '); // Preserve newlines + } + continue; + } + + if (inString) { + result.append(c); + if (c == '\\' && i + 1 < code.length()) { + result.append(code.charAt(++i)); + continue; + } + if (c == '"') inString = false; + continue; + } + + if (inChar) { + result.append(c); + if (c == '\\' && i + 1 < code.length()) { + result.append(code.charAt(++i)); + continue; + } + if (c == '\'') inChar = false; + continue; + } + + // Check for start of comments + if (c == '/' && next == '/') { + inLineComment = true; + result.append(" "); // Preserve position + i++; + continue; + } + if (c == '/' && next == '*') { + inBlockComment = true; + result.append(" "); // Preserve position + i++; + continue; + } + if (c == '"') { inString = true; } + if (c == '\'') { inChar = true; } + + result.append(c); + } + + return result.toString(); + } + + // ==================== Keyword Detection ==================== + + /** + * Check if a keyword exists at the given position in text. + * Verifies the keyword is not part of a larger identifier. + * @param text The text to check + * @param pos Position to check + * @param keyword The keyword to look for + * @return true if the keyword is at this position as a standalone word + */ + public static boolean isKeywordAt(String text, int pos, String keyword) { + if (pos + keyword.length() > text.length()) return false; + if (!text.substring(pos).startsWith(keyword)) return false; + + boolean validBefore = pos == 0 || !Character.isLetterOrDigit(text.charAt(pos - 1)); + boolean validAfter = pos + keyword.length() >= text.length() + || !Character.isLetterOrDigit(text.charAt(pos + keyword.length())); + + return validBefore && validAfter; + } + + /** + * Find the next "return" keyword that is not inside a string literal. + * @param text The text to search + * @param start Position to start searching from + * @return Position of "return" or -1 if not found + */ + public static int findReturnKeyword(String text, int start) { + boolean inString = false; + boolean inChar = false; + + for (int i = start; i < text.length() - 5; i++) { + char c = text.charAt(i); + + if (inString) { + if (c == '\\' && i + 1 < text.length()) { i++; continue; } + if (c == '"') inString = false; + continue; + } + if (inChar) { + if (c == '\\' && i + 1 < text.length()) { i++; continue; } + if (c == '\'') inChar = false; + continue; + } + + if (c == '"') { inString = true; continue; } + if (c == '\'') { inChar = true; continue; } + + // Check for "return" keyword + if (isKeywordAt(text, i, "return")) { + return i; + } + } + return -1; + } + + /** + * Find the semicolon that ends a return statement, handling nested structures. + * @param text The text to search + * @param start Position to start searching (after "return" keyword) + * @return Position of ';' or -1 if not found + */ + public static int findReturnSemicolon(String text, int start) { + int parenDepth = 0; + int braceDepth = 0; + boolean inString = false; + boolean inChar = false; + + for (int i = start; i < text.length(); i++) { + char c = text.charAt(i); + + if (inString) { + if (c == '\\' && i + 1 < text.length()) { i++; continue; } + if (c == '"') inString = false; + continue; + } + if (inChar) { + if (c == '\\' && i + 1 < text.length()) { i++; continue; } + if (c == '\'') inChar = false; + continue; + } + + if (c == '"') { inString = true; continue; } + if (c == '\'') { inChar = true; continue; } + if (c == '(') { parenDepth++; continue; } + if (c == ')') { parenDepth--; continue; } + if (c == '{') { braceDepth++; continue; } + if (c == '}') { braceDepth--; continue; } + + if (c == ';' && parenDepth == 0 && braceDepth == 0) { + return i; + } + } + return -1; + } + + // ==================== Whitespace Utilities ==================== + + /** + * Skip whitespace characters starting from the given position. + * @param text The text + * @param pos Starting position + * @return Position of first non-whitespace character + */ + public static int skipWhitespace(String text, int pos) { + while (pos < text.length() && Character.isWhitespace(text.charAt(pos))) { + pos++; + } + return pos; + } +} + diff --git a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/ControlFlowAnalyzer.java b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/ControlFlowAnalyzer.java new file mode 100644 index 000000000..6416805ba --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/ControlFlowAnalyzer.java @@ -0,0 +1,215 @@ +package noppes.npcs.client.gui.util.script.interpreter; + +/** + * Analyzes control flow in method bodies to determine if all paths return a value. + * Used for detecting "missing return statement" errors. + */ +public final class ControlFlowAnalyzer { + + private ControlFlowAnalyzer() {} // Utility class + + /** + * Check if the method body has a guaranteed return on all code paths. + */ + public static boolean hasGuaranteedReturn(String body) { + String cleanBody = CodeParser.removeStringsAndComments(body); + + return hasTopLevelReturn(cleanBody) + || hasCompleteIfElseReturn(cleanBody) + || hasTryFinallyReturn(cleanBody) + || hasCompleteSwitchReturn(cleanBody); + } + + /** + * Check for a bare return statement at the method's top level (brace depth 0). + */ + private static boolean hasTopLevelReturn(String body) { + int braceDepth = 0; + int parenDepth = 0; + + for (int i = 0; i < body.length(); i++) { + char c = body.charAt(i); + + if (c == '{') braceDepth++; + else if (c == '}') braceDepth--; + else if (c == '(') parenDepth++; + else if (c == ')') parenDepth--; + + if (braceDepth == 0 && parenDepth == 0 && isKeywordAt(body, i, "return")) { + return true; + } + } + return false; + } + + /** + * Check for complete if-else chains where all branches return. + */ + private static boolean hasCompleteIfElseReturn(String body) { + int braceDepth = 0; + + for (int i = 0; i < body.length(); i++) { + char c = body.charAt(i); + if (c == '{') { braceDepth++; continue; } + if (c == '}') { braceDepth--; continue; } + + if (braceDepth == 0 && isKeywordAt(body, i, "if")) { + if (checkIfElseChainReturns(body, i)) { + return true; + } + } + } + return false; + } + + /** + * Check if an if-else chain starting at the given position guarantees a return. + * Handles: if(true) { return; } - always returns (else unreachable) + * if(false) { } else { return; } - else always executes + */ + private static boolean checkIfElseChainReturns(String body, int ifStart) { + int pos = ifStart; + + while (pos < body.length()) { + if (!body.substring(pos).startsWith("if")) return false; + pos += 2; + + pos = skipWhitespace(body, pos); + if (pos >= body.length() || body.charAt(pos) != '(') return false; + + int condStart = pos; + int condEnd = CodeParser.findMatchingParen(body, pos); + if (condEnd < 0) return false; + + // Check for literal true/false conditions + String condition = body.substring(condStart + 1, condEnd).trim(); + boolean isAlwaysTrue = condition.equals("true"); + boolean isAlwaysFalse = condition.equals("false"); + + pos = skipWhitespace(body, condEnd + 1); + if (pos >= body.length() || body.charAt(pos) != '{') return false; + + int blockEnd = CodeParser.findMatchingBrace(body, pos); + if (blockEnd < 0) return false; + + String ifBlock = body.substring(pos + 1, blockEnd); + boolean ifBlockReturns = hasGuaranteedReturn(ifBlock); + + // if(true) with return means we always return (else is unreachable) + if (isAlwaysTrue && ifBlockReturns) { + return true; + } + + pos = skipWhitespace(body, blockEnd + 1); + + // Must have else clause for guaranteed return + if (!body.substring(pos).startsWith("else")) { + return false; + } + pos += 4; + pos = skipWhitespace(body, pos); + + // else if - continue the chain + if (isKeywordAt(body, pos, "if")) { + continue; + } + + // Plain else block - final branch + if (pos >= body.length() || body.charAt(pos) != '{') return false; + int elseBlockEnd = CodeParser.findMatchingBrace(body, pos); + if (elseBlockEnd < 0) return false; + + String elseBlock = body.substring(pos + 1, elseBlockEnd); + boolean elseBlockReturns = hasGuaranteedReturn(elseBlock); + + // For if-else to guarantee return: both branches must return + // Special case: if(false) means if-block never executes, so only else matters + if (isAlwaysFalse) { + return elseBlockReturns; + } + + // Normal case: both if and else must return + return ifBlockReturns && elseBlockReturns; + } + return false; + } + + /** + * Check for try-finally where finally block returns. + */ + private static boolean hasTryFinallyReturn(String body) { + for (int i = 0; i < body.length(); i++) { + if (!isKeywordAt(body, i, "try")) continue; + + int tryBlockStart = body.indexOf('{', i); + if (tryBlockStart < 0) continue; + + int tryBlockEnd = CodeParser.findMatchingBrace(body, tryBlockStart); + if (tryBlockEnd < 0) continue; + + // Skip catch blocks + int searchPos = skipCatchBlocks(body, tryBlockEnd + 1); + searchPos = skipWhitespace(body, searchPos); + + // Check for finally + if (body.substring(searchPos).startsWith("finally")) { + int finallyBlockStart = body.indexOf('{', searchPos); + if (finallyBlockStart >= 0) { + int finallyBlockEnd = CodeParser.findMatchingBrace(body, finallyBlockStart); + if (finallyBlockEnd >= 0) { + String finallyBlock = body.substring(finallyBlockStart + 1, finallyBlockEnd); + if (hasGuaranteedReturn(finallyBlock)) { + return true; + } + } + } + } + } + return false; + } + + private static int skipCatchBlocks(String body, int start) { + int pos = start; + while (true) { + pos = skipWhitespace(body, pos); + if (pos >= body.length() || !body.substring(pos).startsWith("catch")) break; + + int catchBlockStart = body.indexOf('{', pos); + if (catchBlockStart < 0) break; + + int catchBlockEnd = CodeParser.findMatchingBrace(body, catchBlockStart); + if (catchBlockEnd < 0) break; + + pos = catchBlockEnd + 1; + } + return pos; + } + + /** + * Check for switch with default where all cases return. + * Returns false for now - full implementation is complex. + */ + private static boolean hasCompleteSwitchReturn(String body) { + return false; + } + + // ==================== Utility Methods ==================== + + private static boolean isKeywordAt(String text, int pos, String keyword) { + if (pos + keyword.length() > text.length()) return false; + if (!text.substring(pos).startsWith(keyword)) return false; + + boolean validBefore = pos == 0 || !Character.isLetterOrDigit(text.charAt(pos - 1)); + boolean validAfter = pos + keyword.length() >= text.length() + || !Character.isLetterOrDigit(text.charAt(pos + keyword.length())); + + return validBefore && validAfter; + } + + private static int skipWhitespace(String text, int pos) { + while (pos < text.length() && Character.isWhitespace(text.charAt(pos))) { + pos++; + } + return pos; + } +} diff --git a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/MethodInfo.java b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/MethodInfo.java index 9e9ed9039..5614c2fec 100644 --- a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/MethodInfo.java +++ b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/MethodInfo.java @@ -22,6 +22,17 @@ import java.util.Set; */ public final class MethodInfo { + /** + * Validation error types for method declarations. + */ + public enum ErrorType { + NONE, + MISSING_RETURN, // Non-void method missing return statement + RETURN_TYPE_MISMATCH, // Return statement type doesn't match method return type + DUPLICATE_PARAMETER, // Two parameters have the same name + PARAMETER_UNDEFINED // Parameter type cannot be resolved + } + private final String name; private final TypeInfo returnType; private final TypeInfo containingType; // The class/interface that owns this method @@ -36,6 +47,12 @@ public final class MethodInfo { private final int modifiers; // Java Modifier flags (e.g., Modifier.PUBLIC | Modifier.STATIC) private final String documentation; // Javadoc/comment documentation for this method + // Error tracking for method declarations + private ErrorType errorType = ErrorType.NONE; + private String errorMessage; + private List parameterErrors = new ArrayList<>(); + private List returnStatementErrors = new ArrayList<>(); + private MethodInfo(String name, TypeInfo returnType, TypeInfo containingType, List parameters, int fullDeclarationOffset, int typeOffset, int nameOffset, int bodyStart, int bodyEnd, boolean resolved, boolean isDeclaration, @@ -237,4 +254,248 @@ public final class MethodInfo { public int hashCode() { return name.hashCode() * 31 + parameters.size(); } + + // ==================== ERROR HANDLING ==================== + + /** + * Represents an error with a specific parameter. + */ + public static class ParameterError { + private final FieldInfo parameter; + private final int paramIndex; + private final ErrorType errorType; + private final String message; + + public ParameterError(FieldInfo parameter, int paramIndex, ErrorType errorType, String message) { + this.parameter = parameter; + this.paramIndex = paramIndex; + this.errorType = errorType; + this.message = message; + } + + public FieldInfo getParameter() { return parameter; } + public int getParamIndex() { return paramIndex; } + public ErrorType getErrorType() { return errorType; } + public String getMessage() { return message; } + } + + /** + * Represents an error with a return statement (type mismatch). + */ + public static class ReturnStatementError { + private final int startOffset; // Start of "return" keyword (absolute position) + private final int endOffset; // End of return statement (after semicolon) + private final String message; + private final TypeInfo expectedType; + private final TypeInfo actualType; + + public ReturnStatementError(int startOffset, int endOffset, String message, TypeInfo expectedType, TypeInfo actualType) { + this.startOffset = startOffset; + this.endOffset = endOffset; + this.message = message; + this.expectedType = expectedType; + this.actualType = actualType; + } + + public int getStartOffset() { return startOffset; } + public int getEndOffset() { return endOffset; } + public String getMessage() { return message; } + public TypeInfo getExpectedType() { return expectedType; } + public TypeInfo getActualType() { return actualType; } + } + + /** + * Check if this method declaration has any errors. + */ + public boolean hasError() { + return errorType != ErrorType.NONE || !parameterErrors.isEmpty() || !returnStatementErrors.isEmpty(); + } + + /** + * Check if this has a missing return error. + */ + public boolean hasMissingReturnError() { + return errorType == ErrorType.MISSING_RETURN; + } + + /** + * Check if this has parameter errors. + */ + public boolean hasParameterErrors() { + return !parameterErrors.isEmpty(); + } + + /** + * Check if this has return statement type errors. + */ + public boolean hasReturnStatementErrors() { + return !returnStatementErrors.isEmpty(); + } + + public ErrorType getErrorType() { return errorType; } + public String getErrorMessage() { return errorMessage; } + public List getParameterErrors() { return Collections.unmodifiableList(parameterErrors); } + public List getReturnStatementErrors() { return Collections.unmodifiableList(returnStatementErrors); } + + /** + * Set the main error for this method. + */ + public void setError(ErrorType type, String message) { + this.errorType = type; + this.errorMessage = message; + } + + /** + * Add a parameter-specific error. + */ + public void addParameterError(FieldInfo param, int index, ErrorType type, String message) { + parameterErrors.add(new ParameterError(param, index, type, message)); + } + + /** + * Add a return statement error. + */ + public void addReturnStatementError(int startOffset, int endOffset, String message, TypeInfo expectedType, TypeInfo actualType) { + returnStatementErrors.add(new ReturnStatementError(startOffset, endOffset, message, expectedType, actualType)); + } + + /** + * Functional interface for type resolution callback. + */ + @FunctionalInterface + public interface TypeResolver { + TypeInfo resolveExpression(String expression, int position); + } + + /** + * Validate this method declaration. + * Checks for parameter errors and missing return statements. + * + * @param methodBodyText The text content of the method body (between { and }) + */ + public void validate(String methodBodyText) { + validate(methodBodyText, null); + } + + /** + * Validate this method declaration with type resolution for return statements. + * Checks for parameter errors, missing return statements, and return type mismatches. + * + * @param methodBodyText The text content of the method body (between { and }) + * @param typeResolver Optional callback to resolve expression types (for return type checking) + */ + public void validate(String methodBodyText, TypeResolver typeResolver) { + if (!isDeclaration) return; + + // Validate parameters + validateParameters(); + + // Validate return statement types FIRST if we have a type resolver + // This ensures type errors are shown even if there's also a missing return + if (typeResolver != null) { + validateReturnTypes(methodBodyText, typeResolver); + } + + // Validate return statement for non-void methods + // This may set the main error, but return type errors are already recorded + validateReturnStatement(methodBodyText); + } + + /** + * Check for duplicate and unresolved parameters. + */ + private void validateParameters() { + Set seenNames = new HashSet<>(); + + for (int i = 0; i < parameters.size(); i++) { + FieldInfo param = parameters.get(i); + String paramName = param.getName(); + + // Check for duplicate parameter names + if (seenNames.contains(paramName)) { + addParameterError(param, i, ErrorType.DUPLICATE_PARAMETER, + "Duplicate parameter name '" + paramName + "'"); + } else { + seenNames.add(paramName); + } + + // Check for unresolved parameter types + TypeInfo paramType = param.getDeclaredType(); + if (paramType == null || !paramType.isResolved()) { + String typeName = paramType != null ? paramType.getSimpleName() : "unknown"; + addParameterError(param, i, ErrorType.PARAMETER_UNDEFINED, + "Cannot resolve parameter type '" + typeName + "'"); + } + } + } + + /** + * Validate that a non-void method has a guaranteed return statement. + * Delegates to {@link ControlFlowAnalyzer} for control flow analysis. + */ + private void validateReturnStatement(String bodyText) { + if (bodyText == null || bodyText.isEmpty()) return; + + // void methods don't need return statements + if (TypeChecker.isVoidType(returnType)) return; + + // Abstract/native methods don't have bodies + if (isAbstract() || isNative()) return; + + // Check if the method has a guaranteed return using the control flow analyzer + if (!ControlFlowAnalyzer.hasGuaranteedReturn(bodyText)) { + setError(ErrorType.MISSING_RETURN, "Missing return statement"); + } + } + + /** + * Validate that all return statements have compatible types. + * Delegates to {@link CodeParser} for parsing and {@link TypeChecker} for type checks. + */ + private void validateReturnTypes(String bodyText, TypeResolver typeResolver) { + if (bodyText == null || bodyText.isEmpty()) return; + if (TypeChecker.isVoidType(returnType)) return; + + String expectedTypeName = returnType.getSimpleName(); + + // Remove comments but keep strings (we need accurate positions) + String cleanBody = CodeParser.removeComments(bodyText); + + // Find all return statements + int pos = 0; + while (pos < cleanBody.length()) { + // Look for "return" keyword + int returnPos = CodeParser.findReturnKeyword(cleanBody, pos); + if (returnPos < 0) break; + + // Find the semicolon ending this return statement + int semiPos = CodeParser.findReturnSemicolon(cleanBody, returnPos + 6); + if (semiPos < 0) { + pos = returnPos + 6; + continue; + } + + // Extract the return expression + String returnExpr = cleanBody.substring(returnPos + 6, semiPos).trim(); + + // Skip empty returns (handled by missing return check) + if (!returnExpr.isEmpty()) { + // Calculate absolute position (bodyStart + 1 is after the opening brace) + int absoluteReturnStart = bodyStart + 1 + returnPos; + int absoluteSemiEnd = bodyStart + 1 + semiPos + 1; // +1 to include semicolon + + // Resolve the expression type + TypeInfo actualType = typeResolver.resolveExpression(returnExpr, absoluteReturnStart); + + // Check type compatibility + if (!TypeChecker.isTypeCompatible(returnType, actualType)) { + String actualTypeName = actualType != null ? actualType.getSimpleName() : "null"; + String message = "Incompatible types.\nRequired: " + expectedTypeName + "\nFound: " + actualTypeName; + addReturnStatementError(absoluteReturnStart, absoluteSemiEnd, message, returnType, actualType); + } + } + + pos = semiPos + 1; + } + } } diff --git a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/ScriptLine.java b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/ScriptLine.java index ae5f6d0ab..455465c2c 100644 --- a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/ScriptLine.java +++ b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/ScriptLine.java @@ -450,6 +450,126 @@ public class ScriptLine { drawCurlyUnderline(lineStartX + beforeWidth, baselineY, underlineWidth, 0xFF5555); } } + + // Check all method declarations for errors (missing return, parameter errors, return type errors) + for (MethodInfo method : doc.getMethods()) { + if (!method.isDeclaration() || !method.hasError()) + continue; + + // Handle return statement type errors (underline the entire return statement) + if (method.hasReturnStatementErrors()) { + for (MethodInfo.ReturnStatementError returnError : method.getReturnStatementErrors()) { + int returnStart = returnError.getStartOffset(); + int returnEnd = returnError.getEndOffset(); + + //error 247, 258 + //line 235, 259 + // Skip if return statement doesn't intersect this line + if (returnEnd < lineStart || returnStart > lineEnd) + continue; + + // Clip to line boundaries + int clipStart = Math.max(returnStart, lineStart); + int clipEnd = Math.min(returnEnd, lineEnd); + + if (clipStart >= clipEnd) + continue; + + // Convert to line-local coordinates + int lineLocalStart = clipStart - lineStart; + int lineLocalEnd = clipEnd - lineStart; + + // Bounds check + if (lineLocalStart < 0 || lineLocalStart >= lineText.length()) + continue; + + // Compute pixel position + String beforeReturn = lineText.substring(0, lineLocalStart); + int beforeWidth = ClientProxy.Font.width(beforeReturn); + + int returnWidth; + if (lineLocalEnd > lineText.length()) { + returnWidth = ClientProxy.Font.width(lineText.substring(lineLocalStart)); + } else { + String returnTextOnLine = lineText.substring(lineLocalStart, lineLocalEnd); + returnWidth = ClientProxy.Font.width(returnTextOnLine); + } + + if (returnWidth > 0) { + drawCurlyUnderline(lineStartX + beforeWidth, baselineY, returnWidth, 0xFF5555); + } + } + } + + // Handle missing return error (underline the method name) + if (method.hasMissingReturnError()) { + int methodNameStart = method.getNameOffset(); + int methodNameEnd = methodNameStart + method.getName().length(); + + // Skip if method name doesn't intersect this line + if (methodNameEnd < lineStart || methodNameStart > lineEnd) + continue; + + // Check if method name is on this line + if (methodNameStart >= lineStart && methodNameStart < lineEnd) { + int lineLocalStart = methodNameStart - lineStart; + if (lineLocalStart >= 0 && lineLocalStart < lineText.length()) { + String beforeMethod = lineText.substring(0, lineLocalStart); + int beforeWidth = ClientProxy.Font.width(beforeMethod); + int methodWidth = ClientProxy.Font.width(method.getName()); + drawCurlyUnderline(lineStartX + beforeWidth, baselineY, methodWidth, 0xFF5555); + } + } + } + + // Handle parameter errors (underline specific parameters) + if (method.hasParameterErrors()) { + for (MethodInfo.ParameterError paramError : method.getParameterErrors()) { + FieldInfo param = paramError.getParameter(); + if (param == null || param.getDeclarationOffset() < 0) + continue; + + int paramStart = param.getDeclarationOffset(); + int paramEnd = paramStart + param.getName().length(); + + // Skip if parameter doesn't intersect this line + if (paramEnd < lineStart || paramStart > lineEnd) + continue; + + // Clip to line boundaries + int clipStart = Math.max(paramStart, lineStart); + int clipEnd = Math.min(paramEnd, lineEnd); + + if (clipStart >= clipEnd) + continue; + + // Convert to line-local coordinates + int lineLocalStart = clipStart - lineStart; + int lineLocalEnd = clipEnd - lineStart; + + // Bounds check + if (lineLocalStart < 0 || lineLocalStart >= lineText.length()) + continue; + + // Compute pixel position + String beforeParam = lineText.substring(0, lineLocalStart); + int beforeWidth = ClientProxy.Font.width(beforeParam); + + int paramWidth; + if (lineLocalEnd > lineText.length()) { + paramWidth = ClientProxy.Font.width(lineText.substring(lineLocalStart)); + } else { + String paramTextOnLine = lineText.substring(lineLocalStart, lineLocalEnd); + paramWidth = ClientProxy.Font.width(paramTextOnLine); + } + + if (paramWidth > 0) { + drawCurlyUnderline(lineStartX + beforeWidth, baselineY, paramWidth, 0xFF5555); + } + } + } + + } } /** diff --git a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/TypeChecker.java b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/TypeChecker.java new file mode 100644 index 000000000..8e63507ae --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/TypeChecker.java @@ -0,0 +1,161 @@ +package noppes.npcs.client.gui.util.script.interpreter; + +/** + * Utility class for checking type compatibility between types. + * Handles primitive widening conversions, boxing/unboxing, and inheritance. + */ +public final class TypeChecker { + + private TypeChecker() {} // Utility class + + /** + * Check if the actual type is compatible with (assignable to) the expected type. + * @param expected The expected/target type + * @param actual The actual/source type + * @return true if actual can be assigned to expected + */ + public static boolean isTypeCompatible(TypeInfo expected, TypeInfo actual) { + if (expected == null) return true; // void can accept anything (shouldn't happen) + if (actual == null) return true; // null is compatible with any reference type + + String expectedName = expected.getSimpleName(); + String actualName = actual.getSimpleName(); + + if (expectedName == null || actualName == null) return true; + + // Exact match + if (expectedName.equals(actualName)) return true; + + // Primitive widening conversions + if (isNumericType(expectedName) && isNumericType(actualName)) { + return canWiden(actualName, expectedName); + } + + // Object type compatibility (check inheritance) + if (expected.getJavaClass() != null && actual.getJavaClass() != null) { + return expected.getJavaClass().isAssignableFrom(actual.getJavaClass()); + } + + // Allow boxed/unboxed conversions + if (isPrimitiveOrWrapper(expectedName) && isPrimitiveOrWrapper(actualName)) { + return getUnboxedName(expectedName).equals(getUnboxedName(actualName)); + } + + return false; + } + + /** + * Check if the type is a numeric type (including wrappers). + */ + public static boolean isNumericType(String typeName) { + switch (typeName) { + case "byte": case "Byte": + case "short": case "Short": + case "int": case "Integer": + case "long": case "Long": + case "float": case "Float": + case "double": case "Double": + case "char": case "Character": + return true; + default: + return false; + } + } + + /** + * Check if a primitive type can be widened to another type. + * Follows Java's widening primitive conversion rules. + */ + public static boolean canWiden(String from, String to) { + int fromRank = getNumericRank(from); + int toRank = getNumericRank(to); + return fromRank <= toRank; + } + + /** + * Get the numeric rank for primitive widening conversion. + * Higher rank can accept lower ranks. + */ + public static int getNumericRank(String typeName) { + switch (getUnboxedName(typeName)) { + case "byte": return 1; + case "short": case "char": return 2; + case "int": return 3; + case "long": return 4; + case "float": return 5; + case "double": return 6; + default: return 0; + } + } + + /** + * Check if the type name is a primitive or its wrapper type. + */ + public static boolean isPrimitiveOrWrapper(String typeName) { + switch (typeName) { + case "byte": case "Byte": + case "short": case "Short": + case "int": case "Integer": + case "long": case "Long": + case "float": case "Float": + case "double": case "Double": + case "char": case "Character": + case "boolean": case "Boolean": + return true; + default: + return false; + } + } + + /** + * Get the primitive type name from a wrapper type name. + * Returns the input unchanged if already primitive or not a wrapper. + */ + public static String getUnboxedName(String typeName) { + switch (typeName) { + case "Byte": return "byte"; + case "Short": return "short"; + case "Integer": return "int"; + case "Long": return "long"; + case "Float": return "float"; + case "Double": return "double"; + case "Character": return "char"; + case "Boolean": return "boolean"; + default: return typeName; + } + } + + /** + * Get the wrapper type name from a primitive type name. + * Returns the input unchanged if already a wrapper or not a primitive. + */ + public static String getBoxedName(String typeName) { + switch (typeName) { + case "byte": return "Byte"; + case "short": return "Short"; + case "int": return "Integer"; + case "long": return "Long"; + case "float": return "Float"; + case "double": return "Double"; + case "char": return "Character"; + case "boolean": return "Boolean"; + default: return typeName; + } + } + + /** + * Check if the type name represents a void type. + */ + public static boolean isVoidType(String typeName) { + return typeName == null || typeName.equals("void") || typeName.equals("Void"); + } + + /** + * Check if the type represents a void type. + */ + public static boolean isVoidType(TypeInfo type) { + if (type == null) return true; + return isVoidType(type.getSimpleName()); + } +} + diff --git a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/hover/TokenHoverInfo.java b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/hover/TokenHoverInfo.java index 9f342c9d6..1559a3b04 100644 --- a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/hover/TokenHoverInfo.java +++ b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/hover/TokenHoverInfo.java @@ -204,6 +204,50 @@ public class TokenHoverInfo { if (fieldInfo != null && !fieldInfo.isResolved()) { errors.add("Cannot resolve symbol '" + token.getText() + "'"); } + + // Show method declaration errors (missing return, parameter errors, return type errors) + MethodInfo methodDecl = findMethodDeclarationContainingPosition(token); + if (methodDecl != null && methodDecl.hasError()) { + int tokenStart = token.getGlobalStart(); + int tokenEnd = token.getGlobalEnd(); + + // If hovering over the method name, show missing return error + if (methodDecl.hasMissingReturnError()) { + int methodNameStart = methodDecl.getNameOffset(); + int methodNameEnd = methodNameStart + methodDecl.getName().length(); + + if (tokenStart >= methodNameStart && tokenEnd <= methodNameEnd) { + errors.add(methodDecl.getErrorMessage()); + } + } + + // If hovering over a parameter with an error, show that error + if (methodDecl.hasParameterErrors()) { + for (MethodInfo.ParameterError paramError : methodDecl.getParameterErrors()) { + FieldInfo param = paramError.getParameter(); + if (param != null && param.getDeclarationOffset() >= 0) { + int paramStart = param.getDeclarationOffset(); + int paramEnd = paramStart + param.getName().length(); + + if (tokenStart >= paramStart && tokenEnd <= paramEnd) { + errors.add(paramError.getMessage()); + } + } + } + } + + // If hovering over a return statement with a type error, show that error + if (methodDecl.hasReturnStatementErrors()) { + for (MethodInfo.ReturnStatementError returnError : methodDecl.getReturnStatementErrors()) { + int returnStart = returnError.getStartOffset(); + int returnEnd = returnError.getEndOffset(); + + if (tokenStart >= returnStart && tokenEnd <= returnEnd) { + errors.add(returnError.getMessage()); + } + } + } + } } /** @@ -247,6 +291,36 @@ public class TokenHoverInfo { } return null; } + + /** + * Find the method declaration that contains this token's position. + * Returns null if the token is not within a method declaration (header or body). + */ + private MethodInfo findMethodDeclarationContainingPosition(Token token) { + ScriptLine line = token.getParentLine(); + if (line == null || line.getParent() == null) { + return null; + } + + ScriptDocument doc = line.getParent(); + int tokenStart = token.getGlobalStart(); + + for (MethodInfo method : doc.getMethods()) { + if (!method.isDeclaration()) + continue; + + // Check if token is within the method declaration header OR body + int methodStart = method.getFullDeclarationOffset(); + if (methodStart < 0) methodStart = method.getTypeOffset(); + int bodyEnd = method.getBodyEnd(); + + // Token is within the method (header + body) + if (tokenStart >= methodStart && tokenStart <= bodyEnd) { + return method; + } + } + return null; + } /** * Find the argument that contains the given position.