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.
+ *
+ * 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.