Object Literals AKA 'dict'

Phase #1: Fields
This commit is contained in:
bigguy345 committed 2026-03-07 15:56:37 +02:00
1 parent c00ad8e0a7
commit f01bc0a117
4 files changed
+1166 -49

No files matched your search

@@ -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.
*
* <h3>Purpose</h3>
* Encapsulates the logic for:
* <ul>
* <li>Parsing object literal syntax ({@code { key: value, ... }}) into structured property lists.</li>
* <li>Inferring synthetic {@link TypeInfo} shapes from object literal contents for hover/autocomplete.</li>
* <li>Parsing dynamic property accesses ({@code obj.prop}, {@code obj["prop"]}) on the assignment LHS.</li>
* <li>Extending synthetic object shapes when new properties are assigned after initial declaration.</li>
* <li>Detecting synthetic object literal display names for UI rendering.</li>
* </ul>
*
* <h3>V1 Scope</h3>
* <ul>
* <li>Only simple identifier keys and quoted-string keys are supported. Computed keys ({@code [expr]: val})
* and spread ({@code ...obj}) are recognized but gracefully skipped.</li>
* <li>Nested object literals inside values degrade to {@link TypeInfo#ANY} rather than recursive inference.</li>
* <li>Value type inference delegates to the caller-supplied {@link ExpressionTypeResolverFn}; this class
* does not resolve types itself.</li>
* </ul>
*
* <h3>Degradation Behavior</h3>
* 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.
*
* <h3>Architecture Note</h3>
* 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.
*
* <p>Contains the parsed properties, whether full type inference is feasible,
* and (when inference succeeded) the synthetic {@link TypeInfo} representing the object's shape.</p>
*/
public static final class ObjectLiteralAnalysis {
/** Parsed properties (key name, offsets, optional inferred value type). */
public final List<ObjectLiteralProperty> 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<ObjectLiteralProperty> properties, boolean supportsInference, TypeInfo inferredType) {
this.properties = properties;
this.supportsInference = supportsInference;
this.inferredType = inferredType;
}
}
/**
* A single property inside an object literal.
*
* <p>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).</p>
*/
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.
*
* <p>Two forms are supported:</p>
* <ul>
* <li>Dot access: {@code obj.prop} → {@code receiverExpr="obj", propertyName="prop", bracketAccess=false}</li>
* <li>Bracket access: {@code obj["prop"]} → {@code receiverExpr="obj", propertyName="prop", bracketAccess=true}</li>
* </ul>
*/
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.
*
* <p>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.</p>
*
* @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<ObjectLiteralProperty> 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"}).
*
* <p>Synthetic object literal types are created by {@link #parse} with
* names of the form {@code __ObjectLiteral$<offset>} under the {@code __synthetic__} namespace.
* This method recognizes that naming convention and maps it to a human-readable display name.</p>
*
* @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.
*
* <p>Recognizes two forms:</p>
* <ul>
* <li>Dot access: {@code obj.prop} — the last dot-separated segment is treated as the property name.</li>
* <li>Bracket access: {@code obj["prop"]} or {@code obj['prop']} — the quoted string inside brackets
* is treated as the property name.</li>
* </ul>
*
* <p>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}.</p>
*
* @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.
*
* <p>Handles both single and double quotes, plus basic backslash escapes.
* Returns {@code null} if the expression is not a properly quoted string.</p>
*
* @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).
*
* <p>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.</p>
*
* <p>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.</p>
*
* @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}.
*
* <p>Handles backslash escapes. The returned index points one past the closing quote character.</p>
*
* @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.
*
* <p>Respects nested braces, brackets, parentheses, string literals, and comments.
* Stops at a top-level comma or the {@code endLimit}.</p>
*
* @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).
*
* <p>Used to skip over unsupported property entries (computed keys, spread, etc.)
* and advance to the next property in the object literal.</p>
*
* @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.
*
* <p>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.</p>
*
* @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;
}
}
@@ -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:
* <ul>
* <li>A {@code ;} at depth 0 always terminates the expression.</li>
* <li>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).</li>
* <li>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 [}.</li>
* </ul>
*
* 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<ScriptLine.Mark> 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<MethodInfo> 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<String, List<FieldInfo>> 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<FieldInfo> 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<String, FieldInfo> getGlobalFields() {
return Collections.unmodifiableMap(globalFields);
}
/**
* Get all variables available at a specific position.
* Used by autocomplete to show scope-aware suggestions.
@@ -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"
};
@@ -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<TypeInfo> 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<ObjectLiteralParser.ObjectLiteralProperty> 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.