From f01bc0a117274fdc93506fba8794d17ca60da882 Mon Sep 17 00:00:00 2001 From: bigguy345 Date: Sat, 7 Mar 2026 15:56:37 +0200 Subject: [PATCH] Object Literals AKA 'dict' Phase #1: Fields --- .../interpreter/ObjectLiteralParser.java | 814 ++++++++++++++++++ .../script/interpreter/ScriptDocument.java | 364 +++++++- .../script/interpreter/type/TypeChecker.java | 2 +- .../script/interpreter/type/TypeInfo.java | 35 +- 4 files changed, 1166 insertions(+), 49 deletions(-) create mode 100644 src/main/java/noppes/npcs/client/gui/util/script/interpreter/ObjectLiteralParser.java diff --git a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/ObjectLiteralParser.java b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/ObjectLiteralParser.java new file mode 100644 index 000000000..8be088cc5 --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/ObjectLiteralParser.java @@ -0,0 +1,814 @@ +package noppes.npcs.client.gui.util.script.interpreter; + +import noppes.npcs.client.gui.util.script.interpreter.field.FieldInfo; +import noppes.npcs.client.gui.util.script.interpreter.js_parser.JSTypeInfo; +import noppes.npcs.client.gui.util.script.interpreter.type.TypeInfo; + +import java.util.ArrayList; +import java.util.List; + +/** + * Helper class for JavaScript object literal (AKA dicts) analysis, parsing, type-shape inference, + * and dynamic property access handling. + * + *

Purpose

+ * Encapsulates the logic for: + * + * + *

V1 Scope

+ * + * + *

Degradation Behavior

+ * When the parser encounters unsupported syntax (computed keys, spread, deeply nested literals), + * it sets {@link ObjectLiteralAnalysis#supportsInference} to {@code false} and returns partial + * property data. Callers (e.g. mark building) can still use the parsed properties for key highlighting + * even when full type inference is unavailable. + * + *

Architecture Note

+ * Methods that need access to ScriptDocument's type resolution ({@code resolveExpressionType}, + * {@code resolveVariable}) accept functional interfaces ({@link ExpressionTypeResolverFn}, + * {@link VariableResolverFn}) so that this class remains decoupled from ScriptDocument's internals. + * ScriptDocument retains ownership of {@code markObjectLiteralKeys} and + * {@code findMatchingBraceEndInDocument} which need direct access to document state (excluded ranges, text). + */ +public final class ObjectLiteralParser { + + private ObjectLiteralParser() { + // Static utility class — not instantiable + } + + // ────────────────────────────────────────────────────────────────────────── + // Functional interfaces for ScriptDocument callbacks + // ────────────────────────────────────────────────────────────────────────── + + /** + * Callback to resolve an expression's type given the expression text and its absolute position + * within the document. Delegates to {@code ScriptDocument.resolveExpressionType(expr, position)}. + */ + @FunctionalInterface + public interface ExpressionTypeResolverFn { + TypeInfo resolve(String expr, int position); + } + + /** + * Callback to resolve a variable by name at a given document position. + * Delegates to {@code ScriptDocument.resolveVariable(name, position)}. + */ + @FunctionalInterface + public interface VariableResolverFn { + FieldInfo resolve(String name, int position); + } + + // ────────────────────────────────────────────────────────────────────────── + // Data classes + // ────────────────────────────────────────────────────────────────────────── + + /** + * Result of analyzing an object literal expression. + * + *

Contains the parsed properties, whether full type inference is feasible, + * and (when inference succeeded) the synthetic {@link TypeInfo} representing the object's shape.

+ */ + public static final class ObjectLiteralAnalysis { + /** Parsed properties (key name, offsets, optional inferred value type). */ + public final List properties; + /** + * {@code true} when every property was fully parseable and the closing brace was found, + * meaning a synthetic type shape can be constructed. {@code false} when unsupported syntax + * was encountered or the literal is incomplete. + */ + public final boolean supportsInference; + /** + * The synthetic {@link TypeInfo} representing this object literal's shape, or {@code null} + * when inference was not requested or not supported. + */ + public final TypeInfo inferredType; + + ObjectLiteralAnalysis(List properties, boolean supportsInference, TypeInfo inferredType) { + this.properties = properties; + this.supportsInference = supportsInference; + this.inferredType = inferredType; + } + } + + /** + * A single property inside an object literal. + * + *

Tracks the key name, whether the key was written as a bare identifier or a quoted string, + * the absolute document offsets for both key and value spans, and the inferred value type + * (when type inference was requested).

+ */ + public static final class ObjectLiteralProperty { + /** The property key name (without quotes if originally quoted). */ + public final String keyName; + /** {@code true} if the key was a bare identifier; {@code false} if it was a quoted string. */ + public final boolean isIdentifierKey; + /** Absolute start offset of the key in the document. */ + public final int keyStartAbs; + /** Absolute end offset of the key in the document. */ + public final int keyEndAbs; + /** Absolute start offset of the value expression in the document. */ + public final int valueStartAbs; + /** Absolute end offset of the value expression in the document. */ + public final int valueEndAbs; + /** The inferred type of the value expression, or {@code null} if inference was not requested. */ + public final TypeInfo valueType; + + ObjectLiteralProperty(String keyName, boolean isIdentifierKey, int keyStartAbs, int keyEndAbs, + int valueStartAbs, int valueEndAbs, TypeInfo valueType) { + this.keyName = keyName; + this.isIdentifierKey = isIdentifierKey; + this.keyStartAbs = keyStartAbs; + this.keyEndAbs = keyEndAbs; + this.valueStartAbs = valueStartAbs; + this.valueEndAbs = valueEndAbs; + this.valueType = valueType; + } + } + + /** + * Represents a dynamic property access on the LHS of an assignment. + * + *

Two forms are supported:

+ *
    + *
  • Dot access: {@code obj.prop} → {@code receiverExpr="obj", propertyName="prop", bracketAccess=false}
  • + *
  • Bracket access: {@code obj["prop"]} → {@code receiverExpr="obj", propertyName="prop", bracketAccess=true}
  • + *
+ */ + public static final class DynamicPropertyAccess { + /** The expression for the receiver object (everything before the dot or bracket). */ + public final String receiverExpr; + /** The property name being accessed. */ + public final String propertyName; + /** Absolute document offset where the receiver expression starts. */ + public final int receiverStart; + /** {@code true} if this is bracket notation ({@code obj["prop"]}); {@code false} for dot notation. */ + public final boolean bracketAccess; + + DynamicPropertyAccess(String receiverExpr, String propertyName, int receiverStart, boolean bracketAccess) { + this.receiverExpr = receiverExpr; + this.propertyName = propertyName; + this.receiverStart = receiverStart; + this.bracketAccess = bracketAccess; + } + } + + // ────────────────────────────────────────────────────────────────────────── + // Object literal analysis + // ────────────────────────────────────────────────────────────────────────── + + /** + * Analyze an object literal string and extract its properties. + * + *

The input is expected to start with {@code '{'} (after optional leading whitespace) and + * may or may not be a complete literal. Properties are parsed into {@link ObjectLiteralProperty} + * records. When {@code inferTypes} is {@code true} and inference is feasible, a synthetic + * {@link TypeInfo} shape is constructed with fields matching the parsed properties.

+ * + * @param objectLiteral the source text of the object literal (starting at or near '{') + * @param baseOffset the absolute document offset corresponding to the start of {@code objectLiteral} + * @param inferTypes if {@code true}, attempt to resolve value types and build a synthetic shape + * @param assumeComplete if {@code true}, treat the entire string as a complete literal (no brace matching needed) + * @param typeResolver callback for resolving value expression types (may be {@code null} if {@code inferTypes} is false) + * @return the analysis result, or {@code null} if the input is not a valid object literal start + */ + public static ObjectLiteralAnalysis parse(String objectLiteral, int baseOffset, + boolean inferTypes, boolean assumeComplete, + ExpressionTypeResolverFn typeResolver) { + if (objectLiteral == null) { + return null; + } + + int leading = 0; + while (leading < objectLiteral.length() && Character.isWhitespace(objectLiteral.charAt(leading))) { + leading++; + } + int trailing = objectLiteral.length(); + while (trailing > leading && Character.isWhitespace(objectLiteral.charAt(trailing - 1))) { + trailing--; + } + + String src = objectLiteral.substring(leading, trailing); + int absBase = baseOffset + leading; + + if (src.isEmpty() || src.charAt(0) != '{') { + return null; + } + + int endExclusive; + if (assumeComplete) { + endExclusive = src.length(); + } else { + endExclusive = findMatchingObjectLiteralEnd(src); + if (endExclusive <= 0) { + return null; + } + } + + boolean supportsInference = (endExclusive == src.length()); + int bodyStart = 1; + int bodyEnd = endExclusive - 1; + + List props = new ArrayList<>(); + boolean sawUnsupported = false; + + int i = bodyStart; + while (i < bodyEnd) { + while (i < bodyEnd) { + char c = src.charAt(i); + if (Character.isWhitespace(c) || c == ',') { + i++; + continue; + } + break; + } + if (i >= bodyEnd) { + break; + } + + int keyStart = i; + String keyName = null; + boolean isIdentifierKey = false; + + char kc = src.charAt(i); + if (kc == '"' || kc == '\'') { + int keyEnd = scanStringLiteralEnd(src, i); + if (keyEnd < 0 || keyEnd > bodyEnd) { + sawUnsupported = true; + break; + } + keyName = src.substring(i + 1, keyEnd - 1); + isIdentifierKey = false; + if (!isSimpleIdentifier(keyName)) { + sawUnsupported = true; + i = scanToNextTopLevelComma(src, keyEnd, bodyEnd); + continue; + } + i = keyEnd; + } else if (Character.isJavaIdentifierStart(kc)) { + i++; + while (i < bodyEnd && Character.isJavaIdentifierPart(src.charAt(i))) { + i++; + } + keyName = src.substring(keyStart, i); + isIdentifierKey = true; + } else if (kc == '[') { + // Computed key — unsupported for inference, skip to next property + sawUnsupported = true; + i = scanToNextTopLevelComma(src, i, bodyEnd); + continue; + } else if (kc == '.' && i + 2 < bodyEnd && src.charAt(i + 1) == '.' && src.charAt(i + 2) == '.') { + // Spread syntax — unsupported, skip + sawUnsupported = true; + i = scanToNextTopLevelComma(src, i, bodyEnd); + continue; + } else { + sawUnsupported = true; + i = scanToNextTopLevelComma(src, i, bodyEnd); + continue; + } + + int keyEnd = i; + while (i < bodyEnd && Character.isWhitespace(src.charAt(i))) { + i++; + } + + if (i >= bodyEnd || src.charAt(i) != ':') { + // Shorthand property or invalid — skip + sawUnsupported = true; + i = scanToNextTopLevelComma(src, i, bodyEnd); + continue; + } + i++; // skip ':' + + while (i < bodyEnd && Character.isWhitespace(src.charAt(i))) { + i++; + } + int valueStart = i; + int valueEnd = scanTopLevelValueEnd(src, valueStart, bodyEnd); + if (valueEnd < valueStart) { + sawUnsupported = true; + break; + } + + TypeInfo valueType = null; + if (inferTypes && typeResolver != null) { + String valueExpr = src.substring(valueStart, valueEnd).trim(); + if (!valueExpr.isEmpty()) { + char vc = valueExpr.charAt(0); + if (vc == '{' || vc == '[') { + // Nested object/array literals degrade to 'any' + valueType = TypeInfo.ANY; + } else { + TypeInfo inferred = typeResolver.resolve(valueExpr, absBase + valueStart); + valueType = (inferred != null) ? inferred : TypeInfo.ANY; + } + } else { + valueType = TypeInfo.ANY; + } + } + + int keyStartAbs = absBase + keyStart; + int keyEndAbs = absBase + keyEnd; + int valueStartAbs = absBase + valueStart; + int valueEndAbs = absBase + valueEnd; + + if (keyName != null) { + props.add(new ObjectLiteralProperty(keyName, isIdentifierKey, keyStartAbs, keyEndAbs, + valueStartAbs, valueEndAbs, valueType)); + } + + i = valueEnd; + while (i < bodyEnd && Character.isWhitespace(src.charAt(i))) { + i++; + } + if (i < bodyEnd && src.charAt(i) == ',') { + i++; + } + } + + if (sawUnsupported) { + supportsInference = false; + } + + TypeInfo inferredType = null; + if (inferTypes && supportsInference) { + inferredType = TypeInfo.objectLiteral(props); + } + + return new ObjectLiteralAnalysis(props, supportsInference, inferredType); + } + + // ────────────────────────────────────────────────────────────────────────── + // Synthetic object literal display name detection + // ────────────────────────────────────────────────────────────────────────── + + /** + * Determine whether a {@link JSTypeInfo} represents a synthetic object literal type, + * and if so return its display name ({@code "Object"}). + * + *

Synthetic object literal types are created by {@link #parse} with + * names of the form {@code __ObjectLiteral$} under the {@code __synthetic__} namespace. + * This method recognizes that naming convention and maps it to a human-readable display name.

+ * + * @param jsType the JS type info to inspect (may be {@code null}) + * @return {@code "Object"} if this is a synthetic object literal type, {@code null} otherwise + */ + public static String getSyntheticObjectLiteralDisplayName(JSTypeInfo jsType) { + if (jsType == null) { + return null; + } + String namespace = jsType.getNamespace(); + String simple = jsType.getSimpleName(); + if ("__synthetic__".equals(namespace) && simple != null && simple.startsWith("__ObjectLiteral$")) { + return "Object"; + } + return null; + } + + // ────────────────────────────────────────────────────────────────────────── + // Dynamic property access parsing + // ────────────────────────────────────────────────────────────────────────── + + /** + * Parse a left-hand-side expression to detect dynamic property access patterns. + * + *

Recognizes two forms:

+ *
    + *
  • Dot access: {@code obj.prop} — the last dot-separated segment is treated as the property name.
  • + *
  • Bracket access: {@code obj["prop"]} or {@code obj['prop']} — the quoted string inside brackets + * is treated as the property name.
  • + *
+ * + *

Only simple identifier property names are accepted. Complex expressions (e.g., {@code obj[expr]} + * where {@code expr} is not a string literal) return {@code null}.

+ * + * @param lhs the left-hand-side expression text + * @param lhsStart the absolute document offset of the LHS start + * @return a {@link DynamicPropertyAccess} if a valid pattern was detected, {@code null} otherwise + */ + public static DynamicPropertyAccess parseDynamicPropertyAccess(String lhs, int lhsStart) { + if (lhs == null || lhs.isEmpty()) { + return null; + } + + int dotIndex = lhs.lastIndexOf('.'); + int bracketIndex = lhs.lastIndexOf('['); + + if (dotIndex >= 0 && dotIndex > bracketIndex) { + String receiverExpr = lhs.substring(0, dotIndex).trim(); + String propertyName = lhs.substring(dotIndex + 1).trim(); + if (!receiverExpr.isEmpty() && isSimpleIdentifier(propertyName)) { + return new DynamicPropertyAccess(receiverExpr, propertyName, lhsStart, false); + } + return null; + } + + if (bracketIndex >= 0 && lhs.endsWith("]")) { + String receiverExpr = lhs.substring(0, bracketIndex).trim(); + String keyExpr = lhs.substring(bracketIndex + 1, lhs.length() - 1).trim(); + String propertyName = extractQuotedPropertyName(keyExpr); + if (!receiverExpr.isEmpty() && propertyName != null && isSimpleIdentifier(propertyName)) { + return new DynamicPropertyAccess(receiverExpr, propertyName, lhsStart, true); + } + } + + return null; + } + + /** + * Resolve the receiver type for a dynamic property access by first trying expression type resolution + * and then falling back to variable resolution. + * + * @param access the dynamic property access to resolve + * @param typeResolver callback for expression type resolution + * @param variableResolver callback for variable resolution + * @return the resolved receiver {@link TypeInfo}, or {@code null} if resolution failed + */ + public static TypeInfo resolveDynamicPropertyReceiverType(DynamicPropertyAccess access, + ExpressionTypeResolverFn typeResolver, + VariableResolverFn variableResolver) { + if (access == null) { + return null; + } + + TypeInfo receiverType = typeResolver.resolve(access.receiverExpr, access.receiverStart); + if (receiverType != null && receiverType.isResolved()) { + return receiverType; + } + + FieldInfo receiverField = variableResolver.resolve(access.receiverExpr, access.receiverStart); + if (receiverField != null) { + return receiverField.getTypeInfo(); + } + + return null; + } + + /** + * Extract a property name from a quoted string expression. + * + *

Handles both single and double quotes, plus basic backslash escapes. + * Returns {@code null} if the expression is not a properly quoted string.

+ * + * @param expr the expression to extract from (e.g., {@code "prop"} or {@code 'prop'}) + * @return the unquoted, unescaped property name, or {@code null} if extraction failed + */ + public static String extractQuotedPropertyName(String expr) { + if (expr == null || expr.length() < 2) { + return null; + } + char quote = expr.charAt(0); + if ((quote != '"' && quote != '\'') || expr.charAt(expr.length() - 1) != quote) { + return null; + } + + StringBuilder sb = new StringBuilder(); + for (int i = 1; i < expr.length() - 1; i++) { + char c = expr.charAt(i); + if (c == '\\') { + if (i + 1 >= expr.length() - 1) { + return null; + } + char escaped = expr.charAt(++i); + sb.append(escaped); + continue; + } + sb.append(c); + } + return sb.toString(); + } + + /** + * Extend a synthetic object literal shape with a new property (or update an existing one). + * + *

This is called when an assignment like {@code obj.newProp = value} is encountered and + * {@code obj} was previously inferred as a synthetic object literal type. The new property + * is added to the underlying {@link JSTypeInfo} so subsequent hover/autocomplete sees it.

+ * + *

Only operates on types that are recognized as synthetic object literals + * (see {@link #getSyntheticObjectLiteralDisplayName}). Returns {@code false} and does nothing + * if the receiver type is not a synthetic object literal.

+ * + * @param receiverType the type of the receiver object + * @param propertyName the name of the property to add/update + * @param propertyType the inferred type of the property value (defaults to {@link TypeInfo#ANY} if null/unresolved) + * @return {@code true} if the shape was extended/updated, {@code false} if the receiver is not a synthetic object literal + */ + public static boolean extendSyntheticObjectShape(TypeInfo receiverType, String propertyName, TypeInfo propertyType) { + if (receiverType == null || propertyName == null || propertyName.isEmpty()) { + return false; + } + if (!receiverType.isSyntheticObjectLiteralType()) { + return false; + } + TypeInfo safeType = (propertyType != null && propertyType.isResolved()) ? propertyType : TypeInfo.ANY; + receiverType.addOrUpdateSyntheticField(propertyName, safeType); + return true; + } + + public static final class DynamicFieldResult { + public final TypeInfo receiverType; + public final String propertyName; + public final FieldInfo field; + + DynamicFieldResult(TypeInfo receiverType, String propertyName, FieldInfo field) { + this.receiverType = receiverType; + this.propertyName = propertyName; + this.field = field; + } + } + + public static DynamicFieldResult resolveExistingField(DynamicPropertyAccess access, + ExpressionTypeResolverFn typeResolver, VariableResolverFn varResolver) { + TypeInfo receiver = resolveDynamicPropertyReceiverType(access, typeResolver, varResolver); + FieldInfo field = (receiver != null && receiver.isResolved()) ? receiver.getFieldInfo(access.propertyName) : null; + return new DynamicFieldResult(receiver, access.propertyName, field); + } + + public static DynamicFieldResult extendAndGetField(DynamicPropertyAccess access, + TypeInfo existingReceiver, TypeInfo sourceType, + ExpressionTypeResolverFn typeResolver, VariableResolverFn varResolver) { + TypeInfo receiver = (existingReceiver != null && existingReceiver.isResolved()) + ? existingReceiver + : resolveDynamicPropertyReceiverType(access, typeResolver, varResolver); + if (!extendSyntheticObjectShape(receiver, access.propertyName, sourceType)) return null; + return new DynamicFieldResult(receiver, access.propertyName, receiver.getFieldInfo(access.propertyName)); + } + + // ────────────────────────────────────────────────────────────────────────── + // Scanning / parsing utilities (package-private static, used by analyzer) + // ────────────────────────────────────────────────────────────────────────── + + /** + * Scan to the end of a string literal starting at {@code start}. + * + *

Handles backslash escapes. The returned index points one past the closing quote character.

+ * + * @param src the source text + * @param start index of the opening quote character + * @return index one past the closing quote, or {@code -1} if the string is unterminated + */ + static int scanStringLiteralEnd(String src, int start) { + if (start < 0 || start >= src.length()) { + return -1; + } + char quote = src.charAt(start); + int i = start + 1; + while (i < src.length()) { + char c = src.charAt(i); + if (c == '\\') { + i += (i + 1 < src.length()) ? 2 : 1; + continue; + } + if (c == quote) { + return i + 1; + } + i++; + } + return -1; + } + + /** + * Check whether a string is a valid simple identifier (Java identifier rules). + * + * @param s the string to check + * @return {@code true} if {@code s} is non-empty and consists of valid Java identifier characters + */ + static boolean isSimpleIdentifier(String s) { + if (s == null || s.isEmpty()) { + return false; + } + if (!Character.isJavaIdentifierStart(s.charAt(0))) { + return false; + } + for (int i = 1; i < s.length(); i++) { + if (!Character.isJavaIdentifierPart(s.charAt(i))) { + return false; + } + } + return true; + } + + /** + * Scan forward to find the end of a top-level value expression within an object literal. + * + *

Respects nested braces, brackets, parentheses, string literals, and comments. + * Stops at a top-level comma or the {@code endLimit}.

+ * + * @param src the source text + * @param start where to start scanning + * @param endLimit the exclusive boundary (typically the position of the closing brace) + * @return the index where the value expression ends (at a comma or {@code endLimit}) + */ + static int scanTopLevelValueEnd(String src, int start, int endLimit) { + int parenDepth = 0; + int bracketDepth = 0; + int braceDepth = 0; + + boolean inString = false; + char stringChar = 0; + boolean inLineComment = false; + boolean inBlockComment = false; + + int i = start; + while (i < endLimit) { + char c = src.charAt(i); + char next = (i + 1 < endLimit) ? src.charAt(i + 1) : 0; + + if (inLineComment) { + if (c == '\n' || c == '\r') { + inLineComment = false; + } + i++; + continue; + } + + if (inBlockComment) { + if (c == '*' && next == '/') { + inBlockComment = false; + i += 2; + continue; + } + i++; + continue; + } + + if (inString) { + if (c == '\\') { + i += (i + 1 < endLimit) ? 2 : 1; + continue; + } + if (c == stringChar) { + inString = false; + } + i++; + continue; + } + + if (c == '/' && next == '/') { + inLineComment = true; + i += 2; + continue; + } + if (c == '/' && next == '*') { + inBlockComment = true; + i += 2; + continue; + } + + if (c == '"' || c == '\'') { + inString = true; + stringChar = c; + i++; + continue; + } + + if (c == '(') parenDepth++; + else if (c == ')') parenDepth = Math.max(0, parenDepth - 1); + else if (c == '[') bracketDepth++; + else if (c == ']') bracketDepth = Math.max(0, bracketDepth - 1); + else if (c == '{') braceDepth++; + else if (c == '}') braceDepth = Math.max(0, braceDepth - 1); + + if (c == ',' && parenDepth == 0 && bracketDepth == 0 && braceDepth == 0) { + return i; + } + + i++; + } + + return endLimit; + } + + /** + * Scan forward to find the next top-level comma (skipping nested structures and strings). + * + *

Used to skip over unsupported property entries (computed keys, spread, etc.) + * and advance to the next property in the object literal.

+ * + * @param src the source text + * @param start where to start scanning + * @param endLimit the exclusive boundary + * @return the index just past the comma, or {@code endLimit} if no comma is found + */ + static int scanToNextTopLevelComma(String src, int start, int endLimit) { + int i = start; + int depth = 0; + boolean inString = false; + char stringChar = 0; + while (i < endLimit) { + char c = src.charAt(i); + if (inString) { + if (c == '\\') { + i += (i + 1 < endLimit) ? 2 : 1; + continue; + } + if (c == stringChar) { + inString = false; + } + i++; + continue; + } + if (c == '"' || c == '\'') { + inString = true; + stringChar = c; + i++; + continue; + } + if (c == '{' || c == '[' || c == '(') depth++; + else if (c == '}' || c == ']' || c == ')') depth = Math.max(0, depth - 1); + if (c == ',' && depth == 0) { + return i + 1; + } + i++; + } + return endLimit; + } + + /** + * Find the matching closing brace for an object literal within a standalone string. + * + *

Unlike {@code ScriptDocument.findMatchingBraceEndInDocument} which operates on the full + * document text with excluded-range awareness, this method works on an isolated string snippet + * and handles comments/strings inline.

+ * + * @param src the source text starting with '{' (or containing it) + * @return the index one past the matching '}', or {@code -1} if not found + */ + static int findMatchingObjectLiteralEnd(String src) { + int depth = 0; + boolean inString = false; + char stringChar = 0; + boolean inLineComment = false; + boolean inBlockComment = false; + + for (int i = 0; i < src.length(); i++) { + char c = src.charAt(i); + char next = (i + 1 < src.length()) ? src.charAt(i + 1) : 0; + + if (inLineComment) { + if (c == '\n' || c == '\r') { + inLineComment = false; + } + continue; + } + if (inBlockComment) { + if (c == '*' && next == '/') { + inBlockComment = false; + i++; + } + continue; + } + if (inString) { + if (c == '\\') { + i += (i + 1 < src.length()) ? 1 : 0; + continue; + } + if (c == stringChar) { + inString = false; + } + continue; + } + + if (c == '/' && next == '/') { + inLineComment = true; + i++; + continue; + } + if (c == '/' && next == '*') { + inBlockComment = true; + i++; + continue; + } + if (c == '"' || c == '\'') { + inString = true; + stringChar = c; + continue; + } + + if (c == '{') { + depth++; + } else if (c == '}') { + depth--; + if (depth == 0) { + return i + 1; + } + } + } + + return -1; + } +} diff --git a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/ScriptDocument.java b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/ScriptDocument.java index e6d715710..7f2a564cb 100644 --- a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/ScriptDocument.java +++ b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/ScriptDocument.java @@ -72,7 +72,7 @@ public class ScriptDocument { private static final Pattern KEYWORD_JS_PATTERN = Pattern.compile( "\\b(function|var|let|const|if|else|for|while|do|switch|case|break|continue|return|" + - "try|catch|finally|throw|new|typeof|instanceof|in|of|this|null|undefined|true|false|" + + "try|catch|finally|throw|delete|new|typeof|instanceof|in|of|this|null|undefined|true|false|" + "class|extends|import|export|default|async|await|yield)\\b"); // Declarations - Updated to capture method parameters @@ -1865,6 +1865,31 @@ public class ScriptDocument { return pos; } + /** + * Scans forward from {@code rhsStart} to find where a JavaScript initializer expression ends. + * + * Unlike Java, JS has no mandatory semicolons, so this method implements a conservative + * ASI (Automatic Semicolon Insertion) heuristic: + *
    + *
  • A {@code ;} at depth 0 always terminates the expression.
  • + *
  • A newline while inside open parens, brackets, or braces does NOT terminate — + * the expression continues on the next line (e.g. multi-line object literals, + * chained calls).
  • + *
  • A newline at depth 0 terminates only if {@link #shouldContinueJsInitializer} + * says the line break is NOT a continuation. Continuation is inferred from + * the current line ending with an operator/open-bracket, or the next line + * starting with {@code .}, {@code ?.}, or {@code [}.
  • + *
+ * + * String literals (single/double-quoted) and both comment forms ({@code //} and + * {@code /* ... *\/}) are skipped so their contents never affect depth tracking. + * + * @param source the full source text to scan + * @param rhsStart the index to begin scanning (typically the character after {@code =}) + * @return the index of the terminating character (a {@code ;} or newline), or + * {@code source.length()} if the source ends before a terminator is found. + * Returns {@code rhsStart} unchanged if {@code rhsStart} is out of bounds. + */ private int findJsInitializerEnd(String source, int rhsStart) { if (rhsStart < 0 || rhsStart >= source.length()) { return rhsStart; @@ -1872,9 +1897,84 @@ public class ScriptDocument { int pos = rhsStart; int lineExprStart = rhsStart; + + int parenDepth = 0; + int bracketDepth = 0; + int braceDepth = 0; + + boolean inString = false; + char stringChar = 0; + boolean inLineComment = false; + boolean inBlockComment = false; + while (pos < source.length()) { char c = source.charAt(pos); - if (c == ';') { + char next = (pos + 1 < source.length()) ? source.charAt(pos + 1) : 0; + + if (inLineComment) { + if (c == '\n' || c == '\r') { + inLineComment = false; + } else { + pos++; + continue; + } + } + + if (inBlockComment) { + if (c == '*' && next == '/') { + inBlockComment = false; + pos += 2; + continue; + } + pos++; + continue; + } + + if (inString) { + if (c == '\\') { + pos += (pos + 1 < source.length()) ? 2 : 1; + continue; + } + if (c == stringChar) { + inString = false; + } + pos++; + continue; + } + + if (c == '/' && next == '/') { + inLineComment = true; + pos += 2; + continue; + } + if (c == '/' && next == '*') { + inBlockComment = true; + pos += 2; + continue; + } + + if (c == '"' || c == '\'') { + inString = true; + stringChar = c; + pos++; + continue; + } + + if (c == '(') { + parenDepth++; + } else if (c == ')') { + parenDepth = Math.max(0, parenDepth - 1); + } else if (c == '[') { + bracketDepth++; + } else if (c == ']') { + bracketDepth = Math.max(0, bracketDepth - 1); + } else if (c == '{') { + braceDepth++; + } else if (c == '}') { + braceDepth = Math.max(0, braceDepth - 1); + } + + if (c == ';' && parenDepth == 0 && bracketDepth == 0 && braceDepth == 0) { return pos; } @@ -1885,6 +1985,12 @@ public class ScriptDocument { nextPos++; } + if (parenDepth != 0 || bracketDepth != 0 || braceDepth != 0) { + pos = nextPos; + lineExprStart = nextPos; + continue; + } + int currentLineEnd = lineBreakPos; while (currentLineEnd > lineExprStart && Character.isWhitespace(source.charAt(currentLineEnd - 1))) { currentLineEnd--; @@ -1893,17 +1999,17 @@ public class ScriptDocument { int nextExprStart = nextPos; while (nextExprStart < source.length()) { - char next = source.charAt(nextExprStart); - if (next == ' ' || next == '\t') { + char n = source.charAt(nextExprStart); + if (n == ' ' || n == '\t') { nextExprStart++; continue; } - if (next == ';') { + if (n == ';') { return nextExprStart; } - if (next == '\n' || next == '\r') { + if (n == '\n' || n == '\r') { nextExprStart++; - if (next == '\r' && nextExprStart < source.length() && source.charAt(nextExprStart) == '\n') { + if (n == '\r' && nextExprStart < source.length() && source.charAt(nextExprStart) == '\n') { nextExprStart++; } continue; @@ -1917,8 +2023,8 @@ public class ScriptDocument { int nextLineEnd = nextExprStart; while (nextLineEnd < source.length()) { - char next = source.charAt(nextLineEnd); - if (next == '\n' || next == '\r' || next == ';') { + char n = source.charAt(nextLineEnd); + if (n == '\n' || n == '\r' || n == ';') { break; } nextLineEnd++; @@ -3052,7 +3158,11 @@ public class ScriptDocument { // Numbers - same for both languages addPatternMarks(marks, NUMBER_PATTERN, TokenType.LITERAL); - + + if (isJavaScript()) { + markObjectLiteralKeys(marks); + } + // Import statements - Java only if (!isJavaScript()) { markImports(marks); @@ -3109,7 +3219,94 @@ public class ScriptDocument { return marks; } - + + private void markObjectLiteralKeys(List marks) { + int i = 0; + while (i < text.length()) { + int brace = text.indexOf('{', i); + if (brace < 0) { + return; + } + i = brace; + if (isExcluded(brace) || !isLikelyObjectLiteralStart(brace)) { + i++; + continue; + } + + int end = findMatchingBraceEndInDocument(brace); + if (end <= brace) { + i++; + continue; + } + + String objectLiteral = text.substring(brace, end); + ObjectLiteralParser.ObjectLiteralAnalysis analysis = ObjectLiteralParser.parse(objectLiteral, brace, false, true, null); + if (analysis != null) { + for (ObjectLiteralParser.ObjectLiteralProperty p : analysis.properties) { + if (p.isIdentifierKey) { + marks.add(new ScriptLine.Mark(p.keyStartAbs, p.keyEndAbs, TokenType.LOCAL_FIELD)); + } + } + } + + i = end; + } + } + + private boolean isLikelyObjectLiteralStart(int bracePos) { + int prev = bracePos - 1; + while (prev >= 0 && Character.isWhitespace(text.charAt(prev))) { + prev--; + } + if (prev < 0) { + return false; + } + + char pc = text.charAt(prev); + if (pc == '=' || pc == '(' || pc == '[' || pc == ',' || pc == ':' || pc == '?' || pc == '!' || + pc == '+' || pc == '-' || pc == '*' || pc == '/' || pc == '%' || pc == '&' || pc == '|' || + pc == '^') { + return true; + } + + if (Character.isJavaIdentifierPart(pc)) { + int end = prev + 1; + int start = prev; + while (start >= 0 && Character.isJavaIdentifierPart(text.charAt(start))) { + start--; + } + start++; + String word = text.substring(start, end); + return "return".equals(word); + } + + return false; + } + + private int findMatchingBraceEndInDocument(int braceStart) { + int depth = 0; + for (int pos = braceStart; pos < text.length(); pos++) { + if (isExcluded(pos)) { + continue; + } + char c = text.charAt(pos); + if (c == '{') { + depth++; + } else if (c == '}') { + depth--; + if (depth == 0) { + return pos + 1; + } + } + } + return -1; + } + + + + + + /** * Mark lambda arrow (->) and method reference (::) operators. */ @@ -3576,6 +3773,11 @@ public class ScriptDocument { "static", "final", "abstract", "synchronized", "native", "default", "enum", "throws", "super", "assert", "volatile", "transient" )); + + if (isJavaScript()) { + knownKeywords.add("delete"); + knownKeywords.add("undefined"); + } // Find all identifiers Pattern identifier = Pattern.compile("\\b([a-zA-Z_][a-zA-Z0-9_]*)\\b"); @@ -4943,10 +5145,20 @@ public class ScriptDocument { if (expr.isEmpty()) return null; + + if (isJavaScript() && expr.charAt(0) == '{') { + ObjectLiteralParser.ObjectLiteralAnalysis analysis = ObjectLiteralParser.parse(expr, position, true, false, this::resolveExpressionType); + if (analysis == null) { + return TypeInfo.ANY; + } + if (!analysis.supportsInference || analysis.inferredType == null) { + return TypeInfo.ANY; + } + return analysis.inferredType; } // Check if expression contains operators - if so, use the full expression resolver - // Handle cast expressions: (Type)expr, ((Type)expr).method(), etc. + // Handle cast expressions: (Type)expr, ((Type)expr).method(), etc // Also route JS function expressions and arrow lambdas through the parser if (containsOperators(expr) || expr.contains("::") || expr.contains("->") || expr.startsWith("(") || looksLikeFunctionOrLambda(expr)) { return resolveExpressionWithParserAPI(expr, position); @@ -5066,15 +5278,11 @@ public class ScriptDocument { String fieldName = segments.get(1).name; if (globalFields.containsKey(fieldName)) { currentType = globalFields.get(fieldName).getTypeInfo(); - if (currentType != null && segments.get(1).hasArrayAccess) { - currentType = unwrapArrayElement(currentType); - } + currentType = applyBracketAccess(currentType, segments.get(1)); // Continue from segment 2 for (int i = 2; i < segments.size(); i++) { currentType = resolveChainSegment(currentType, segments.get(i)); - if (currentType != null && segments.get(i).hasArrayAccess) { - currentType = unwrapArrayElement(currentType); - } + currentType = applyBracketAccess(currentType, segments.get(i)); if (currentType == null) return null; } return currentType; @@ -5126,16 +5334,12 @@ public class ScriptDocument { } } - if (currentType != null && first.hasArrayAccess) { - currentType = unwrapArrayElement(currentType); - } + currentType = applyBracketAccess(currentType, first); // Resolve the rest of the chain for (int i = 1; i < segments.size(); i++) { currentType = resolveChainSegment(currentType, segments.get(i)); - if (currentType != null && segments.get(i).hasArrayAccess) { - currentType = unwrapArrayElement(currentType); - } + currentType = applyBracketAccess(currentType, segments.get(i)); if (currentType == null) { return null; } @@ -5233,11 +5437,13 @@ public class ScriptDocument { } boolean hasArrayAccess = false; + String bracketKey = null; while (i < expr.length() && Character.isWhitespace(expr.charAt(i))) { i++; } if (i < expr.length() && expr.charAt(i) == '[') { hasArrayAccess = true; + int bracketStart = i + 1; int depth = 1; i++; while (i < expr.length() && depth > 0) { @@ -5246,9 +5452,16 @@ public class ScriptDocument { else if (c == ']') depth--; i++; } + String bracketContent = expr.substring(bracketStart, i - 1).trim(); + if (bracketContent.length() >= 2) { + char q = bracketContent.charAt(0); + if ((q == '"' || q == '\'') && bracketContent.charAt(bracketContent.length() - 1) == q) { + bracketKey = bracketContent.substring(1, bracketContent.length() - 1); + } + } } - - segments.add(new ChainSegment(name, start, i, isMethodCall, arguments, hasArrayAccess)); + + segments.add(new ChainSegment(name, start, i, isMethodCall, arguments, hasArrayAccess, bracketKey)); // Skip whitespace while (i < expr.length() && Character.isWhitespace(expr.charAt(i))) { @@ -5365,21 +5578,23 @@ public class ScriptDocument { final int start; final int end; final boolean isMethodCall; - final String arguments; // The text between parentheses for method calls, or null for fields + final String arguments; final boolean hasArrayAccess; - - ChainSegment(String name, int start, int end, boolean isMethodCall, String arguments, boolean hasArrayAccess) { + final String bracketKey; + + ChainSegment(String name, int start, int end, boolean isMethodCall, String arguments, boolean hasArrayAccess, String bracketKey) { this.name = name; this.start = start; this.end = end; this.isMethodCall = isMethodCall; this.arguments = arguments; this.hasArrayAccess = hasArrayAccess; + this.bracketKey = bracketKey; } } /** - * + * */ private String stripLineComments(String expr) { if (!expr.contains("//")) @@ -5397,7 +5612,22 @@ public class ScriptDocument { } return result.toString().trim(); } - + + /** + * Handle ObjectLiteral bracket access (e.g., obj["field"] or obj[fieldName]) and array access (e.g., items[0]). + * @param type + * @param seg + * @return + */ + private TypeInfo applyBracketAccess(TypeInfo type, ChainSegment seg) { + if (type == null || !seg.hasArrayAccess) return type; + if (seg.bracketKey != null) { + FieldInfo f = type.getFieldInfo(seg.bracketKey); + return f != null ? f.getTypeInfo() : TypeInfo.ANY; + } + if (type.isSyntheticObjectLiteralType()) return TypeInfo.ANY; + return unwrapArrayElement(type); + } /** * Extract the element type from an array type, using multiple fallback strategies. * @@ -5927,15 +6157,11 @@ public class ScriptDocument { } } - if (currentType != null && first.hasArrayAccess) { - currentType = unwrapArrayElement(currentType); - } + currentType = applyBracketAccess(currentType, first); for (int i = 1; i < segments.size(); i++) { currentType = resolveChainSegment(currentType, segments.get(i)); - if (currentType != null && segments.get(i).hasArrayAccess) { - currentType = unwrapArrayElement(currentType); - } + currentType = applyBracketAccess(currentType, segments.get(i)); if (currentType == null) { return null; } @@ -6499,6 +6725,11 @@ public class ScriptDocument { "throws", "super", "assert", "volatile", "transient" )); + if (isJavaScript()) { + knownKeywords.add("delete"); + knownKeywords.add("undefined"); + } + // First pass: mark method parameters in their declaration positions List allMethods = getAllMethods(); allMethods.addAll(getAllConstructors()); @@ -6577,17 +6808,19 @@ public class ScriptDocument { continue; } - // Check local variables (method scope) + FieldInfo localInfo = null; if (containingMethod != null) { Map> locals = methodLocals.get(containingMethod.getDeclarationOffset()); if (locals != null) { - FieldInfo localInfo = pickVisibleLocal(locals.get(name), position); - if (localInfo != null) { - Object metadata = callInfo != null ? new FieldInfo.ArgInfo(localInfo, callInfo) : localInfo; - marks.add(new ScriptLine.Mark(m.start(1), m.end(1), TokenType.LOCAL_FIELD, metadata)); - continue; - } + localInfo = pickVisibleLocal(locals.get(name), position); } + } else { + localInfo = pickVisibleTopLevelLocal(name, position); + } + if (localInfo != null) { + Object metadata = callInfo != null ? new FieldInfo.ArgInfo(localInfo, callInfo) : localInfo; + marks.add(new ScriptLine.Mark(m.start(1), m.end(1), TokenType.LOCAL_FIELD, metadata)); + continue; } // Check enclosing type fields (when inside method) @@ -6704,6 +6937,11 @@ public class ScriptDocument { } } } + for (List fields : topLevelLocals.values()) { + for (FieldInfo field : fields) { + field.clearAssignments(); + } + } // Also clear assignments in script type fields for (ScriptTypeInfo scriptType : scriptTypes.values()) { for (FieldInfo field : scriptType.getFields().values()) { @@ -6838,6 +7076,8 @@ public class ScriptDocument { private void createAndAttachAssignment(String lhs, String rhs, int stmtStart, int equalsPos, int stmtEnd) { lhs = lhs.trim(); rhs = rhs.trim(); + + ObjectLiteralParser.DynamicPropertyAccess dynamicPropertyAccess = isJavaScript() ? ObjectLiteralParser.parseDynamicPropertyAccess(lhs, stmtStart) : null; // Parse the target (LHS) // Could be: varName or obj.field or this.field or array[index] @@ -6902,8 +7142,23 @@ public class ScriptDocument { // Extract the variable name before the bracket String varName = lhs.substring(0, lhs.indexOf('[')).trim(); targetName = varName; - targetField = resolveVariable(varName, lhsStart); - if (targetField != null) { + if (dynamicPropertyAccess != null) { + ObjectLiteralParser.DynamicFieldResult res = ObjectLiteralParser.resolveExistingField( + dynamicPropertyAccess, this::resolveExpressionType, this::resolveVariable); + receiverType = res.receiverType; + targetName = res.propertyName; + targetField = res.field; + if (targetField != null) { + targetType = targetField.getTypeInfo(); + reflectionField = targetField.getReflectionField(); + } + } + + if (targetField == null) { + targetField = resolveVariable(varName, lhsStart); + } + + if (targetField != null && targetType == null) { // The variable type is the array type (e.g., ItemStack[]) // Unwrap to get the element type (e.g., ItemStack) targetType = unwrapArrayElement(targetField.getTypeInfo()); @@ -6926,6 +7181,21 @@ public class ScriptDocument { } finally { ExpressionTypeResolver.CURRENT_EXPECTED_TYPE = null; } + + if (dynamicPropertyAccess != null) { + ObjectLiteralParser.DynamicFieldResult res = ObjectLiteralParser.extendAndGetField( + dynamicPropertyAccess, receiverType, sourceType, + this::resolveExpressionType, this::resolveVariable); + if (res != null) { + receiverType = res.receiverType; + targetName = res.propertyName; + targetField = res.field; + if (targetField != null) { + targetType = targetField.getTypeInfo(); + reflectionField = targetField.getReflectionField(); + } + } + } // Type inference: If the target field has "any" type and no inferred type yet, // set the inferred type from the resolved source type (first assignment wins) @@ -7969,7 +8239,7 @@ public class ScriptDocument { public Map getGlobalFields() { return Collections.unmodifiableMap(globalFields); } - + /** * Get all variables available at a specific position. * Used by autocomplete to show scope-aware suggestions. diff --git a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/type/TypeChecker.java b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/type/TypeChecker.java index e0e11bdfd..ea3966382 100644 --- a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/type/TypeChecker.java +++ b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/type/TypeChecker.java @@ -538,7 +538,7 @@ public final class TypeChecker { String[] keywords = { "function", "var", "let", "const", "if", "else", "for", "while", "do", "switch", "case", "break", "continue", "return", "try", "catch", "finally", - "throw", "new", "typeof", "instanceof", "in", "of", "this", "null", + "throw", "delete", "new", "typeof", "instanceof", "in", "of", "this", "null", "undefined", "true", "false", "async", "await", "yield", "class", "extends", "import", "export", "default" }; diff --git a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/type/TypeInfo.java b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/type/TypeInfo.java index 864a02bd2..1d15032a0 100644 --- a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/type/TypeInfo.java +++ b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/type/TypeInfo.java @@ -1,5 +1,6 @@ package noppes.npcs.client.gui.util.script.interpreter.type; +import noppes.npcs.client.gui.util.script.interpreter.ObjectLiteralParser; import noppes.npcs.client.gui.util.script.interpreter.field.EnumConstantInfo; import noppes.npcs.client.gui.util.script.interpreter.field.FieldInfo; import noppes.npcs.client.gui.util.script.interpreter.js_parser.JSFieldInfo; @@ -18,6 +19,7 @@ import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.function.Supplier; /** * Represents resolved type information for a class/interface/enum. @@ -69,6 +71,9 @@ public class TypeInfo { */ public static final TypeInfo ANY = new TypeInfo("any", "any", "", Kind.CLASS, null, true, null).setPrimitive(true); public static final TypeInfo NUMBER = new TypeInfo("number", "number", "", Kind.CLASS, double.class, true, null).setPrimitive(true); + + public static final Supplier OBJECT_LITERAL = () -> new TypeInfo("ObjectLiteral", "ObjectLiteral", "", + Kind.CLASS, null, true, null); private final String simpleName; // e.g., "List", "ColorType" @@ -80,6 +85,7 @@ public class TypeInfo { private final TypeInfo enclosingType; // For inner classes, the outer type (null if top-level) private boolean isPrimitive; // Whether this is a primitive type (e.g., int, boolean) + private boolean isObjectLiteral; // JavaScript/TypeScript type info (for types from .d.ts files) private final JSTypeInfo jsTypeInfo; // The JS type info (null if Java type) @@ -325,6 +331,12 @@ public class TypeInfo { */ public static TypeInfo fromJSTypeInfo(JSTypeInfo jsType) { if (jsType == null) return null; + + String syntheticDisplayName = ObjectLiteralParser.getSyntheticObjectLiteralDisplayName(jsType); + if (syntheticDisplayName != null) { + return new TypeInfo(syntheticDisplayName, jsType.getFullName(), "", + Kind.INTERFACE, null, true, null, jsType); + } String simpleName = jsType.getSimpleName(); String fullName = jsType.getFullName(); @@ -334,7 +346,24 @@ public class TypeInfo { return new TypeInfo(simpleName, fullName, namespace != null ? namespace : "", Kind.INTERFACE, null, true, null, jsType); } - + + public static TypeInfo objectLiteral(List properties) { + TypeInfo t = OBJECT_LITERAL.get(); + t.isObjectLiteral = true; + if (properties != null) { + for (ObjectLiteralParser.ObjectLiteralProperty p : properties) { + TypeInfo vt = (p.valueType != null) ? p.valueType : TypeInfo.ANY; + t.syntheticFields.add(FieldInfo.external(p.keyName, vt, null, Modifier.PUBLIC)); + } + } + return t; + } + + public void addOrUpdateSyntheticField(String name, TypeInfo type) { + syntheticFields.removeIf(f -> f.getName().equals(name)); + syntheticFields.add(FieldInfo.external(name, type, null, Modifier.PUBLIC)); + } + public static void injectJSMembers(TypeInfo target, JSTypeInfo jsExtensions) { for (JSFieldInfo jsField : jsExtensions.getFields().values()) target.syntheticFields.add(FieldInfo.fromJSField(jsField, target)); @@ -414,6 +443,10 @@ public class TypeInfo { public boolean isJSType() { return jsTypeInfo != null; } + + public boolean isSyntheticObjectLiteralType() { + return isObjectLiteral; + } /** * Get the underlying JSTypeInfo if this is a JS type.